From daa7e914e2ab616a1024d62e15799b1cf17f2747 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 19 Jun 2026 16:44:29 +0200 Subject: [PATCH 001/166] add the possibility to compute a contingency analysis even on grid split apart in the majority of cases Signed-off-by: DONNOT Benjamin --- lightsim2grid/contingencyAnalysis.py | 19 +- .../tests/test_ContingencyAnalysis_split.py | 218 ++++++++++++++++++ lightsim2grid/tests/test_storage_pypowsybl.py | 114 +++++++++ src/bindings/python/binding_batch.cpp | 9 + src/core/AlgorithmSelector.hpp | 8 + .../batch_algorithm/ContingencyAnalysis.cpp | 216 +++++++++++++++-- .../batch_algorithm/ContingencyAnalysis.hpp | 30 +++ src/core/powerflow_algorithm/BaseAlgo.hpp | 8 + src/core/powerflow_algorithm/NRAlgo.hpp | 6 + src/core/powerflow_algorithm/NRSystem.hpp | 57 +++++ src/core/powerflow_algorithm/NRSystem.tpp | 20 ++ 11 files changed, 684 insertions(+), 21 deletions(-) create mode 100644 lightsim2grid/tests/test_ContingencyAnalysis_split.py create mode 100644 lightsim2grid/tests/test_storage_pypowsybl.py diff --git a/lightsim2grid/contingencyAnalysis.py b/lightsim2grid/contingencyAnalysis.py index e2b7bf71..17cc3c9f 100644 --- a/lightsim2grid/contingencyAnalysis.py +++ b/lightsim2grid/contingencyAnalysis.py @@ -76,6 +76,13 @@ class __ContingencyAnalysis(object): In grid2op, it would be, in this case, 0. for the flows and 0. for the voltages. + By default, a contingency that splits the grid in multiple connected components is not + simulated (its voltages are left at 0.). If you set the `handle_disconnected_grid` attribute + to ``True`` (it requires a Newton-Raphson algorithm), such contingencies are instead simulated + on their largest connected component: the buses of the other component(s) are "masked" and + their voltage is reported as 0. This is done without triggering any extra matrix + re-factorization (the symbolic factorization of the solver is reused). + """ STR_TYPES = (str, np.str_) # np.str deprecated in numpy 1.20 and earlier versions not supported anyway @@ -131,7 +138,17 @@ def init_from_n_powerflow(self, val: bool): if bool(val) != val: raise ValueError("The `init_from_n_powerflow` attribute must be a boolean.") self.computer.init_from_n_powerflow = bool(val) - + + @property + def handle_disconnected_grid(self): + return self.computer.handle_disconnected_grid + + @handle_disconnected_grid.setter + def handle_disconnected_grid(self, val: bool): + if bool(val) != val: + raise ValueError("The `handle_disconnected_grid` attribute must be a boolean.") + self.computer.handle_disconnected_grid = bool(val) + # TODO implement that ! def __update_grid(self, backend_act): raise NotImplementedError("TODO !") diff --git a/lightsim2grid/tests/test_ContingencyAnalysis_split.py b/lightsim2grid/tests/test_ContingencyAnalysis_split.py new file mode 100644 index 00000000..887dd443 --- /dev/null +++ b/lightsim2grid/tests/test_ContingencyAnalysis_split.py @@ -0,0 +1,218 @@ +# Copyright (c) 2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Tests for the `handle_disconnected_grid` mode of the contingency analysis. + +When this mode is ON, a contingency that splits the grid into several connected +components is no longer skipped: the largest component is solved while the buses +of the other component(s) are "masked" (their NR equations are forced to identity +and their voltage reported as 0), without any extra symbolic factorization. +""" + +import unittest +import warnings +import numpy as np +import pandapower as pp + +from lightsim2grid.gridmodel import init_from_pandapower +from lightsim2grid.lightsim2grid_cpp import ContingencyAnalysisCPP +from lightsim2grid.algorithm import AlgorithmType + + +def _line(net, f, t): + pp.create_line_from_parameters(net, f, t, length_km=1., + r_ohm_per_km=0.1, x_ohm_per_km=0.3, + c_nf_per_km=0., max_i_ka=1.) + + +def _build_radial(n_bus, with_island=True): + """A simple radial grid 0-1-2(-3). Disconnecting the last line (id 2) + isolates bus 3. `with_island=False` builds the reference grid (no bus 3).""" + net = pp.create_empty_network(sn_mva=1.) + nb = n_bus if with_island else 3 + for _ in range(nb): + pp.create_bus(net, vn_kv=20.) + pp.create_ext_grid(net, 0, vm_pu=1.0) + _line(net, 0, 1) # line 0 + _line(net, 1, 2) # line 1 + pp.create_load(net, 1, p_mw=1.0, q_mvar=0.2) + pp.create_load(net, 2, p_mw=1.0, q_mvar=0.2) + if with_island: + _line(net, 2, 3) # line 2 (splits the grid -> isolates bus 3) + pp.create_load(net, 3, p_mw=1.0, q_mvar=0.2) + return net + + +class TestContingencySplitMode(unittest.TestCase): + def setUp(self): + self.max_it = 30 + self.tol = 1e-8 + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.grid = init_from_pandapower(_build_radial(4)) + self.ref = init_from_pandapower(_build_radial(4, with_island=False)) + self.V0 = np.ones(self.grid.get_bus_vn_kv().shape[0], dtype=complex) + # reference voltages for the surviving component {0, 1, 2} + self.Vref = self.ref.ac_pf(np.ones(self.ref.get_bus_vn_kv().shape[0], dtype=complex), + self.max_it, self.tol) + assert self.Vref.size == 3, "reference powerflow did not converge" + + def _run(self, handle_disconnected): + SA = ContingencyAnalysisCPP(self.grid) + SA.add_n1(2) # disconnect line 2 -> isolates bus 3 + SA.handle_disconnected_grid = handle_disconnected + SA.compute(1. * self.V0, self.max_it, self.tol) + return SA.get_voltages().copy() + + def test_default_is_off(self): + SA = ContingencyAnalysisCPP(self.grid) + assert SA.handle_disconnected_grid is False + + def test_flag_off_skips_split(self): + # legacy behaviour: a splitting contingency is not simulated -> all 0. + v = self._run(False) + assert np.max(np.abs(v[0])) == 0., f"split contingency should be skipped, got {v[0]}" + + def test_flag_on_solves_main_component(self): + v = self._run(True) + # the surviving component {0, 1, 2} matches the reference powerflow + assert np.max(np.abs(v[0, :3] - self.Vref)) <= 1e-6, \ + f"main component mismatch: {np.max(np.abs(v[0, :3] - self.Vref)):.2e}" + # the isolated bus 3 is masked -> reported as exactly 0 + assert v[0, 3] == 0., f"masked bus should be 0, got {v[0, 3]}" + + def test_masked_setter_validates_bool(self): + SA = ContingencyAnalysisCPP(self.grid) + # the C++ property only accepts a bool; a clean round-trip is enough here + SA.handle_disconnected_grid = True + assert SA.handle_disconnected_grid is True + SA.handle_disconnected_grid = False + assert SA.handle_disconnected_grid is False + + def test_error_on_non_nr_ac_algorithm(self): + # the mode requires an NR algorithm: an AC non-NR solver must be rejected + SA = ContingencyAnalysisCPP(self.grid) + SA.change_algorithm(AlgorithmType.GaussSeidel) + SA.add_n1(2) + SA.handle_disconnected_grid = True + with self.assertRaises(RuntimeError): + SA.compute(1. * self.V0, self.max_it, self.tol) + + def test_dc_flag_on_is_a_noop(self): + # DC handles connectivity internally: enabling the flag must not raise and + # must keep the legacy DC behaviour (no exception, computation runs). + SA = ContingencyAnalysisCPP(self.grid) + SA.change_algorithm(AlgorithmType.DC_SparseLU) + SA.add_n1(2) + SA.handle_disconnected_grid = True + SA.compute(1. * self.V0, self.max_it, self.tol) # must not raise + + +class TestContingencySplitMultiSlack(unittest.TestCase): + """Two slacks at the two ends of a line; a contingency strands one of them. + + Topology: ext_grid@0 - 1 - 2 - 3 - ext_grid@4. Disconnecting line 2 (2-3) + splits it into {0,1,2} (with slack 0) and {3,4} (with slack 4). The largest + component {0,1,2} is solved; the stranded slack 4 has its weight zeroed. + """ + def setUp(self): + self.max_it = 30 + self.tol = 1e-8 + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.grid = init_from_pandapower(self._net(with_island=True)) + self.ref = init_from_pandapower(self._net(with_island=False)) + self.V0 = np.ones(self.grid.get_bus_vn_kv().shape[0], dtype=complex) + self.Vref = self.ref.ac_pf(np.ones(self.ref.get_bus_vn_kv().shape[0], dtype=complex), + self.max_it, self.tol) + assert self.Vref.size == 3 + + @staticmethod + def _net(with_island): + net = pp.create_empty_network(sn_mva=1.) + nb = 5 if with_island else 3 + for _ in range(nb): + pp.create_bus(net, vn_kv=20.) + pp.create_ext_grid(net, 0, vm_pu=1.0) + _line(net, 0, 1) # line 0 + _line(net, 1, 2) # line 1 + pp.create_load(net, 1, p_mw=1.0, q_mvar=0.2) + pp.create_load(net, 2, p_mw=1.0, q_mvar=0.2) + if with_island: + _line(net, 2, 3) # line 2 (splits the grid) + _line(net, 3, 4) # line 3 + pp.create_ext_grid(net, 4, vm_pu=1.0) # second slack, stranded by the split + pp.create_load(net, 3, p_mw=1.0, q_mvar=0.2) + return net + + def test_stranded_slack_is_masked(self): + SA = ContingencyAnalysisCPP(self.grid) + SA.add_n1(2) # isolates {3, 4} (which holds the second slack) + SA.handle_disconnected_grid = True + SA.compute(1. * self.V0, self.max_it, self.tol) + v = SA.get_voltages() + # surviving component {0,1,2} solved with slack 0 absorbing everything; + # the reference grid keeps only slack 0, so the voltages must match. + assert np.max(np.abs(v[0, :3] - self.Vref)) <= 1e-6, \ + f"main component mismatch: {np.max(np.abs(v[0, :3] - self.Vref)):.2e}" + # the stranded island {3, 4} is masked -> reported as 0 + assert np.max(np.abs(v[0, 3:5])) == 0., f"masked island should be 0, got {v[0, 3:5]}" + + +class TestContingencySplitCase14(unittest.TestCase): + """Integration test on l2rpn_case14_sandbox: enabling the mode must leave the + connected contingencies bit-identical and additionally solve at least one of + the contingencies that split the grid (skipped when the mode is off).""" + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + self.nb_sub = self.env.n_sub + + def tearDown(self): + self.env.close() + + def _voltages(self, handle_disconnected): + SA = ContingencyAnalysisCPP(self.env.backend._grid) + SA.add_all_n1() + SA.handle_disconnected_grid = handle_disconnected + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + return SA.get_voltages().copy() + + def test_regression_and_new_contingencies(self): + voff = self._voltages(False) + von = self._voltages(True) + nb_sub = self.nb_sub + + # contingencies solved when the mode is OFF (connected, converged) must be + # bit-identical when the mode is ON (their masked set is empty) + off_solved = np.array([np.any(np.abs(voff[i, :nb_sub]) > 1e-9) for i in range(voff.shape[0])]) + assert off_solved.any(), "sanity: some contingencies should be solved with the mode off" + assert np.max(np.abs(von[off_solved] - voff[off_solved])) == 0., \ + "connected contingencies must be unchanged when the mode is enabled" + + # at least one contingency skipped when OFF is now solved on its largest + # connected component when ON + newly_solved = [i for i in range(voff.shape[0]) + if (not off_solved[i]) and np.any(np.abs(von[i, :nb_sub]) > 1e-9)] + assert len(newly_solved) >= 1, "the mode should solve at least one split contingency" + + for i in newly_solved: + vm = np.abs(von[i, :nb_sub]) + live = vm[vm > 1e-9] + assert np.all(np.isfinite(von[i, :nb_sub])), f"cont {i}: non-finite voltage" + assert live.min() > 0.5 and live.max() < 1.5, \ + f"cont {i}: unrealistic voltages on the live component [{live.min()}, {live.max()}]" + assert np.sum(vm <= 1e-9) >= 1, f"cont {i}: expected at least one masked (0) bus" + + +if __name__ == "__main__": + unittest.main() diff --git a/lightsim2grid/tests/test_storage_pypowsybl.py b/lightsim2grid/tests/test_storage_pypowsybl.py new file mode 100644 index 00000000..dad6115e --- /dev/null +++ b/lightsim2grid/tests/test_storage_pypowsybl.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Storage-unit (battery) parity against PowSyBl OpenLoadFlow through the pypowsybl +converter. + +IIDM batteries use the *generator* convention for their `target_p` / `target_q` +setpoints (positive = produced), while lightsim2grid stores storage as PQ in the +*load* convention (positive = drawn from the grid). The converter negates the +setpoints; this test pins that sign down with a *non-zero* battery (the +`case_14_storage_iidm` fixture only has `targetP=0`, so it cannot catch a sign +regression). Skipped when pypowsybl is unavailable. +""" + +import unittest +import numpy as np + +try: + import pypowsybl as pp + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +def _build_net(target_p=50.0, target_q=10.0, connected=True): + """2-bus network: slack gen + line + load, and a battery on bus 2.""" + n = pp.network.create_empty() + n.create_substations(id=["S1", "S2"]) + n.create_voltage_levels(id=["VL1", "VL2"], substation_id=["S1", "S2"], + topology_kind=["BUS_BREAKER"] * 2, nominal_v=[400.0, 400.0]) + n.create_buses(id=["B1", "B2"], voltage_level_id=["VL1", "VL2"]) + n.create_lines(id="L", voltage_level1_id="VL1", voltage_level2_id="VL2", + bus1_id="B1", bus2_id="B2", r=1.0, x=20.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + n.create_generators(id="G", voltage_level_id="VL1", bus_id="B1", + target_p=100.0, target_q=0.0, target_v=400.0, + voltage_regulator_on=True, max_p=1000.0, min_p=0.0) + n.create_loads(id="LD", voltage_level_id="VL2", bus_id="B2", p0=80.0, q0=20.0) + kwargs = dict(id="BATT", voltage_level_id="VL2", bus_id="B2", + max_p=1000.0, min_p=-1000.0, target_p=target_p, target_q=target_q) + n.create_batteries(**kwargs) + if not connected: + n.update_batteries(id="BATT", connected=False) + return n + + +@unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") +class TestStoragePypowsybl(unittest.TestCase): + def setUp(self): + self.params = pp.loadflow.Parameters(distributed_slack=False, use_reactive_limits=False) + self.tol_v = 5e-6 + self.tol_a = 5e-6 + self.tol_pq = 1e-4 # MW / MVar + + def _run_ls(self, n): + n.per_unit = False + model = init_from_pypowsybl(n, gen_slack_id="G", sort_index=True) + nb_bus = len(model.get_bus_status()) + V = model.ac_pf(np.ones(nb_bus, dtype=np.complex128), 30, 1e-10) + self.assertGreater(V.shape[0], 0, "lightsim2grid diverged") + return model, V + + def test_one_storage_loaded(self): + model, _ = self._run_ls(_build_net(target_p=50.0, target_q=10.0)) + self.assertEqual(len(model.get_storages()), 1) + sto = model.get_storages()[0] + # IIDM generator convention target_p=+50 -> load convention -50 (discharging) + self.assertAlmostEqual(sto.target_p_mw, -50.0, places=4) + self.assertAlmostEqual(sto.target_q_mvar, -10.0, places=4) + + def test_parity_voltage(self): + n = _build_net(target_p=50.0, target_q=10.0) + ref = pp.loadflow.run_ac(n, parameters=self.params) + self.assertEqual(ref[0].status, pp.loadflow.ComponentStatus.CONVERGED) + n.per_unit = False + buses_si = n.get_buses() + vlevels = n.get_voltage_levels() + model, V = self._run_ls(n) + for i, (bus_id, row) in enumerate(buses_si.iterrows()): + nominal_v = vlevels.loc[row["voltage_level_id"], "nominal_v"] + v_olf = row["v_mag"] / nominal_v + a_olf = np.deg2rad(row["v_angle"]) + self.assertLess(abs(abs(V[i]) - v_olf), self.tol_v, f"V at {bus_id}") + self.assertLess(abs(np.angle(V[i]) - a_olf), self.tol_a, f"angle at {bus_id}") + + def test_parity_storage_pq_sign(self): + """lightsim2grid storage result p/q (load convention) must match the + pypowsybl battery output p/q columns (also load convention).""" + n = _build_net(target_p=50.0, target_q=10.0) + ref = pp.loadflow.run_ac(n, parameters=self.params) + self.assertEqual(ref[0].status, pp.loadflow.ComponentStatus.CONVERGED) + n.per_unit = False + batt = n.get_batteries() + p_olf = batt.loc["BATT", "p"] + q_olf = batt.loc["BATT", "q"] + model, _ = self._run_ls(n) + res_p, res_q, _ = model.get_storages_res() + self.assertLess(abs(res_p[0] - p_olf), self.tol_pq, f"storage p: ls={res_p[0]} olf={p_olf}") + self.assertLess(abs(res_q[0] - q_olf), self.tol_pq, f"storage q: ls={res_q[0]} olf={q_olf}") + + def test_disconnected_storage(self): + n = _build_net(target_p=50.0, target_q=10.0, connected=False) + model, V = self._run_ls(n) + self.assertEqual(len(model.get_storages()), 1) + self.assertFalse(model.get_storages_status()[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index 2c1889b6..036e7123 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -63,6 +63,15 @@ void bind_batch(py::module_& m) { "(*ie* a powerflow without any line disconnection) or not. " "Default: false, meaning each simulation is initialized " "with the given input vector)mydelim") + .def_property("handle_disconnected_grid", + [](const ContingencyAnalysis & self){ return self.get_handle_disconnected_grid(); }, + [](ContingencyAnalysis & self, bool val){ self.set_handle_disconnected_grid(val); }, + R"mydelim(Whether to simulate the contingencies that split the grid in " + "multiple connected components. When False (default) such contingencies " + "are skipped (their voltages are left at 0), reproducing the legacy " + "behaviour. When True, the largest connected component is solved while " + "the buses of the other component(s) are masked (their voltage is " + "reported as 0). Requires a Newton-Raphson algorithm.)mydelim") // solver control .def("change_algorithm", &ContingencyAnalysis::change_algorithm, DocLSGrid::change_algorithm.c_str()) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index b646c128..6371ae05 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -214,6 +214,14 @@ class LS2G_API AlgorithmSelector final get_prt_solver("tell_solver_control", false)->tell_solver_control(solver_control); } + // bus masking (ContingencyAnalysis "handle disconnected grid" mode) + bool supports_bus_masking() const { + return get_prt_solver("supports_bus_masking", false)->supports_bus_masking(); + } + void set_masked_buses(const std::vector& solver_bus_ids) { + get_prt_solver("set_masked_buses", false)->set_masked_buses(solver_bus_ids); + } + Eigen::SparseMatrix get_J_python() const { Eigen::SparseMatrix res = get_J(); return res; diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index def2f102..6a15cb0e 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -9,6 +9,7 @@ #include "ContingencyAnalysis.hpp" #include +#include #include /* isfinite */ namespace ls2g { @@ -46,6 +47,132 @@ bool ContingencyAnalysis::check_invertible(const Eigen::SparseMatrix return ok; } +std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatrix & Ybus) const{ + const int n = static_cast(Ybus.cols()); + std::vector comp_of_bus(n, -1); // connected-component label of each bus + int nb_comp = 0; + std::queue neighborhood; + for(int start = 0; start < n; ++start){ + if(comp_of_bus[start] != -1) continue; // already labelled + // BFS labelling the component that contains "start" + comp_of_bus[start] = nb_comp; + neighborhood.push(start); + while(!neighborhood.empty()){ + const int col_id = neighborhood.front(); + neighborhood.pop(); + for (Eigen::SparseMatrix::InnerIterator it(Ybus, col_id); it; ++it){ + const int row = static_cast(it.row()); + if(comp_of_bus[row] == -1 && abs(it.value()) > 1e-8){ + comp_of_bus[row] = nb_comp; + neighborhood.push(row); + } + } + } + ++nb_comp; + } + + if(nb_comp <= 1) return std::vector(); // grid is connected: nothing masked + + // find the largest component (by number of buses) + std::vector nb_bus_per_comp(nb_comp, 0); + for(int bus_id = 0; bus_id < n; ++bus_id) nb_bus_per_comp[comp_of_bus[bus_id]] += 1; + const int main_comp = static_cast(std::distance( + nb_bus_per_comp.begin(), + std::max_element(nb_bus_per_comp.begin(), nb_bus_per_comp.end()))); + + // every bus not in the main component is masked + std::vector masked; + masked.reserve(n - nb_bus_per_comp[main_comp]); + for(int bus_id = 0; bus_id < n; ++bus_id){ + if(comp_of_bus[bus_id] != main_comp) masked.push_back(bus_id); + } + return masked; +} + +void ContingencyAnalysis::select_ref_slack_and_masks(){ + const size_t nb_cont = _li_coeffs.size(); + _li_masked.assign(nb_cont, std::vector()); + _skip_mask.assign(nb_cont, 0); + + // 1) per-contingency masked bus set (largest component is kept). + // Work on a copy so the member Ybus_ used by the n-powerflow is left untouched. + { + Eigen::SparseMatrix Ybus = Ybus_; + size_t cont_id = 0; + for(const auto & coeffs_modif: _li_coeffs){ + for(const auto & c: coeffs_modif) Ybus.coeffRef(c.row_id, c.col_id) -= c.value; + _li_masked[cont_id] = disconnected_buses(Ybus); + for(const auto & c: coeffs_modif) Ybus.coeffRef(c.row_id, c.col_id) += c.value; + ++cont_id; + } + } + + // 2) candidate reference slacks (treated as solver bus ids, consistent with the + // rest of the batch path). Prefer the ones that actually carry slack power; if + // none, fall back to any slack (a 0-weight slack can still anchor the angle). + const Eigen::Index nb_slack = slack_ids_me_.size(); + std::vector candidates; + for(Eigen::Index i = 0; i < nb_slack; ++i){ + const int b = slack_ids_me_[static_cast(i)].cast_int(); + if(b >= 0 && b < slack_weights_.size() && slack_weights_(b) > 0.) candidates.push_back(b); + } + if(candidates.empty()){ + for(Eigen::Index i = 0; i < nb_slack; ++i) candidates.push_back(slack_ids_me_[static_cast(i)].cast_int()); + } + if(candidates.empty()) return; // no slack at all: nothing we can do + + // 3) choose the candidate stranded by the fewest contingencies (ties: larger + // slack weight, then smaller bus id) + auto is_masked = [](const std::vector & masked, int bus){ + return std::find(masked.begin(), masked.end(), bus) != masked.end(); + }; + int best_bus = candidates[0]; + int best_strand = -1; + real_type best_weight = -1.; + for(int bus : candidates){ + int strand = 0; + for(const auto & masked : _li_masked) if(is_masked(masked, bus)) ++strand; + const real_type weight = (bus < slack_weights_.size()) ? slack_weights_(bus) : 0.; + const bool better = (best_strand < 0) || + (strand < best_strand) || + (strand == best_strand && weight > best_weight) || + (strand == best_strand && weight == best_weight && bus < best_bus); + if(better){ best_strand = strand; best_weight = weight; best_bus = bus; } + } + + // 4) reorder slack_ids_me_ so the chosen reference is index 0 (the NR uses + // slack_ids[0] as the angle reference). Done before the (n-)powerflow so the + // symbolic factorization is built once with this reference. + { + std::vector reordered; + reordered.reserve(static_cast(nb_slack)); + reordered.push_back(best_bus); + for(Eigen::Index i = 0; i < nb_slack; ++i){ + const int b = slack_ids_me_[static_cast(i)].cast_int(); + if(b != best_bus) reordered.push_back(b); + } + slack_ids_me_ = GlobalBusIdVect(reordered); + } + + // 5) contingencies that still strand the chosen reference are skipped + for(size_t cont_id = 0; cont_id < nb_cont; ++cont_id){ + if(is_masked(_li_masked[cont_id], best_bus)) _skip_mask[cont_id] = 1; + } +} + +RealVect ContingencyAnalysis::masked_slack_weights(const std::vector & masked) const{ + RealVect w = slack_weights_; + if(masked.empty()) return w; + const real_type orig_sum = w.sum(); + for(int b : masked) if(b >= 0 && b < w.size()) w(b) = 0.; + const real_type new_sum = w.sum(); + // rescale so the slack power is shared only among the live slacks (sum preserved). + // if every slack-carrying bus is masked the vector stays as-is (degenerate: the + // solve will simply fail to converge and the contingency is reported as skipped). + if(new_sum > 1e-12 && orig_sum > 1e-12) w *= (orig_sum / new_sum); + return w; +} + void ContingencyAnalysis::init_li_coeffs( bool ac_solver_used, const SolverBusIdVect &id_me_to_solver) @@ -193,6 +320,20 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ init_li_coeffs(ac_solver_used, id_me_to_solver_); //////////////////////////////////// + // "handle disconnected grid" mode: pre-compute the masked bus set of each + // contingency and choose the reference slack (BEFORE the n-powerflow, so the + // symbolic factorization is built once with that reference and reused). + const bool mask_mode = _handle_disconnected_grid && ac_solver_used; + if(mask_mode){ + if(!_algo.supports_bus_masking()){ + throw std::runtime_error("ContingencyAnalysis: the `handle_disconnected_grid` mode " + "requires a Newton-Raphson algorithm (the active algorithm " + "does not support bus masking). Use `change_algorithm` to " + "select an NR solver (e.g. NR_KLU / NR_SLU)."); + } + select_ref_slack_and_masks(); + } + bool n_powerflow_has_conv = _finish_preprocessing( nb_steps, nb_total_bus, @@ -210,30 +351,65 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ bool conv; for(const auto & coeffs_modif: _li_coeffs){ auto timer_modif_Ybus = CustTimer(); - bool invertible = true; - invertible = remove_from_Ybus(Ybus_, coeffs_modif, ac_solver_used); - _timer_modif_Ybus += timer_modif_Ybus.duration(); + bool do_store = false; conv = false; - if(invertible) - { - V = Vinit_solver; // Vinit is reused for each contingencies - conv = compute_one_powerflow( - Ybus_, - V, - Sbus_, - slack_ids_me_.as_eigen(), - slack_weights_, - bus_pv_.as_eigen(), - bus_pq_.as_eigen(), - max_iter, - tol / sn_mva); + if(mask_mode){ + // disconnected-grid handling: solve the largest component while masking + // the rest, unless the contingency strands the chosen reference slack. + if(!_skip_mask[cont_id]){ + const std::vector & masked = _li_masked[cont_id]; + remove_from_Ybus(Ybus_, coeffs_modif, ac_solver_used); // invertibility handled by masking + _timer_modif_Ybus += timer_modif_Ybus.duration(); + + _algo.set_masked_buses(masked); + const RealVect sw = masked.empty() ? slack_weights_ : masked_slack_weights(masked); + V = Vinit_solver; // Vinit is reused for each contingencies + conv = compute_one_powerflow( + Ybus_, V, Sbus_, + slack_ids_me_.as_eigen(), sw, + bus_pv_.as_eigen(), bus_pq_.as_eigen(), + max_iter, tol / sn_mva); + _algo.set_masked_buses(std::vector()); // reset for the next contingency + + timer_modif_Ybus = CustTimer(); + readd_to_Ybus(Ybus_, coeffs_modif, ac_solver_used); + _timer_modif_Ybus += timer_modif_Ybus.duration(); + + if(conv){ + // masked buses are not simulated: report 0 voltage for them + for(int b : masked) if(b >= 0 && b < V.size()) V(b) = cplx_type(0., 0.); + do_store = true; + } + } + // skipped contingency: conv stays false, voltages stay 0 + } else { + // legacy behaviour: skip the contingency if it disconnects the grid + bool invertible = remove_from_Ybus(Ybus_, coeffs_modif, ac_solver_used); + _timer_modif_Ybus += timer_modif_Ybus.duration(); + + if(invertible) + { + V = Vinit_solver; // Vinit is reused for each contingencies + conv = compute_one_powerflow( + Ybus_, + V, + Sbus_, + slack_ids_me_.as_eigen(), + slack_weights_, + bus_pv_.as_eigen(), + bus_pq_.as_eigen(), + max_iter, + tol / sn_mva); + } + + timer_modif_Ybus = CustTimer(); + readd_to_Ybus(Ybus_, coeffs_modif, ac_solver_used); + _timer_modif_Ybus += timer_modif_Ybus.duration(); + do_store = conv && invertible; } - timer_modif_Ybus = CustTimer(); - readd_to_Ybus(Ybus_, coeffs_modif, ac_solver_used); - _timer_modif_Ybus += timer_modif_Ybus.duration(); - if (conv && invertible) _voltages.row(cont_id)(id_solver_to_me_.as_eigen()) = V.array(); + if (do_store) _voltages.row(cont_id)(id_solver_to_me_.as_eigen()) = V.array(); ++cont_id; } _timer_total = timer.duration(); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 4bf64c9e..67b740fb 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -68,12 +68,16 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch BaseBatchSolverSynch::clear(); _li_defaults.clear(); _li_coeffs.clear(); + _li_masked.clear(); + _skip_mask.clear(); _timer_total = 0.; _timer_modif_Ybus = 0.; _timer_pre_proc = 0.; } void clear_results_only(){ BaseBatchSolverSynch::clear(); + _li_masked.clear(); + _skip_mask.clear(); _timer_total = 0.; _timer_modif_Ybus = 0.; _timer_pre_proc = 0.; @@ -105,6 +109,14 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch return nb_removed >= 1; } + // "handle disconnected grid" mode: when ON, a contingency that splits the grid + // is no longer skipped. Instead the largest connected component is solved while + // the buses of the other component(s) are "masked" (their NR equations are + // forced to identity, their voltage reported as 0). Default: OFF (legacy + // behaviour: such contingencies are skipped). Only the NR family supports it. + bool get_handle_disconnected_grid() const {return _handle_disconnected_grid;} + void set_handle_disconnected_grid(bool val) {_handle_disconnected_grid = val;} + // make the computation void compute(const CplxVect & Vinit, int max_iter, real_type tol); IntVect is_grid_connected_after_contingency(); @@ -169,11 +181,29 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch // in this case, well, i don't use the results of the simulation bool check_invertible(const Eigen::SparseMatrix & Ybus) const; + // ----- "handle disconnected grid" mode helpers (mask mode only) ----------- + // connected-component labelling of Ybus: returns the solver bus ids that are + // NOT part of the largest connected component (empty if Ybus is connected). + std::vector disconnected_buses(const Eigen::SparseMatrix & Ybus) const; + // pre-pass run before the (n-)powerflow: fills _li_masked (the masked bus set + // of each contingency), chooses the reference slack that minimises the number + // of skipped contingencies (reordering slack_ids_me_ so it is index 0) and + // fills _skip_mask (contingencies that still strand the chosen reference). + void select_ref_slack_and_masks(); + // a copy of slack_weights_ with the masked slack buses zeroed and the vector + // rescaled so its total is preserved (slack power stays among live slacks). + RealVect masked_slack_weights(const std::vector & masked) const; + private: // li_default std::set > _li_defaults; // do not use unordered_set here, we rely on the order for different functions ! std::vector > _li_coeffs; // for each n-k, stores the coefficients I need to modify in the Ybus + // "handle disconnected grid" mode (see set_handle_disconnected_grid) + bool _handle_disconnected_grid = false; + std::vector > _li_masked; // per contingency: masked solver bus ids (mask mode) + std::vector _skip_mask; // per contingency: 1 => skip (strands the ref slack) + //timers double _timer_modif_Ybus; // time to update the Ybus between the defaults simulation }; diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 0e81bce8..5d87df1e 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -209,6 +209,14 @@ class LS2G_API BaseAlgo : public BaseConstants throw std::runtime_error("Function update_internal_Ybus not implemented in general."); } + // bus masking (ContingencyAnalysis "handle disconnected grid" mode): forces + // the given solver buses' equations to identity so an isolated island does + // not make the system singular, without changing the matrix sparsity. Only + // the Newton-Raphson family supports it (see supports_bus_masking); the + // default is a no-op so other algorithms are unaffected. + virtual bool supports_bus_masking() const { return false; } + virtual void set_masked_buses(const std::vector & /*solver_bus_ids*/) {} + virtual AlgoConfig get_config() const { return AlgoConfig{}; } virtual void set_config(const AlgoConfig&) {} diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 2571cb18..5f2dabbf 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -118,6 +118,12 @@ class NRAlgo : public BaseAlgo virtual void reset() override; + // ----- bus masking --------------------------------------------------------- + virtual bool supports_bus_masking() const override { return true; } + virtual void set_masked_buses(const std::vector & solver_bus_ids) override { + _system.set_masked_buses(solver_bus_ids); + } + // ----- scaling policy ------------------------------------------------------ ScalingPolicyType get_scaling_policy_type() const { return scaling_policy_->type(); } diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index dd5f25ba..76016047 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -783,6 +783,7 @@ class NRSystem NRSystem() noexcept: timer_dSbus_(0.), timer_fillJ_(0.), + masked_dirty_(false), lsgrid_ptr_(nullptr), Ybus_ptr_(nullptr), Sbus_ptr_(nullptr) {} @@ -815,6 +816,19 @@ class NRSystem void fill_J(); void fill_internal_variables(); + // ----- bus masking (ContingencyAnalysis "handle disconnected grid" mode) ------ + // Mark some solver buses as "masked": their P/Q mismatch rows are replaced by + // trivial identity rows (so dx == 0 on those buses), which keeps the Jacobian + // non-singular when a contingency isolates them from the live component. This + // is a pure value-level change: the J sparsity pattern / dimension are NOT + // touched, so the symbolic factorization is reused (no analyze()). An empty + // vector (the default) disables masking and reproduces the unmasked behaviour + // bit-for-bit. solver_bus_ids must never include the reference slack. + void set_masked_buses(const std::vector& solver_bus_ids) { + masked_buses_ = solver_bus_ids; + masked_dirty_ = true; + } + // ----- NR iteration primitives ----------------------------------------------- virtual RealVect mismatch() const; @@ -837,6 +851,10 @@ class NRSystem map_dsdvm_i_.clear(); sink_.clear(); feature_pos_.clear(); + masked_buses_.clear(); + masked_zero_pos_.clear(); + masked_one_pos_.clear(); + masked_dirty_ = false; ledger_.reset(0); } @@ -908,6 +926,45 @@ class NRSystem FeatureSink sink_; std::vector feature_pos_; + // bus masking (see set_masked_buses): masked_buses_ are solver bus ids whose + // P/Q rows are forced to identity. masked_zero_pos_ / masked_one_pos_ are the + // J_.valuePtr() positions to overwrite with 0 / 1 in fill_J; they are derived + // from masked_buses_ + the (fixed) J sparsity and recomputed lazily. + std::vector masked_buses_; + std::vector masked_zero_pos_; + std::vector masked_one_pos_; + bool masked_dirty_; + + // resolve masked_zero_pos_ / masked_one_pos_ from masked_buses_ and J_'s + // sparsity (one pass over the nonzeros). Call only when J_ is built. + void _recompute_mask_positions() { + masked_zero_pos_.clear(); + masked_one_pos_.clear(); + masked_dirty_ = false; + if (masked_buses_.empty() || J_.nonZeros() == 0) return; + const int dim = static_cast(J_.rows()); + std::vector is_masked_row(dim, 0); + std::vector one_col_of_row(dim, -1); // for a masked row: the col forced to 1 + for (int b : masked_buses_) { + const int pr = ledger_.p_row(b); + const int tc = ledger_.theta_col(b); + if (pr >= 0) { is_masked_row[pr] = 1; one_col_of_row[pr] = tc; } + const int qr = ledger_.q_row(b); + const int vc = ledger_.vm_col(b); + if (qr >= 0) { is_masked_row[qr] = 1; one_col_of_row[qr] = vc; } + } + const int* outer = J_.outerIndexPtr(); + const int* inner = J_.innerIndexPtr(); + for (int col = 0; col < dim; ++col) { + for (int p = outer[col]; p < outer[col + 1]; ++p) { + const int row = inner[p]; + if (!is_masked_row[row]) continue; + if (one_col_of_row[row] == col) masked_one_pos_.push_back(p); + else masked_zero_pos_.push_back(p); + } + } + } + // Holds the base things Base base_; diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index 9197efdd..3c2fb4e1 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -162,6 +162,15 @@ inline void NRSystem::fill_J() base_.fill_feature_values(writer, Va_); _fill_feature_values_extensions(writer, std::make_index_sequence{}); + // bus masking: overwrite the masked buses' rows with the identity (zero the + // whole row, then set the diagonal to 1 -- ones last, since the diagonal is + // itself part of a masked row). Pure value-level edit, J sparsity unchanged. + if (!masked_buses_.empty()) { + if (masked_dirty_) _recompute_mask_positions(); + for (int p : masked_zero_pos_) J_values[p] = static_cast(0.); + for (int p : masked_one_pos_) J_values[p] = static_cast(1.); + } + timer_fillJ_ += timer.duration(); } @@ -288,6 +297,17 @@ inline RealVect NRSystem::_residual(const CplxVect& V_t, const Re // component-owned custom rows (none for Base / MultiSlack) base_.fill_custom_rows(res, Va_, Vm_, dx); _fill_custom_rows_extensions(res, Va_, Vm_, dx, std::make_index_sequence{}); + + // bus masking: the masked buses' P/Q residuals are forced to 0 so the trivial + // identity rows of J (see fill_J) yield dx == 0 on those buses. + if (!masked_buses_.empty()) { + for (int b : masked_buses_) { + const int pr = ledger_.p_row(b); + if (pr >= 0) res(pr) = static_cast(0.); + const int qr = ledger_.q_row(b); + if (qr >= 0) res(qr) = static_cast(0.); + } + } return res; } From 652b0b01c819400ff12be0cefa0c7029cb3a3150 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 19 Jun 2026 16:50:02 +0200 Subject: [PATCH 002/166] update CHANGELOG and docs + fix a segfault Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 16 ++++++ docs/security_analysis.rst | 53 +++++++++++++++++-- .../tests/test_ContingencyAnalysis_split.py | 14 +++++ .../batch_algorithm/ContingencyAnalysis.cpp | 16 +++++- 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 878fe785..bed55050 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -94,6 +94,10 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. in the generator convention (positive = produced) whereas lightsim2grid stores storage in the load convention (positive = charging), so the setpoints are now negated. The previous (untested) behaviour modeled a producing battery as a consuming load. +- [FIXED] `ContingencyAnalysisCPP.is_grid_connected_after_contingency()` could segfault: it + built its working matrix from the grid model's own (never populated, hence empty) Ybus, + causing out-of-bounds writes. It now uses the correctly indexed internal `Ybus_` and builds + the required inputs on demand, so it works both before and after `compute()`. - [ADDED] a dedicated `StorageContainer` / `StorageInfo` (exposed through `lightsim2grid.elements`) for the storage units, with its convention documented. - [ADDED] reading the storage units (batteries) from a pypowsybl grid is now tested @@ -118,6 +122,18 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `test_voltage_control_pypowsybl`). - [ADDED] `LSGrid.get_storages()`, `LSGrid.get_dclines()`, `LSGrid.get_svcs()` and `LSGrid.get_voltage_levels()` accessors for the (new) containers. +- [ADDED] a `handle_disconnected_grid` mode for the contingency analysis (`ContingencyAnalysis` + and `ContingencyAnalysisCPP`). By default (``False``) a contingency that splits the grid in + several connected components is skipped (its voltages stay at 0, legacy behaviour). When set + to ``True``, the largest connected component is solved while the buses of the other + component(s) are "masked" (their Newton-Raphson equations are forced to identity and their + voltage reported as 0). This is done **without any extra symbolic factorization** (the + solver's analyze/factor are not re-triggered): the masked buses keep the Jacobian sparsity + unchanged. The reference slack is chosen once, up-front, to minimise the number of + contingencies that have to be skipped (those that would strand the chosen reference), and a + stranded slack generator has its slack weight zeroed and the remaining weights rescaled. + Requires a Newton-Raphson algorithm (an AC non-NR solver raises a clear error). Tested in + `test_ContingencyAnalysis_split`. - [ADDED] (to interpret the new Jacobian layout) the methods `get_theta_to_J_col()`, `get_vm_to_J_col()` and `get_q_to_J_col()` on the Newton-Raphson algorithms (and on the `AlgorithmSelector`). Each returns a vector, **indexed by the solver bus id**, diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index 50c35ff8..777ea6d6 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -45,11 +45,58 @@ It can be used as: For now this relies on grid2op, but we could imagine a version of this class that can read to / from other data sources. -.. note:: - - A more advanced usage is given in the `examples\\security_analysis.py` +.. note:: + + A more advanced usage is given in the `examples\\security_analysis.py` file from the lightsim2grid package. +Handling contingencies that split the grid +------------------------------------------- + +By default, a contingency that splits the grid into several connected components (an "islanding") +is **not simulated**: the corresponding row of the results is left at 0. (for the voltages) and +``NaN`` (for the flows). You can list which contingencies split the grid with +:func:`ContingencyAnalysisCPP.is_grid_connected_after_contingency`. + +Starting from lightsim2grid 0.14.0, you can opt in to a mode that *does* simulate these +contingencies, on the largest connected component, by setting the ``handle_disconnected_grid`` +attribute to ``True``: + +.. code-block:: python + + import grid2op + from lightsim2grid import ContingencyAnalysis + from lightsim2grid import LightSimBackend + env = grid2op.make(..., backend=LightSimBackend()) + + security_analysis = ContingencyAnalysis(env) + security_analysis.add_all_n1_contingencies() + + # opt in: simulate the largest island instead of skipping split contingencies + security_analysis.handle_disconnected_grid = True + + res_p, res_a, res_v = security_analysis.get_flows() + +When this mode is enabled and a contingency splits the grid: + +- the **largest** connected component is solved as a regular powerflow; +- the buses of the other component(s) are *masked*: their voltage is reported as ``0.`` (same + convention as a skipped contingency) and they do not influence the solved component; +- if a slack generator ends up in a masked component, its slack weight is set to 0 and the + remaining slack weights are rescaled so the slack power is shared only among the live slacks. + +This is implemented **without re-triggering the symbolic factorization** of the linear solver +(the Jacobian sparsity pattern is left unchanged), so it stays compatible with the speed of the +contingency analysis. To make it possible, the reference slack is chosen once, before the +computation, so as to minimise the number of contingencies that still have to be skipped (those +that would disconnect the chosen reference slack itself). + +.. note:: + + This mode **requires a Newton-Raphson algorithm** (the default). Selecting an AC non + Newton-Raphson algorithm (*eg* Gauss-Seidel or Fast-Decoupled) and enabling the mode raises + an error. In DC the connectivity is already handled internally, so the flag has no effect. + .. _sa_benchmarks: Benchmarks (Contingency Analysis) diff --git a/lightsim2grid/tests/test_ContingencyAnalysis_split.py b/lightsim2grid/tests/test_ContingencyAnalysis_split.py index 887dd443..e6ed6c77 100644 --- a/lightsim2grid/tests/test_ContingencyAnalysis_split.py +++ b/lightsim2grid/tests/test_ContingencyAnalysis_split.py @@ -112,6 +112,20 @@ def test_dc_flag_on_is_a_noop(self): SA.handle_disconnected_grid = True SA.compute(1. * self.V0, self.max_it, self.tol) # must not raise + def test_is_grid_connected_no_segfault(self): + # regression: is_grid_connected_after_contingency() used to segfault because + # it relied on the (empty) grid-model Ybus. It must now work both standalone + # (before compute) and after compute, and agree. + SA = ContingencyAnalysisCPP(self.grid) + SA.add_n1(2) # splits the grid (isolates bus 3) + SA.add_n1(0) # also splits (isolates {1, 2, 3} from the slack) + standalone = np.asarray(SA.is_grid_connected_after_contingency()) + assert standalone.shape == (2,) + assert np.all(standalone == 0), f"both radial cuts disconnect the grid, got {standalone}" + SA.compute(1. * self.V0, self.max_it, self.tol) + after = np.asarray(SA.is_grid_connected_after_contingency()) + assert np.array_equal(standalone, after) + class TestContingencySplitMultiSlack(unittest.TestCase): """Two slacks at the two ends of a line; a contingency strands one of them. diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 6a15cb0e..8c4b9515 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -265,9 +265,21 @@ IntVect ContingencyAnalysis::is_grid_connected_after_contingency(){ if(!ac_solver_used){ // in DC mode the solver takes responsibility for the connectivity (see remove_from_Ybus), // so every contingency is reported as "connected". No need to build / cast a Ybus here. - return IntVect::Constant(_li_coeffs.size(), 1); + return IntVect::Constant(_li_defaults.size(), 1); } - Eigen::SparseMatrix Ybus = _grid_model.get_Ybus_solver(); + // Build the solver inputs (Ybus_, id_me_to_solver_) and the per-contingency + // coefficients if they are not available yet (i.e. compute() was not called). + // NB: we use the (correctly indexed) member Ybus_, NOT _grid_model.get_Ybus_solver(): + // the latter is the grid model's own Ybus, which is never built by this class (it + // works on Ybus_) and would be an empty 0x0 matrix here -> out-of-bounds coeffRef. + if(Ybus_.cols() == 0 || _li_coeffs.size() != _li_defaults.size()){ + const size_t nb_total_bus = _grid_model.total_bus(); + CplxVect Vinit = CplxVect::Constant(static_cast(nb_total_bus), + {_grid_model.get_init_vm_pu(), 0.}); + prepare_solver_input_base(Vinit, ac_solver_used); + init_li_coeffs(ac_solver_used, id_me_to_solver_); + } + Eigen::SparseMatrix Ybus = Ybus_; // correctly-indexed copy IntVect res = IntVect::Constant(_li_coeffs.size(), 0); int cont_id = 0; for(const auto & coeffs_modif: _li_coeffs){ From 6b80116e384890f305e97efcc413becaca6ac461 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 25 Jun 2026 09:07:45 +0200 Subject: [PATCH 003/166] add functions to properly set and compare a network between lightsim2grid and pypowsybl Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 13 + benchmarks/compare_lightsim2grid_pypowsybl.py | 35 +- docs/comparison_with_pypowsybl.rst | 84 ++-- lightsim2grid/network/__init__.py | 7 + .../network/from_pypowsybl/__init__.py | 9 +- .../network/from_pypowsybl/_olf_bake.py | 255 ++++++++++++ .../network/from_pypowsybl/_olf_compare.py | 225 +++++++++++ .../network/from_pypowsybl/_olf_params.py | 126 ++++++ .../utils_for_slack.py | 105 +---- lightsim2grid/tests/test_olf_bake.py | 376 ++++++++++++++++++ 10 files changed, 1080 insertions(+), 155 deletions(-) create mode 100644 lightsim2grid/network/from_pypowsybl/_olf_bake.py create mode 100644 lightsim2grid/network/from_pypowsybl/_olf_compare.py create mode 100644 lightsim2grid/network/from_pypowsybl/_olf_params.py create mode 100644 lightsim2grid/tests/test_olf_bake.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bed55050..6b0bb015 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -98,6 +98,19 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. built its working matrix from the grid model's own (never populated, hence empty) Ybus, causing out-of-bounds writes. It now uses the correctly indexed internal `Ybus_` and builds the required inputs on demand, so it works both before and after `compute()`. +- [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's + input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / + shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so + a subsequent *outer-loop-free* solve in OLF or lightsim2grid reproduces the OLF + with-loops operating point. Tested in `test_olf_bake` (pure-OLF round trips plus + lightsim2grid agreement through line / transformer outages). +- [ADDED] `lightsim2grid.network.get_pypowsybl_loopfree_parameters`: a factory for the + canonical OLF `Parameters` with every outer loop disabled (empty `outerLoopNames` + allow-list, every trigger forced off). Now the single source of truth used by the + benchmarks (`compare_lightsim2grid_pypowsybl`), the documentation and the test suite. +- [ADDED] `lightsim2grid.network.compare_baked` (and `ComparisonResult`): a thin helper + to validate lightsim2grid against OLF on identical inputs (bake, optional outages, + loop-free solve in both engines, voltage comparison). - [ADDED] a dedicated `StorageContainer` / `StorageInfo` (exposed through `lightsim2grid.elements`) for the storage units, with its convention documented. - [ADDED] reading the storage units (batteries) from a pypowsybl grid is now tested diff --git a/benchmarks/compare_lightsim2grid_pypowsybl.py b/benchmarks/compare_lightsim2grid_pypowsybl.py index 13999375..1c8e13e4 100644 --- a/benchmarks/compare_lightsim2grid_pypowsybl.py +++ b/benchmarks/compare_lightsim2grid_pypowsybl.py @@ -7,7 +7,7 @@ import pypowsybl as pypow import pypowsybl.loadflow as pypow_lf -from lightsim2grid.gridmodel import init_from_pypowsybl +from lightsim2grid.network import init_from_pypowsybl, get_pypowsybl_loopfree_parameters from lightsim2grid.contingencyAnalysis import ContingencyAnalysisCPP from utils_benchmark import print_configuration @@ -33,34 +33,11 @@ def get_same_slack(case_name): raise RuntimeError(f"Unknown env {case_name}") -def get_pypowsybl_parameters(slack_voltage_level): - params = pypow_lf.Parameters( - voltage_init_mode=pypow._pypowsybl.VoltageInitMode.UNIFORM_VALUES, - transformer_voltage_control_on=False, - use_reactive_limits=False, - phase_shifter_regulation_on=False, - twt_split_shunt_admittance=True, - shunt_compensator_voltage_control_on=False, - read_slack_bus=False, - write_slack_bus=True, - distributed_slack=False, - dc_use_transformer_ratio=True, - hvdc_ac_emulation=False, - dc_power_factor=1., - provider_parameters={ - "useActiveLimits": "false", - "useReactiveLimits": "false", - "svcVoltageMonitoring": "false", - "voltageRemoteControl": "false", - "writeReferenceTerminals": "false", - "slackBusSelectionMode" : "NAME", # for case 118 - "slackBusesIds" : f"{slack_voltage_level}", # for case 118 - "voltagePerReactivePowerControl": "false", - "generatorReactivePowerRemoteControl": "false", - "secondaryVoltageControl": "false", - } - ) - return params +def get_pypowsybl_parameters(slack_voltage_level): + # single source of truth: the canonical "every outer loop disabled" + # parameters shipped with lightsim2grid, with the slack pinned by name so + # both engines use the same slack bus. + return get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_voltage_level) def main(case_name, diff --git a/docs/comparison_with_pypowsybl.rst b/docs/comparison_with_pypowsybl.rst index 20bded4b..1f9e9dc2 100644 --- a/docs/comparison_with_pypowsybl.rst +++ b/docs/comparison_with_pypowsybl.rst @@ -88,38 +88,30 @@ For example: Load-flow parameters ********************** -The parameters used to compute the powerflow in these examples are: +The parameters used to compute the powerflow in these examples are the canonical +"every outer loop disabled" parameters shipped with lightsim2grid, exported as +``get_pypowsybl_loopfree_parameters``. lightsim2grid solves a single power-flow +problem (no outer loops), so to get consistent results pypowsybl must be told to +run no outer loop either: .. code-block:: python - import pypowsybl.loadflow as pypow_lf - - params = pypow_lf.Parameters( - voltage_init_mode=pypow._pypowsybl.VoltageInitMode.UNIFORM_VALUES, - transformer_voltage_control_on=False, - use_reactive_limits=False, - phase_shifter_regulation_on=False, - twt_split_shunt_admittance=True, - shunt_compensator_voltage_control_on=False, - read_slack_bus=False, - write_slack_bus=True, - distributed_slack=False, - dc_use_transformer_ratio=True, - hvdc_ac_emulation=False, - dc_power_factor=1., - provider_parameters={ - "useActiveLimits": "false", - "useReactiveLimits": "false", - "svcVoltageMonitoring": "false", - "voltageRemoteControl": "false", - "writeReferenceTerminals": "false", - "slackBusSelectionMode" : "NAME", - "slackBusesIds" : "VL69_0", # DEPENDS ON CASE_NAME: for case 118 - "voltagePerReactivePowerControl": "false", - "generatorReactivePowerRemoteControl": "false", - "secondaryVoltageControl": "false", - } - ) + from lightsim2grid.network import get_pypowsybl_loopfree_parameters + + # pin the slack on the same bus lightsim2grid uses (depends on the case, + # e.g. "VL69_0" for ieee118); omit slack_bus_ids to read the slack from + # the network instead. + params = get_pypowsybl_loopfree_parameters(slack_bus_ids="VL69_0") + +Under the hood this builds a :class:`pypowsybl.loadflow.Parameters` that disables +distributed slack, reactive limits, transformer / shunt / phase-shifter voltage +control, area-interchange and secondary-voltage control, automation systems, etc. +The key mechanism is the empty ``outerLoopNames`` allow-list, which registers +*zero* outer loops regardless of which loops a future pypowsybl release adds. + +If you need a *raw* (un-baked) network whose outer loops would actually trigger +to also match lightsim2grid, freeze the converged outer-loop state first with +``lightsim2grid.network.bake_outer_loops`` before solving loop-free. .. important:: As you notice from these parameters, a lot of the @@ -129,6 +121,40 @@ The parameters used to compute the powerflow in these examples are: If you are interested in an "abalation study" on the impact of certain parameters above, let us know, for example with a github issue or by reaching out on discord. + +Comparing the two engines +************************** + +To check that lightsim2grid reproduces an OLF operating point you can use the +``compare_baked`` helper. It solves the network *with* outer loops in OLF, bakes +the converged outer-loop state into the inputs (so the problem becomes a plain +power flow), optionally applies the same outages to both engines, then solves +loop-free in OLF and in lightsim2grid and compares the bus voltages: + +.. code-block:: python + + from lightsim2grid.network import compare_baked + import pypowsybl as pp + + res = compare_baked( + pp.network.create_ieee14, # a callable returning a fresh network + slack_gen_id="B1-G", + line_outages=["L1-2-1"], # optional, applied to both engines + ) + print(res) # ComparisonResult(max |dV| = ..., ...) + print(res.max_dvm_pu) # largest |Vmag| mismatch (pu) + print(res.table) # per-bus detail + +The call returns a :class:`lightsim2grid.network.ComparisonResult` summarising the +largest voltage-magnitude and voltage-angle mismatches (plus a per-bus table): + +.. autoclass:: lightsim2grid.network.ComparisonResult + :members: + :no-index: + +.. autofunction:: lightsim2grid.network.compare_baked + :no-index: + Results ----------------------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 8e05e1c7..c5b251ad 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -20,7 +20,14 @@ try: from lightsim2grid.network.from_pypowsybl import init as init_from_pypowsybl # noqa + from lightsim2grid.network.from_pypowsybl import bake_outer_loops # noqa + from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_parameters # noqa + from lightsim2grid.network.from_pypowsybl import compare_baked, ComparisonResult # noqa __all__.append("init_from_pypowsybl") + __all__.append("bake_outer_loops") + __all__.append("get_pypowsybl_loopfree_parameters") + __all__.append("compare_baked") + __all__.append("ComparisonResult") except ImportError: # pypowsybl is not installed pass diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index c62313ab..7f83c6c9 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -6,6 +6,13 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__all__ = ["init"] +__all__ = ["init", + "bake_outer_loops", + "get_pypowsybl_loopfree_parameters", + "compare_baked", + "ComparisonResult"] from ._from_pypowsybl import init +from ._olf_bake import bake_outer_loops +from ._olf_params import get_pypowsybl_loopfree_parameters +from ._olf_compare import compare_baked, ComparisonResult diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py new file mode 100644 index 00000000..892e939f --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -0,0 +1,255 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Bake PowSyBl Open Load Flow (OLF) outer-loop results into an IIDM network so +that a subsequent *outer-loop-free* power flow -- in OLF itself or in +lightsim2grid -- reproduces the converged state exactly. + +Why this exists +--------------- +lightsim2grid solves a single power-flow problem: it does not run the discrete +"outer loops" that OLF runs (distributed slack, reactive-limit PV<->PQ +switching, transformer/shunt voltage control, phase control, ...). Those loops +change the *inputs* of the power flow between inner solves: a generator that +hits its Q limit becomes a fixed-Q (PQ) injection, a tap changer settles on a +discrete position, the slack mismatch is spread over participating units, etc. + +If you take the raw network, run OLF *with* outer loops, and then hand the same +raw network to lightsim2grid, the two engines disagree -- not because the +solvers differ, but because they are solving different problems. + +``bake_outer_loops`` rewrites the network's input setpoints to the values the +outer loops settled on, and disables the corresponding regulation, so the +problem becomes a plain power flow. After baking, OLF (loop-free) and +lightsim2grid agree to solver tolerance, and they keep agreeing through +topology changes (line/transformer outages) applied identically to both. + +The loop-free OLF parameters that reproduce the baked operating point are +available as :func:`lightsim2grid.network.get_pypowsybl_loopfree_parameters`. + +What gets baked +--------------- +* Discrete tap / section positions (ratio tap, phase tap, shunt section): + the solved position (``solved_tap_position`` / ``solved_section_count``) is + copied into the input position and regulation is switched off. The copy is + guarded: it only happens where the changer was regulating AND a solved value + exists, because ``solved_tap_position`` is NaN for changers that did not take + part in an outer loop -- copying NaN would destroy the input tap. +* PV -> PQ reactive-limit switches (generators, VSC converter stations): only + the units whose realized reactive power actually sits at a Q limit are frozen + to fixed-Q at that value, with voltage regulation switched off. Units still + inside their limits keep voltage control -- their equation type is unchanged. + Units with NaN (unlimited) reactive limits are never frozen, since NaN + comparisons are False. +* Active-power redistribution from distributed slack / area interchange: the + realized P is written back into the target P of generators and batteries + (and optionally loads, if the slack was distributed on load). + +What is intentionally *not* baked +--------------------------------- +* Static var compensators are left regulating. An SVC's reactive range is + voltage-dependent (it is a susceptance band b_min..b_max, not a fixed Q box), + so the generator-style "Q at a fixed limit" test does not apply. If an SVC is + still regulating voltage at convergence, leaving it regulating reproduces the + OLF result. Freezing a saturated SVC would require its regulated-bus voltage + and the OLF susceptance sign convention; that is left as a hook. + +Verified against pypowsybl 1.15.0. The OLF-internal round trip (solve with +outer loops -> bake -> solve loop-free) reproduces bus voltages to ~2e-4 kV / +~1e-5 deg, both on IEEE-14 (with a forced reactive-limit switch) and on the +four-substations node-breaker network, which carries VSC and LCC HVDC, an SVC +regulating voltage, a shunt, and ratio + phase tap changers. +""" + +import numpy as np +import pandas as pd + + +# Tolerance (MVAr) for deciding a reactive injection sits "at" its Q limit. +_Q_LIMIT_TOL = 1e-3 + + +def _reactive_limits(df: pd.DataFrame): + """Return (qmin, qmax) in *generator* convention. + + Prefer the P-dependent capability-curve limits (``min_q_at_p`` / + ``max_q_at_p``) when present and finite; fall back to the fixed + ``min_q`` / ``max_q`` box otherwise. + """ + if "min_q_at_p" in df.columns: + qmin = df["min_q_at_p"].where(df["min_q_at_p"].notna(), df["min_q"]) + qmax = df["max_q_at_p"].where(df["max_q_at_p"].notna(), df["max_q"]) + else: + qmin, qmax = df["min_q"], df["max_q"] + return qmin, qmax + + +def _bound_at_qlimit(df: pd.DataFrame, regulating: pd.Series): + """Boolean mask of rows that regulate voltage *and* sit at a Q limit, + plus the realized reactive power in generator convention. + + ``df`` must carry result column ``q`` (load convention) and the reactive + limit columns. ``regulating`` is the per-row voltage-regulation flag. + """ + q_gen = -df["q"] # result column is load convention; flip to generator + qmin, qmax = _reactive_limits(df) + at_max = regulating & (q_gen >= qmax - _Q_LIMIT_TOL) + at_min = regulating & (q_gen <= qmin + _Q_LIMIT_TOL) + return (at_max | at_min), q_gen + + +def bake_outer_loops( + network, + bake_taps: bool = True, + bake_reactive_limits: bool = True, + bake_active_power: bool = True, + balance_on_loads: bool = False, + load_power_factor_constant: bool = False, +): + """Rewrite ``network`` input setpoints to the converged outer-loop state. + + Call this on a network that has just been solved by OLF *with* outer loops. + Afterwards the network represents a plain power-flow problem: a loop-free + OLF run (see :func:`get_pypowsybl_loopfree_parameters`) or a lightsim2grid + run (via :func:`init_from_pypowsybl`) will reproduce the same operating + point. + + Parameters + ---------- + network + A pypowsybl network, freshly solved with the outer loops enabled. + bake_taps + Copy solved ratio/phase tap positions and shunt sections into the + input positions and disable their regulation. + bake_reactive_limits + Freeze generators / VSC stations that hit a Q limit to fixed-Q (PQ). + bake_active_power + Write realized active power back into generator/battery target P + (and load p0/q0 if ``balance_on_loads``). + balance_on_loads + Set if the slack was distributed on loads (BalanceType + PROPORTIONAL_TO_LOAD / CONFORM_LOAD). + load_power_factor_constant + Mirror OLF's ``loadPowerFactorConstant``: also rewrite load q0 so the + power factor is preserved. + + Notes + ----- + Operates in place and is idempotent on an already-baked network. The + voltage-regulation flag is *not* used as a switch signal: OLF does not flip + it in IIDM, so PV->PQ is detected from the realized Q sitting at a limit. + """ + if bake_taps: + _bake_taps_and_sections(network) + if bake_reactive_limits: + _bake_reactive_limit_switches(network) + if bake_active_power: + _bake_active_power( + network, + balance_on_loads=balance_on_loads, + load_power_factor_constant=load_power_factor_constant, + ) + + +def _bake_taps_and_sections(network): + # Ratio tap changers (transformer voltage control outer loop). + rtc = network.get_ratio_tap_changers( + attributes=["tap", "solved_tap_position", "regulating"] + ) + if len(rtc): + # Only adopt the solved position where the changer was actually + # regulating AND a solved position exists. ``solved_tap_position`` is + # NaN when the changer did not participate in an outer loop; copying it + # blindly would destroy the input tap. + keep = rtc["regulating"] & rtc["solved_tap_position"].notna() + if keep.any(): + upd = pd.DataFrame(index=rtc.index[keep]) + upd["tap"] = rtc["solved_tap_position"][keep].astype(int) + upd["regulating"] = False + network.update_ratio_tap_changers(upd) + + # Phase tap changers (phase-shifter regulation outer loop). + ptc = network.get_phase_tap_changers( + attributes=["tap", "solved_tap_position", "regulating"] + ) + if len(ptc): + keep = ptc["regulating"] & ptc["solved_tap_position"].notna() + if keep.any(): + upd = pd.DataFrame(index=ptc.index[keep]) + upd["tap"] = ptc["solved_tap_position"][keep].astype(int) + upd["regulating"] = False + network.update_phase_tap_changers(upd) + + # Shunt compensators (shunt voltage control outer loop). + sh = network.get_shunt_compensators( + attributes=["section_count", "solved_section_count", "voltage_regulation_on"] + ) + if len(sh): + keep = sh["voltage_regulation_on"] & sh["solved_section_count"].notna() + if keep.any(): + upd = pd.DataFrame(index=sh.index[keep]) + upd["section_count"] = sh["solved_section_count"][keep].astype(int) + upd["voltage_regulation_on"] = False + network.update_shunt_compensators(upd) + + +def _bake_reactive_limit_switches(network): + gen = network.get_generators( + attributes=[ + "voltage_regulator_on", "q", + "min_q", "max_q", "min_q_at_p", "max_q_at_p", + ] + ) + mask, q_gen = _bound_at_qlimit(gen, gen["voltage_regulator_on"]) + if mask.any(): + upd = pd.DataFrame(index=gen.index[mask]) + upd["target_q"] = q_gen[mask] + upd["voltage_regulator_on"] = False + network.update_generators(upd) + + vsc = network.get_vsc_converter_stations( + attributes=[ + "voltage_regulator_on", "q", + "min_q", "max_q", "min_q_at_p", "max_q_at_p", + ] + ) + if len(vsc): + mask, q_gen = _bound_at_qlimit(vsc, vsc["voltage_regulator_on"]) + if mask.any(): + upd = pd.DataFrame(index=vsc.index[mask]) + upd["target_q"] = q_gen[mask] + upd["voltage_regulator_on"] = False + network.update_vsc_converter_stations(upd) + + # Static var compensators are deliberately left regulating; see module + # docstring. Hook for susceptance-envelope saturation would go here. + + +def _bake_active_power(network, balance_on_loads, load_power_factor_constant): + gen = network.get_generators(attributes=["p"]) + if len(gen): + # result p is load convention; target_p is generator convention + network.update_generators( + pd.DataFrame({"target_p": -gen["p"]}, index=gen.index) + ) + + bat = network.get_batteries(attributes=["p"]) + if len(bat): + network.update_batteries( + pd.DataFrame({"target_p": -bat["p"]}, index=bat.index) + ) + + if balance_on_loads: + load = network.get_loads(attributes=["p", "q"]) + if len(load): + upd = pd.DataFrame(index=load.index) + upd["p0"] = load["p"] # load convention both sides, no flip + if load_power_factor_constant: + upd["q0"] = load["q"] + network.update_loads(upd) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_compare.py b/lightsim2grid/network/from_pypowsybl/_olf_compare.py new file mode 100644 index 00000000..71c135af --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_olf_compare.py @@ -0,0 +1,225 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Thin helper to validate lightsim2grid against PowSyBl Open Load Flow (OLF) on +identical inputs. + +The workflow: + +1. Solve the network with OLF *with* outer loops. +2. :func:`bake_outer_loops` to freeze the converged outer-loop state into the + network (see ``_olf_bake``). +3. Optionally apply the same topology change (e.g. a line outage) to both + engines. +4. Solve the baked network loop-free in OLF (see + :func:`get_pypowsybl_loopfree_parameters`) and in lightsim2grid. +5. Compare bus voltage magnitude (pu) and angle (deg). + +Bus mapping +----------- +lightsim2grid uses its own internal solver-bus indexing, not the IIDM bus +order. The mapping is taken directly from ``grid._ls_to_orig``: solver bus +``i`` corresponds to pypowsybl original bus index ``_ls_to_orig[i]``, i.e. +``network.get_buses().index[_ls_to_orig[i]]``. Building the grid with +``sort_index=False`` makes ``_ls_to_orig`` the identity, so it aligns 1:1 with +``get_buses()``; ``buses_for_sub=False`` keeps the bus set simple. This covers +*every* solver bus, including injection-free junction buses, which a mapping +inferred from element names cannot. +""" + +from dataclasses import dataclass + +import numpy as np +import pandas as pd +import pypowsybl as pp +import pypowsybl.loadflow as lf + +from ._from_pypowsybl import init as init_from_pypowsybl +from ._olf_bake import bake_outer_loops +from ._olf_params import get_pypowsybl_loopfree_parameters + + +def iidm_bus_voltages(network) -> pd.DataFrame: + """Per-bus |V| (pu) and angle (deg) from a solved IIDM network, indexed by + bus id (in ``get_buses()`` order), with the kV->pu conversion applied.""" + buses = network.get_buses() + vls = network.get_voltage_levels()[["nominal_v"]] + buses = buses.join(vls, on="voltage_level_id") + out = pd.DataFrame(index=buses.index) + out["vm_pu"] = buses["v_mag"] / buses["nominal_v"] + out["va_deg"] = buses["v_angle"] + return out + + +def lightsim_bus_to_iidm(grid, network) -> dict: + """Map lightsim2grid solver bus index -> IIDM bus id. + + Uses lightsim2grid's own ``_ls_to_orig`` array, which gives the index of + each solver bus in pypowsybl's *original* bus order. Paired with + ``network.get_buses().index`` (same original order) this pins every solver + bus -- including injection-free junction buses -- to a concrete IIDM bus + id, with no element-name parsing and no guessing. + + Requires the grid to have been built with ``init_from_pypowsybl(..., + sort_index=False, buses_for_sub=False)`` so that the original order matches + ``get_buses()`` one-to-one. + """ + ls_to_orig = np.asarray(grid._ls_to_orig) + orig_bus_ids = list(network.get_buses().index) + return { + ls: orig_bus_ids[orig] + for ls, orig in enumerate(ls_to_orig) + if orig < len(orig_bus_ids) + } + + +@dataclass +class ComparisonResult: + """Outcome of :func:`compare_baked`: how far lightsim2grid and OLF disagree. + + Attributes + ---------- + max_dvm_pu : float + Largest absolute voltage-magnitude difference, in per unit, over every + bus common to both engines. + max_dva_deg : float + Largest absolute voltage-angle difference, in degrees (raw). + max_dva_deg_offset_removed : float + Same as ``max_dva_deg`` but with a uniform angle offset removed first. + A constant offset on all buses is just a difference of reference-datum + convention between the two engines, not a physical disagreement, so this + is usually the meaningful angle metric. + table : pandas.DataFrame + Per-bus detail, indexed by IIDM bus id, with the OLF and lightsim2grid + magnitudes / angles and their differences (columns ``olf_vm``, + ``ls_vm``, ``olf_va``, ``ls_va``, ``dvm``, ``dva``). + """ + + max_dvm_pu: float + max_dva_deg: float + max_dva_deg_offset_removed: float + table: pd.DataFrame + + def __repr__(self): + return ( + f"ComparisonResult(max |dV| = {self.max_dvm_pu:.3e} pu, " + f"max |dAngle| = {self.max_dva_deg:.3e} deg, " + f"offset-removed = {self.max_dva_deg_offset_removed:.3e} deg)" + ) + + +def compare_baked( + network_factory, + slack_gen_id: str, + line_outages=None, + trafo_outages=None, + olf_loop_params: "lf.Parameters | None" = None, +): + """Bake, optionally apply outages, solve in both engines, and compare. + + Parameters + ---------- + network_factory : callable returning a fresh pypowsybl Network + Called twice (once per engine) so the two starts are identical. + lightsim2grid mutates/consumes the network it is built from, so a fresh + instance is needed for each side. + slack_gen_id : str + Generator id to use as the lightsim2grid slack. + line_outages, trafo_outages : list of str, optional + IIDM ids to disconnect identically in both engines after baking. + olf_loop_params : pypowsybl.loadflow.Parameters, optional + Parameters for the initial *with-loops* OLF solve. Defaults to + distributed slack + reactive limits. + + Returns + ------- + ComparisonResult + """ + line_outages = line_outages or [] + trafo_outages = trafo_outages or [] + if olf_loop_params is None: + # ``twt_split_shunt_admittance=True`` matches the transformer model used + # by the loop-free OLF solve and by lightsim2grid, so the baked + # setpoints are derived under the same model as the comparison solve. + olf_loop_params = lf.Parameters( + distributed_slack=True, + use_reactive_limits=True, + balance_type=pp.loadflow.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, + twt_split_shunt_admittance=True, + ) + loop_free_params = get_pypowsybl_loopfree_parameters() + + # ---- OLF side: with-loops solve, bake, outage, loop-free solve ---- + n_olf = network_factory() + lf.run_ac(n_olf, olf_loop_params) + bake_outer_loops(n_olf) + for lid in line_outages: + n_olf.update_lines(id=lid, connected1=False, connected2=False) + for tid in trafo_outages: + n_olf.update_2_windings_transformers(id=tid, connected1=False, connected2=False) + res = lf.run_ac(n_olf, loop_free_params) + if res[0].status != pp.loadflow.ComponentStatus.CONVERGED: + raise RuntimeError(f"OLF loop-free did not converge: {res[0].status}") + olf_v = iidm_bus_voltages(n_olf) # indexed by IIDM bus id + + # ---- lightsim2grid side: bake, outage, solve ---- + n_ls = network_factory() + lf.run_ac(n_ls, olf_loop_params) + bake_outer_loops(n_ls) + grid = init_from_pypowsybl( + n_ls, + gen_slack_id=slack_gen_id, + sort_index=False, + buses_for_sub=False, + ) + solver_to_iidm = lightsim_bus_to_iidm(grid, n_ls) + line_ids = list(n_ls.get_lines().index) + for lid in line_outages: + grid.deactivate_powerline(line_ids.index(lid)) + trafo_ids = list(n_ls.get_2_windings_transformers().index) + for tid in trafo_outages: + grid.deactivate_trafo(trafo_ids.index(tid)) + + V = grid.ac_pf(np.full(grid.total_bus(), 1.06 + 0j), 20, 1e-10) + if len(V) == 0: + raise RuntimeError("lightsim2grid did not converge") + + n_bus = len(V) + ls_v = pd.DataFrame( + { + "vm_pu": [abs(V[s]) for s in range(n_bus)], + "va_deg": [np.degrees(np.angle(V[s])) for s in range(n_bus)], + }, + index=[solver_to_iidm.get(s) for s in range(n_bus)], + ) + ls_v = ls_v[ls_v.index.notna()] # drop any unmapped junction bus + + # ---- compare on the intersection of mapped IIDM bus ids ---- + common = olf_v.index.intersection(ls_v.index) + table = pd.DataFrame( + { + "olf_vm": olf_v.loc[common, "vm_pu"], + "ls_vm": ls_v.loc[common, "vm_pu"], + "olf_va": olf_v.loc[common, "va_deg"], + "ls_va": ls_v.loc[common, "va_deg"], + } + ) + table["dvm"] = (table["olf_vm"] - table["ls_vm"]).abs() + table["dva"] = (table["olf_va"] - table["ls_va"]).abs() + # A uniform angle offset is just a reference-datum convention difference; + # report both raw and offset-removed. + offset = (table["olf_va"] - table["ls_va"]).median() + dva_rel = ((table["olf_va"] - table["ls_va"]) - offset).abs() + + return ComparisonResult( + max_dvm_pu=float(table["dvm"].max()), + max_dva_deg=float(table["dva"].max()), + max_dva_deg_offset_removed=float(dva_rel.max()), + table=table, + ) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py new file mode 100644 index 00000000..63c789b0 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -0,0 +1,126 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Canonical PowSyBl Open Load Flow (OLF) parameters that disable *every* outer +loop, so that OLF solves the same single-shot power-flow problem lightsim2grid +solves. + +This is the single source of truth used by the lightsim2grid benchmarks, the +documentation, the test suite and the OLF-vs-lightsim2grid comparison helper. +Disabling the outer loops is what makes a *raw* (un-baked) network give +consistent results between the two engines on grids where no outer loop would +trigger anyway; on grids where the loops do act, combine it with +:func:`lightsim2grid.network.bake_outer_loops`. +""" + +import inspect +from typing import Iterable, Optional, Union + +import pypowsybl.loadflow as _lf + +# pypowsybl >= 1.15 renamed ``connected_component_mode`` -> ``component_mode`` +# (with the ``ComponentMode`` enum). Pick whichever the installed version +# exposes so we neither trigger the deprecation warning on recent pypowsybl nor +# break on older releases. Both spellings select the *main connected* component. +_HAS_COMPONENT_MODE = "component_mode" in inspect.signature(_lf.Parameters.__init__).parameters + + +def get_pypowsybl_loopfree_parameters( + slack_bus_ids: Optional[Union[str, Iterable[str]]] = None, + **overrides, +) -> _lf.Parameters: + """Build a fresh :class:`pypowsybl.loadflow.Parameters` with every OLF outer + loop disabled. + + The returned parameters disable distributed slack, reactive limits, + transformer / shunt / phase-shifter voltage control, area-interchange and + secondary-voltage control, automation systems, etc. The mechanism that + makes this future-proof is the empty ``outerLoopNames`` allow-list: it + registers *zero* outer loops regardless of which loops a future OLF release + adds. Every individual trigger flag is also forced off (belt and + suspenders), and the control modes are pushed to the inner Newton-Raphson + loop so nothing escalates to an outer loop. + + Parameters + ---------- + slack_bus_ids : str or iterable of str, optional + If given, the slack is pinned by NAME on these bus(es) + (``slackBusSelectionMode = "NAME"``) and ``read_slack_bus`` is turned + off. This is what the benchmarks need to use the same slack as + lightsim2grid. If ``None`` (default), the slack is read from the network + (``read_slack_bus = True``). + **overrides + Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to override + on the returned object (e.g. ``distributed_slack=True``). + + Returns + ------- + pypowsybl.loadflow.Parameters + A new object on each call (no shared mutable state). + + Notes + ----- + Some ``provider_parameters`` keys are OLF-specific; other load-flow + providers may ignore or warn on them. ``maxOuterLoopIterations`` is set to + ``"1"`` because OLF rejects ``"0"``; with an empty ``outerLoopNames`` the + iteration cap is moot (there is no outer loop to iterate). Verified against + pypowsybl 1.15.0. + """ + provider_parameters = { + # ---- empty allow-list: ZERO outer loops, future-proof ---- + "outerLoopNames": "", + # ---- every outer-loop creation trigger forced off (belt and suspenders) ---- + "areaInterchangeControl": "false", + "svcVoltageMonitoring": "false", + "secondaryVoltageControl": "false", + "transformerReactivePowerControl": "false", + "simulateAutomationSystems": "false", + "voltageRemoteControl": "false", + "generatorVoltageRemoteControl": "false", + # ---- control modes pushed to inner-loop / harmless ---- + "transformerVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", + "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", + "phaseShifterControlMode": "CONTINUOUS_WITH_DISCRETISATION", + # ---- NR inner-solver settings forced (the only thing left active) ---- + "maxNewtonRaphsonIterations": "15", + "newtonRaphsonConvEpsPerEq": "1.0E-4", + "stateVectorScalingMode": "NONE", + "lineSearchStateVectorScalingMaxIteration": "10", # inert with NONE; forced anyway + # ---- hard backstop (0 is rejected by OLF; 1 is moot with empty allow-list) ---- + "maxOuterLoopIterations": "1", + "slackDistributionFailureBehavior": "FAIL", + } + + read_slack_bus = slack_bus_ids is None + if slack_bus_ids is not None: + if not isinstance(slack_bus_ids, str): + slack_bus_ids = ",".join(str(s) for s in slack_bus_ids) + provider_parameters["slackBusSelectionMode"] = "NAME" + provider_parameters["slackBusesIds"] = f"{slack_bus_ids}" + + kwargs = dict( + distributed_slack=False, + balance_type=_lf.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, # forced; inert with slack off + use_reactive_limits=False, + phase_shifter_regulation_on=False, + transformer_voltage_control_on=False, + shunt_compensator_voltage_control_on=False, + voltage_init_mode=_lf.VoltageInitMode.UNIFORM_VALUES, + read_slack_bus=read_slack_bus, + write_slack_bus=True, + dc_use_transformer_ratio=True, + twt_split_shunt_admittance=True, + provider_parameters=provider_parameters, + ) + if _HAS_COMPONENT_MODE: + kwargs["component_mode"] = _lf.ComponentMode.MAIN_CONNECTED + else: + kwargs["connected_component_mode"] = _lf.ConnectedComponentMode.MAIN + kwargs.update(overrides) + return _lf.Parameters(**kwargs) diff --git a/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py b/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py index 8e0fc72e..98cd5e15 100644 --- a/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py +++ b/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py @@ -1,6 +1,8 @@ import pypowsybl as pypow import pypowsybl.loadflow as pypow_lf +from lightsim2grid.network import get_pypowsybl_loopfree_parameters + def get_same_slack(case_name): if case_name == "ieee9": @@ -19,100 +21,11 @@ def get_same_slack(case_name): raise RuntimeError(f"Unknown env {case_name}") -def get_pypowsybl_parameters(slack_voltage_level=None): - try: - # modern parameters (eg 1.13.0) - params = pypow_lf.Parameters( - voltage_init_mode=pypow._pypowsybl.VoltageInitMode.UNIFORM_VALUES, - transformer_voltage_control_on=False, - use_reactive_limits=False, - phase_shifter_regulation_on=False, - twt_split_shunt_admittance=True, - shunt_compensator_voltage_control_on=False, - read_slack_bus=False, - write_slack_bus=True, - distributed_slack=False, - dc_use_transformer_ratio=True, - hvdc_ac_emulation=False, - dc_power_factor=1., - provider_parameters={ - "useActiveLimits": "false", - "useReactiveLimits": "false", - "svcVoltageMonitoring": "false", - "voltageRemoteControl": "false", - "writeReferenceTerminals": "false", - "voltagePerReactivePowerControl": "false", - "generatorReactivePowerRemoteControl": "false", - "secondaryVoltageControl": "false", - # see https://github.com/powsybl/pypowsybl/issues/1127#issuecomment-3581713875 - 'slackDistributionFailureBehavior' : 'LEAVE_ON_SLACK_BUS', - # 'transformerVoltageControlMode' : 'WITH_GENERATOR_VOLTAGE_CONTROL', - 'plausibleActivePowerLimit': '5000' - } - ) - except TypeError as exc: - try: - # a bit less modern parameters (eg 1.9.0) - params = pypow_lf.Parameters( - voltage_init_mode=pypow._pypowsybl.VoltageInitMode.UNIFORM_VALUES, - transformer_voltage_control_on=False, - use_reactive_limits=False, - phase_shifter_regulation_on=False, - twt_split_shunt_admittance=True, - shunt_compensator_voltage_control_on=False, - read_slack_bus=False, - write_slack_bus=True, - distributed_slack=False, - dc_use_transformer_ratio=True, - # hvdc_ac_emulation=False, - dc_power_factor=1., - provider_parameters={ - "useActiveLimits": "false", - "useReactiveLimits": "false", - "svcVoltageMonitoring": "false", - "voltageRemoteControl": "false", - "writeReferenceTerminals": "false", - "voltagePerReactivePowerControl": "false", - "generatorReactivePowerRemoteControl": "false", - "secondaryVoltageControl": "false", - # see https://github.com/powsybl/pypowsybl/issues/1127#issuecomment-3581713875 - 'slackDistributionFailureBehavior' : 'LEAVE_ON_SLACK_BUS', - # 'transformerVoltageControlMode' : 'WITH_GENERATOR_VOLTAGE_CONTROL', - 'plausibleActivePowerLimit': '5000' - } - ) - except TypeError as exc2: - # older parameters (eg 1.0.0) - params = pypow_lf.Parameters( - voltage_init_mode=pypow._pypowsybl.VoltageInitMode.UNIFORM_VALUES, - transformer_voltage_control_on=False, - no_generator_reactive_limits=True, # documented in the doc but apparently fails - phase_shifter_regulation_on=False, - twt_split_shunt_admittance=True, - simul_shunt=False, # documented in the doc but apparently fails - read_slack_bus=False, - write_slack_bus=True, - distributed_slack=False, - dc_use_transformer_ratio=True, - # hvdc_ac_emulation=False, - # dc_power_factor=1., - provider_parameters={ - "useActiveLimits": "false", - "useReactiveLimits": "false", - "svcVoltageMonitoring": "false", - "voltageRemoteControl": "false", - "writeReferenceTerminals": "false", - "voltagePerReactivePowerControl": "false", - "generatorReactivePowerRemoteControl": "false", - "secondaryVoltageControl": "false", - # see https://github.com/powsybl/pypowsybl/issues/1127#issuecomment-3581713875 - 'slackDistributionFailureBehavior' : 'LEAVE_ON_SLACK_BUS', - # 'transformerVoltageControlMode' : 'WITH_GENERATOR_VOLTAGE_CONTROL', - 'plausibleActivePowerLimit': '5000' - } - ) - if slack_voltage_level is not None: - params.provider_parameters["slackBusSelectionMode"] = "NAME" - params.provider_parameters["slackBusesIds"] = f"{slack_voltage_level}" - return params +def get_pypowsybl_parameters(slack_voltage_level=None): + # single source of truth: the canonical "every outer loop disabled" + # parameters shipped with lightsim2grid (see + # lightsim2grid.network.get_pypowsybl_loopfree_parameters). When a slack + # voltage level is given, the slack is pinned by name so lightsim2grid and + # pypowsybl use the same slack bus. + return get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_voltage_level) diff --git a/lightsim2grid/tests/test_olf_bake.py b/lightsim2grid/tests/test_olf_bake.py new file mode 100644 index 00000000..96e31b1e --- /dev/null +++ b/lightsim2grid/tests/test_olf_bake.py @@ -0,0 +1,376 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Validation of ``bake_outer_loops`` + ``compare_baked`` against PowSyBl Open Load +Flow. + +Two groups of tests: + +* ``test_olf_*`` -- pure pypowsybl: solve with outer loops, bake, solve + loop-free, and check the baked loop-free solve reproduces the with-loops + result. They exercise the bake on generators, VSC, SVC, shunts, and ratio + + phase tap changers. +* ``test_ls_*`` -- check lightsim2grid reproduces the OLF loop-free result, + including through line / transformer outages. +""" + +import unittest + +import numpy as np + +try: + import pypowsybl as pp + import pypowsybl.loadflow as lf + from lightsim2grid.network import ( + init_from_pypowsybl, + bake_outer_loops, + compare_baked, + get_pypowsybl_loopfree_parameters, + ) + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + +from global_var_tests import ( + CURRENT_PYPOW_VERSION, + VERSION_PHASESHIFT_OK_PYPOW, +) + + +# Solver-tolerance thresholds. +TOL_VM_PU = 1e-3 +TOL_VA_DEG = 5e-2 +TOL_VM_KV = 1e-2 # for the pure-OLF kV-space check + + +def _with_loops_params(): + # ``twt_split_shunt_admittance=True`` matches the transformer model the + # loop-free / lightsim2grid solve uses (see get_pypowsybl_loopfree_parameters); + # keeping it consistent here isolates the outer-loop effect, which is what + # baking neutralizes. + return lf.Parameters( + distributed_slack=True, + use_reactive_limits=True, + balance_type=pp.loadflow.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, + twt_split_shunt_admittance=True, + ) + + +def ieee14_with_qbind(): + """IEEE-14 with one generator's Q range tightened so the reactive-limit + outer loop is forced to switch it PV->PQ. Exercises the freeze logic.""" + n = pp.network.create_ieee14() + n.update_generators(id="B2-G", max_q=10.0, min_q=-10.0) + return n + + +def ieee14_forced_pv_pq(): + """IEEE-14 where one generator is forced PV->PQ at its *upper* Q limit and + another at its *lower* limit, by tightening each limit past the value that + generator reaches with limits wide open. Exercises both freeze directions + with the switch happening naturally during the loadflow.""" + n = pp.network.create_ieee14() + n.update_generators( + id=list(n.get_generators().index), + min_q=[-9999] * 5, max_q=[9999] * 5, + ) + # B3-G unconstrained Q_gen ~= 25.08 -> cap below to bind at max. + n.update_generators(id="B3-G", max_q=20.08) + # B6-G unconstrained Q_gen ~= 12.73 -> raise min above to bind at min. + n.update_generators(id="B6-G", min_q=18.0) + return n + + +def four_substations(): + """Node-breaker grid with VSC + LCC HVDC, an SVC regulating voltage, a + shunt, and ratio + phase tap changers. Exercises every bake path.""" + return pp.network.create_four_substations_node_breaker_network() + + +def _olf_roundtrip_max_dev(network_factory): + """Return (max |dV| kV, max |dAngle| deg) between OLF-with-loops and the + baked OLF-loop-free solve. Pure pypowsybl; no lightsim2grid. This is the + 'without outer loop' test: the baked grid solved with every loop disabled + must reproduce the original with-loops operating point.""" + with_loops = _with_loops_params() + loop_free = get_pypowsybl_loopfree_parameters() + + n_ref = network_factory() + lf.run_ac(n_ref, with_loops) + ref = n_ref.get_buses()[["v_mag", "v_angle"]].copy() + + n_baked = network_factory() + lf.run_ac(n_baked, with_loops) + bake_outer_loops(n_baked) + res = lf.run_ac(n_baked, loop_free) + assert res[0].status == pp.loadflow.ComponentStatus.CONVERGED + baked = n_baked.get_buses()[["v_mag", "v_angle"]] + + cmp = ref.join(baked, lsuffix="_r", rsuffix="_b") + return ( + (cmp["v_mag_r"] - cmp["v_mag_b"]).abs().max(), + (cmp["v_angle_r"] - cmp["v_angle_b"]).abs().max(), + ) + + +def _control_snapshot(n): + """State of everything an AC outer loop can mutate. Used to detect, in a + fully OLF-version-independent way, whether any outer loop took an action: + if it did, at least one of these changes.""" + g = n.get_generators(attributes=["voltage_regulator_on", "q", "target_p"]) + snap = { + "reg": g["voltage_regulator_on"].copy(), + "q": g["q"].copy(), + "target_p": g["target_p"].copy(), + } + for name, getter, col in [ + ("rtc", "get_ratio_tap_changers", "tap"), + ("ptc", "get_phase_tap_changers", "tap"), + ("shunt", "get_shunt_compensators", "section_count"), + ("svc", "get_static_var_compensators", "regulation_mode"), + ]: + df = getattr(n, getter)(attributes=[col]) + snap[name] = df[col].copy() if len(df) else None + return snap + + +def _state_max_change(a, b): + """Largest discrete/continuous change between two control snapshots.""" + flips = int((a["reg"] != b["reg"]).sum()) + dq = float((a["q"] - b["q"]).abs().max()) + dtp = float((a["target_p"] - b["target_p"]).abs().max()) + discrete = flips + for k in ("rtc", "ptc", "shunt", "svc"): + if a[k] is not None and b[k] is not None: + discrete += int((a[k] != b[k]).sum()) + return discrete, dq, dtp + + +@unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") +@unittest.skipUnless( + HAS_PYPOWSYBL and CURRENT_PYPOW_VERSION >= VERSION_PHASESHIFT_OK_PYPOW, + "pypowsybl too old (no solved_tap_position / phase-shifter support)", +) +class TestOlfBake(unittest.TestCase): + # ----------------------------------------------------------------- + # Pure-OLF tests (no lightsim2grid needed) + # ----------------------------------------------------------------- + def _assert_baked_is_inert(self, network_factory, tol_kv=TOL_VM_KV, tol_q=1e-2): + """OLF-version-independent proof that no outer loop acts on the baked grid. + + Two independent angles, neither parsing report text: + + (A) Solution agreement: on the baked grid, solving WITH all outer loops + and solving loop-free give the same bus voltages. If any loop had + acted in the with-loops run, that action would be absent from the + loop-free run and the two would differ. + + (B) State invariance: re-running WITH loops on the baked grid flips no + regulation flag, moves no tap / section / SVC mode, and leaves Q + unchanged. + """ + with_loops = _with_loops_params() + loop_free = get_pypowsybl_loopfree_parameters() + + # (A) with-loops vs loop-free on the baked grid + n_with = network_factory() + lf.run_ac(n_with, with_loops) + bake_outer_loops(n_with) + res_with = lf.run_ac(n_with, with_loops) + v_with = n_with.get_buses()[["v_mag", "v_angle"]].copy() + + n_free = network_factory() + lf.run_ac(n_free, with_loops) + bake_outer_loops(n_free) + lf.run_ac(n_free, loop_free) + v_free = n_free.get_buses()[["v_mag", "v_angle"]] + + cmp = v_with.join(v_free, lsuffix="_w", rsuffix="_f") + self.assertLess( + (cmp["v_mag_w"] - cmp["v_mag_f"]).abs().max(), tol_kv, + "with-loops and loop-free disagree on baked grid -> a loop acted", + ) + + # (B) state invariance across the with-loops re-run + n_state = network_factory() + lf.run_ac(n_state, with_loops) + bake_outer_loops(n_state) + before = _control_snapshot(n_state) + lf.run_ac(n_state, with_loops) + after = _control_snapshot(n_state) + discrete, dq, _dtp = _state_max_change(before, after) + self.assertEqual(discrete, 0, f"{discrete} controllers changed state on baked grid") + self.assertLess(dq, tol_q, f"Q moved by {dq} on baked re-run -> a loop acted") + + return res_with[0].status + + def test_olf_ieee14_reactive_limit_roundtrip(self): + """WITHOUT outer loops: baked grid solved loop-free reproduces the + original with-loops result.""" + dvm, dva = _olf_roundtrip_max_dev(ieee14_with_qbind) + self.assertLess(dvm, TOL_VM_KV) + self.assertLess(dva, 1e-2) + + def test_olf_four_substations_roundtrip(self): + """WITHOUT outer loops, on the VSC+LCC HVDC / SVC / shunt / tap grid.""" + dvm, dva = _olf_roundtrip_max_dev(four_substations) + self.assertLess(dvm, TOL_VM_KV) + self.assertLess(dva, 1e-2) + + def test_olf_pv_pq_switch_both_directions(self): + """The headline case: a generator that hits max_q and one that hits + min_q are frozen to fixed-Q (PQ) at the binding value; generators inside + their limits stay PV. The baked loop-free solve reproduces the + with-limits one.""" + n_ref = ieee14_forced_pv_pq() + lf.run_ac(n_ref, _with_loops_params()) + q_ref = n_ref.get_generators(attributes=["q"])["q"] + + n = ieee14_forced_pv_pq() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + g = n.get_generators(attributes=["voltage_regulator_on", "target_q"]) + + # Bound generators frozen to PQ at the limit. + self.assertFalse(g.loc["B3-G", "voltage_regulator_on"]) + self.assertLess(abs(g.loc["B3-G", "target_q"] - 20.08), 1e-1) + self.assertFalse(g.loc["B6-G", "voltage_regulator_on"]) + self.assertLess(abs(g.loc["B6-G", "target_q"] - 18.0), 1e-1) + # Unbound generators still PV. + self.assertTrue(g.loc[["B1-G", "B2-G", "B8-G"], "voltage_regulator_on"].all()) + + # Loop-free re-solve reproduces the with-limits reactive powers. + res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) + q_redo = n.get_generators(attributes=["q"])["q"] + self.assertLess((q_redo - q_ref).abs().max(), 1e-2) + + def test_olf_ieee14_baked_inert(self): + """WITH outer loops: they trigger nothing on the baked grid (robust + check, no reporter).""" + status = self._assert_baked_is_inert(ieee14_forced_pv_pq) + self.assertEqual(status, pp.loadflow.ComponentStatus.CONVERGED) + + def test_olf_four_substations_baked_inert(self): + """WITH outer loops on the HVDC/SVC/shunt/tap grid: nothing triggers.""" + status = self._assert_baked_is_inert(four_substations) + self.assertEqual(status, pp.loadflow.ComponentStatus.CONVERGED) + + def test_olf_unbaked_loops_do_act_control(self): + """Control: on the UN-baked grid the outer loops genuinely act, so the + inertness checks above are not vacuously passing.""" + n_with = ieee14_forced_pv_pq() + lf.run_ac(n_with, _with_loops_params()) + v_with = n_with.get_buses()[["v_mag"]].copy() + n_free = ieee14_forced_pv_pq() + lf.run_ac(n_free, get_pypowsybl_loopfree_parameters()) + v_free = n_free.get_buses()[["v_mag"]] + cmp = v_with.join(v_free, lsuffix="_w", rsuffix="_f") + self.assertGreater((cmp["v_mag_w"] - cmp["v_mag_f"]).abs().max(), 1e-2) + + # ----------------------------------------------------------------- + # Supplementary reporter-based check (OLF-version dependent: it parses + # report text, whose wording can change between PowSyBl releases). The + # robust checks above are the primary guarantees; this one is skipped + # rather than failed if the marker strings ever stop matching. + # ----------------------------------------------------------------- + _OUTER_LOOP_ACTION_MARKERS = ( + "Outer loop iteration", + "PV -> PQ", + "PQ -> PV", + "switched", + ) + + def _outer_loop_acted(self, report_text): + return any(m in report_text for m in self._OUTER_LOOP_ACTION_MARKERS) + + def test_olf_baked_reporter_shows_no_action(self): + """Supplementary: a node reporter shows no outer-loop action on the + baked grid. First asserts the markers DO fire on the un-baked grid; if + they don't (e.g. PowSyBl reworded the report), the whole check is + skipped, since the robust tests already cover the invariant.""" + n_orig = ieee14_forced_pv_pq() + rep_orig = pp.report.ReportNode() + lf.run_ac(n_orig, _with_loops_params(), report_node=rep_orig) + if not self._outer_loop_acted(str(rep_orig)): + self.skipTest("report markers not present in this PowSyBl version") + + n = ieee14_forced_pv_pq() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + rep = pp.report.ReportNode() + lf.run_ac(n, _with_loops_params(), report_node=rep) + self.assertFalse( + self._outer_loop_acted(str(rep)), + "reporter shows an outer loop acted on the baked grid:\n" + str(rep), + ) + + # ----------------------------------------------------------------- + # lightsim2grid agreement tests + # ----------------------------------------------------------------- + def test_ls_no_outage_agrees(self): + r = compare_baked(ieee14_with_qbind, slack_gen_id="B1-G") + self.assertLess(r.max_dvm_pu, TOL_VM_PU) + self.assertLess(r.max_dva_deg_offset_removed, TOL_VA_DEG) + + def test_ls_single_line_outage_agrees(self): + r = compare_baked( + ieee14_with_qbind, slack_gen_id="B1-G", line_outages=["L1-2-1"] + ) + self.assertLess(r.max_dvm_pu, TOL_VM_PU) + self.assertLess(r.max_dva_deg_offset_removed, TOL_VA_DEG) + + def test_ls_transformer_outage_agrees(self): + r = compare_baked( + ieee14_with_qbind, slack_gen_id="B1-G", trafo_outages=["T4-7-1"] + ) + self.assertLess(r.max_dvm_pu, TOL_VM_PU) + self.assertLess(r.max_dva_deg_offset_removed, TOL_VA_DEG) + + def test_ls_two_line_outage_agrees(self): + """Disconnecting L1-2-1 and L7-9-1 strands the VL7/VL8 corner (VL8 + carries generator B8-G and connects out only through line L7-8 into the + injection-free junction VL7). With the bus mapping taken from + ``grid._ls_to_orig`` the two engines agree to solver tolerance here too, + including on the junction bus.""" + r = compare_baked( + ieee14_with_qbind, + slack_gen_id="B1-G", + line_outages=["L1-2-1", "L7-9-1"], + ) + self.assertLess(r.max_dvm_pu, TOL_VM_PU) + self.assertLess(r.max_dva_deg_offset_removed, TOL_VA_DEG) + + def test_ls_unbaked_disagrees(self): + """Control: WITHOUT baking, OLF (full outer loops) and lightsim2grid + must disagree after a line outage -- that disagreement is the whole + reason baking exists.""" + LINE = "L1-2-1" + n_olf = ieee14_with_qbind() + n_olf.update_lines(id=LINE, connected1=False, connected2=False) + lf.run_ac(n_olf, _with_loops_params()) + b = n_olf.get_buses().join( + n_olf.get_voltage_levels()[["nominal_v"]], on="voltage_level_id" + ) + olf_vm = np.sort((b["v_mag"] / b["nominal_v"]).to_numpy()) + + n_ls = ieee14_with_qbind() + grid = init_from_pypowsybl( + n_ls, gen_slack_id="B1-G", sort_index=False, buses_for_sub=False + ) + grid.deactivate_powerline(list(n_ls.get_lines().index).index(LINE)) + V = grid.ac_pf(np.full(grid.total_bus(), 1.06 + 0j), 20, 1e-10) + ls_vm = np.sort(np.abs(V)) + + spread = np.max(np.abs(olf_vm - ls_vm)) + self.assertGreater(spread, 1e-2, f"expected disagreement, got {spread:.2e}") + + +if __name__ == "__main__": + unittest.main() From bc832e4450503180d58adfe56f235df77102fa4b Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 25 Jun 2026 10:00:24 +0200 Subject: [PATCH 004/166] add multithreaded security analysis Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 10 + docs/security_analysis.rst | 45 ++++ lightsim2grid/contingencyAnalysis.py | 17 ++ src/bindings/python/binding_batch.cpp | 10 + src/core/CMakeLists.txt | 10 + .../batch_algorithm/BaseBatchSolverSynch.cpp | 78 +++++- .../batch_algorithm/BaseBatchSolverSynch.hpp | 33 +++ .../batch_algorithm/ContingencyAnalysis.cpp | 253 +++++++++++++----- .../batch_algorithm/ContingencyAnalysis.hpp | 41 ++- 9 files changed, 421 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 532b3dff..2ff2c5ab 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -144,6 +144,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. follow such a change. This is a known limitation (see the ``TODO`` in the code). - [ADDED] `LSGrid.get_storages()`, `LSGrid.get_dclines()`, `LSGrid.get_svcs()` and `LSGrid.get_voltage_levels()` accessors for the (new) containers. +- [ADDED] multi-threaded contingency analysis: a `nb_thread` attribute on `ContingencyAnalysis` + and `ContingencyAnalysisCPP` (default ``1``). When set to a value greater than ``1``, the + contingency list is split into contiguous ranges, each solved by its own OS thread (each + thread gets its own solver and its own copy of the admittance matrix, and writes to disjoint + rows of the result matrix). The results do not depend on the number of threads (they match the + sequential ones up to the solver's convergence tolerance: each thread keeps its own solver + warm-start state, so converged voltages agree to ~1e-13, far below the powerflow tolerance). It + is implemented using only the C++ standard library (`std::thread`, no MPI / OpenMP), works for the + AC (Newton-Raphson), DC and `handle_disconnected_grid` modes, and behaves identically to the + previous sequential code when `nb_thread == 1`. - [ADDED] a `handle_disconnected_grid` mode for the contingency analysis (`ContingencyAnalysis` and `ContingencyAnalysisCPP`). By default (``False``) a contingency that splits the grid in several connected components is skipped (its voltages stay at 0, legacy behaviour). When set diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index 777ea6d6..eec6ff69 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -97,6 +97,51 @@ that would disconnect the chosen reference slack itself). Newton-Raphson algorithm (*eg* Gauss-Seidel or Fast-Decoupled) and enabling the mode raises an error. In DC the connectivity is already handled internally, so the flag has no effect. +Running the contingencies on multiple threads +--------------------------------------------- + +Starting from lightsim2grid 0.14.0, the contingencies can be solved on several CPU threads +at once. By default everything runs on a single thread (``nb_thread = 1``), reproducing the +exact same behaviour (and results) as before. Set the ``nb_thread`` attribute to a value +greater than ``1`` to split the work: + +.. code-block:: python + + import grid2op + from lightsim2grid import ContingencyAnalysis + from lightsim2grid import LightSimBackend + env = grid2op.make(..., backend=LightSimBackend()) + + security_analysis = ContingencyAnalysis(env) + security_analysis.add_all_n1_contingencies() + + # solve the contingencies on 4 threads + security_analysis.nb_thread = 4 + + res_p, res_a, res_v = security_analysis.get_flows() + +Internally the contingency list is split into ``nb_thread`` contiguous ranges, and each range +is solved by its own thread. To stay correct (and lock-free), every thread works on **its own** +solver instance and **its own** copy of the admittance matrix, and writes to a distinct set of +rows of the (shared) result matrix. As a consequence: + +- the results do not depend on the number of threads: ``nb_thread`` changes the timing, not the + numbers. They match the sequential results up to the solver's convergence tolerance (each + thread keeps its own solver warm-start state, so the converged voltages agree to roughly + ``1e-13``, far below the powerflow tolerance); +- it works for the AC (Newton-Raphson), DC and ``handle_disconnected_grid`` modes alike; +- there is a small per-thread set-up cost (one extra solver "warm-up" and one admittance-matrix + copy per additional thread), so the speed-up is sub-linear and most useful when there are many + contingencies to simulate. + +This feature only relies on the C++ standard library (``std::thread``): no additional dependency +(MPI, OpenMP, ...) is required. + +.. note:: + + ``nb_thread`` is also available on the lower-level ``ContingencyAnalysisCPP`` class (same + semantics). + .. _sa_benchmarks: Benchmarks (Contingency Analysis) diff --git a/lightsim2grid/contingencyAnalysis.py b/lightsim2grid/contingencyAnalysis.py index 17cc3c9f..a7b1fac7 100644 --- a/lightsim2grid/contingencyAnalysis.py +++ b/lightsim2grid/contingencyAnalysis.py @@ -149,6 +149,23 @@ def handle_disconnected_grid(self, val: bool): raise ValueError("The `handle_disconnected_grid` attribute must be a boolean.") self.computer.handle_disconnected_grid = bool(val) + @property + def nb_thread(self): + """Number of OS threads used to solve the contingencies (default: 1). + + With ``nb_thread == 1`` the behaviour is identical to the legacy + sequential computation. With ``nb_thread > 1`` the contingencies are + split across that many threads (each with its own solver and admittance + matrix copy); the results do not depend on the number of threads. + """ + return self.computer.nb_thread + + @nb_thread.setter + def nb_thread(self, val: int): + if int(val) != val: + raise ValueError("The `nb_thread` attribute must be an integer.") + self.computer.nb_thread = int(val) + # TODO implement that ! def __update_grid(self, backend_act): raise NotImplementedError("TODO !") diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index 036e7123..0a344007 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -72,6 +72,16 @@ void bind_batch(py::module_& m) { "behaviour. When True, the largest connected component is solved while " "the buses of the other component(s) are masked (their voltage is " "reported as 0). Requires a Newton-Raphson algorithm.)mydelim") + .def_property("nb_thread", + [](const ContingencyAnalysis & self){ return self.get_nb_thread(); }, + [](ContingencyAnalysis & self, int val){ self.set_nb_thread(val); }, + R"mydelim(Number of OS threads used to solve the contingencies (default: 1). " + "With nb_thread == 1 the behaviour is identical to the legacy sequential " + "computation. With nb_thread > 1 the contingency list is split into " + "contiguous ranges, each solved by its own thread (each with its own solver " + "and admittance matrix copy), writing to disjoint rows of the result matrix. " + "The results do not depend on the number of threads. Values < 1 are " + "clamped to 1.)mydelim") // solver control .def("change_algorithm", &ContingencyAnalysis::change_algorithm, DocLSGrid::change_algorithm.c_str()) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 6ce77e6e..2d2fd902 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -24,6 +24,12 @@ if(NOT PROJECT_NAME) endif() +# ============================================================================= +# Threads (std::thread) — used by the multi-threaded ContingencyAnalysis +# ============================================================================= +set(THREADS_PREFER_PTHREAD_FLAG ON) +find_package(Threads REQUIRED) + # ============================================================================= # KLU (SuiteSparse) detection — 4 strategies, same logic as historical root # ============================================================================= @@ -296,6 +302,10 @@ target_include_directories(lightsim2grid_core target_compile_definitions(lightsim2grid_core PRIVATE LS2G_BUILDING_CORE) +# std::thread support (multi-threaded ContingencyAnalysis). PUBLIC so the +# lightsim2grid_cpp binding target inherits the threading flags / link. +target_link_libraries(lightsim2grid_core PUBLIC Threads::Threads) + # ============================================================================= # Link optional solvers diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index ca7084c3..c377dca9 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -12,6 +12,7 @@ namespace ls2g { /** V is modified at each call ! + Member version: single-threaded path, uses the member solver / control / accumulators. **/ bool BaseBatchSolverSynch::compute_one_powerflow( const Eigen::SparseMatrix & Ybus, @@ -25,10 +26,36 @@ bool BaseBatchSolverSynch::compute_one_powerflow( double tol ) { - _algo.tell_solver_control(_algo_controler); + return compute_one_powerflow( + _algo, _algo_controler, _nb_solved, _timer_solver, + Ybus, V, Sbus, slack_ids, slack_weights, bus_pv, bus_pq, max_iter, tol); +} + +/** + V is modified at each call ! + Explicit version: operates on the passed solver / control / accumulators so it + can run concurrently (one solver per thread). The member Bbus_ is read-only here. +**/ +bool BaseBatchSolverSynch::compute_one_powerflow( + AlgorithmSelector & algo, + AlgoControl & control, + int & nb_solved, + double & timer_solver, + const Eigen::SparseMatrix & Ybus, + CplxVect & V, + const CplxVect & Sbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref bus_pv, + Eigen::Ref bus_pq, + int max_iter, + double tol +) +{ + algo.tell_solver_control(control); bool conv; - if(_algo.ac_solver_used()){ - conv = _algo.compute_pf( + if(algo.ac_solver_used()){ + conv = algo.compute_pf( Ybus, V, Sbus, @@ -42,7 +69,7 @@ bool BaseBatchSolverSynch::compute_one_powerflow( // native real DC entry point: Bbus_ is the (constant) real admittance built in // prepare_solver_input_base; Pbus is the real part of the (possibly per-step) injection const RealVect Pbus = Sbus.real(); - conv = _algo.compute_pf_dc( + conv = algo.compute_pf_dc( Bbus_, V, Pbus, @@ -52,10 +79,47 @@ bool BaseBatchSolverSynch::compute_one_powerflow( bus_pq); } if(conv){ - V = _algo.get_V().array(); + V = algo.get_V().array(); + } + ++nb_solved; + timer_solver += algo.get_computation_time(); + return conv; +} + +bool BaseBatchSolverSynch::warmup_solver( + AlgorithmSelector & algo, + AlgoControl & control, + CplxVect Vinit_solver, + int max_iter, + real_type tol) +{ + algo.reset(); + control.tell_all_changed(); + algo.tell_solver_control(control); + bool conv; + if(algo.ac_solver_used()){ + conv = algo.compute_pf( + Ybus_, + Vinit_solver, + Sbus_, + slack_ids_me_.as_eigen(), + slack_weights_, + bus_pv_.as_eigen(), + bus_pq_.as_eigen(), + max_iter, + tol); + } else { + conv = algo.compute_pf_dc( + Bbus_, + Vinit_solver, + Pbus_, + slack_ids_me_.as_eigen(), + slack_weights_, + bus_pv_.as_eigen(), + bus_pq_.as_eigen()); } - ++_nb_solved; - _timer_solver += _algo.get_computation_time(); + // subsequent per-contingency solves reuse the factorization + control.tell_none_changed(); return conv; } diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index f832fa65..384e2d6c 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -219,6 +219,8 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants } } + // member version: forwards to the explicit overload below using the + // member solver / control / accumulators (single-threaded path). bool compute_one_powerflow(const Eigen::SparseMatrix & Ybus, CplxVect & V, const CplxVect & Sbus, @@ -230,6 +232,37 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants double tol ); + // explicit version: operates on the passed solver / control and writes + // its book-keeping into the passed accumulators. This is what the + // multi-threaded ContingencyAnalysis uses (one solver per thread). The + // read-only member Bbus_ is only read here (safe to share across threads). + bool compute_one_powerflow(AlgorithmSelector & algo, + AlgoControl & control, + int & nb_solved, + double & timer_solver, + const Eigen::SparseMatrix & Ybus, + CplxVect & V, + const CplxVect & Sbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref bus_pv, + Eigen::Ref bus_pq, + int max_iter, + double tol + ); + + // Warm up a (freshly built) solver so its symbolic factorization / DC + // internal Ybus / sparsity pattern match the member solver after the + // n-powerflow. Mirrors the n-powerflow block of _finish_preprocessing + // (works on the member Ybus_ / Bbus_ / Sbus_ / Pbus_ — all read-only). + // `control` is left in the "nothing changed" state on return so the + // subsequent per-contingency solves reuse the factorization. + bool warmup_solver(AlgorithmSelector & algo, + AlgoControl & control, + CplxVect Vinit_solver, + int max_iter, + real_type tol); + void compute_flows_from_Vs(bool amps=true); CplxVect extract_Vsolver_from_Vinit(const CplxVect& Vinit, diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 8c4b9515..9d1e7b25 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -11,6 +11,9 @@ #include #include #include /* isfinite */ +#include +#include +#include namespace ls2g { @@ -240,7 +243,8 @@ void ContingencyAnalysis::init_li_coeffs( bool ContingencyAnalysis::remove_from_Ybus(Eigen::SparseMatrix & Ybus, const std::vector & coeffs, - bool ac_solver_used) + bool ac_solver_used, + AlgorithmSelector & algo) { if(ac_solver_used) { @@ -252,7 +256,7 @@ bool ContingencyAnalysis::remove_from_Ybus(Eigen::SparseMatrix & Ybus // DC solver stores the ybus internally, I update it // instead of building it over and over for(const Coeff& coeff : coeffs){ - _algo.update_internal_Ybus(coeff, false); // false => remove the coeff (using -= ) + algo.update_internal_Ybus(coeff, false); // false => remove the coeff (using -= ) } // in DC mode the solver takes the responsibility // so Ybus is always "connected". @@ -283,9 +287,9 @@ IntVect ContingencyAnalysis::is_grid_connected_after_contingency(){ IntVect res = IntVect::Constant(_li_coeffs.size(), 0); int cont_id = 0; for(const auto & coeffs_modif: _li_coeffs){ - if(remove_from_Ybus(Ybus, coeffs_modif, true)) res(cont_id) = 1; + if(remove_from_Ybus(Ybus, coeffs_modif, true, _algo)) res(cont_id) = 1; else res(cont_id) = 0; - readd_to_Ybus(Ybus, coeffs_modif, true); + readd_to_Ybus(Ybus, coeffs_modif, true, _algo); ++cont_id; } return res; @@ -294,7 +298,8 @@ IntVect ContingencyAnalysis::is_grid_connected_after_contingency(){ void ContingencyAnalysis::readd_to_Ybus( Eigen::SparseMatrix & Ybus, const std::vector & coeffs, - bool ac_solver_used) + bool ac_solver_used, + AlgorithmSelector & algo) { if(ac_solver_used){ for(const Coeff & coeff_to_remove: coeffs){ @@ -304,7 +309,7 @@ void ContingencyAnalysis::readd_to_Ybus( // DC solver stores the ybus internally, I update it // instead of building it over and over for(const Coeff& coeff : coeffs){ - _algo.update_internal_Ybus(coeff, true); // true => add back the coeff (using += ) + algo.update_internal_Ybus(coeff, true); // true => add back the coeff (using += ) } } } @@ -357,74 +362,190 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ if(!n_powerflow_has_conv) return; - // now perform the security analysis - size_t cont_id = 0; - CplxVect V; - bool conv; - for(const auto & coeffs_modif: _li_coeffs){ - auto timer_modif_Ybus = CustTimer(); - bool do_store = false; - conv = false; - - if(mask_mode){ - // disconnected-grid handling: solve the largest component while masking - // the rest, unless the contingency strands the chosen reference slack. - if(!_skip_mask[cont_id]){ - const std::vector & masked = _li_masked[cont_id]; - remove_from_Ybus(Ybus_, coeffs_modif, ac_solver_used); // invertibility handled by masking - _timer_modif_Ybus += timer_modif_Ybus.duration(); - - _algo.set_masked_buses(masked); - const RealVect sw = masked.empty() ? slack_weights_ : masked_slack_weights(masked); - V = Vinit_solver; // Vinit is reused for each contingencies - conv = compute_one_powerflow( - Ybus_, V, Sbus_, - slack_ids_me_.as_eigen(), sw, - bus_pv_.as_eigen(), bus_pq_.as_eigen(), - max_iter, tol / sn_mva); - _algo.set_masked_buses(std::vector()); // reset for the next contingency + // now perform the security analysis, possibly split across several threads + const size_t nb_cont = _li_coeffs.size(); + const int nb_thread = std::min(static_cast(nb_cont), std::max(1, _nb_thread)); + + if(nb_thread <= 1){ + // single-threaded path: reuse the (already warmed-up) member solver, + // member Ybus_ and the member accumulators -> identical to the legacy code. + std::exception_ptr err; + run_contingency_range( + 0, nb_cont, + _algo, _algo_controler, Ybus_, Vinit_solver, + ac_solver_used, mask_mode, max_iter, tol, sn_mva, + _timer_modif_Ybus, _nb_solved, _timer_solver, err); + if(err) std::rethrow_exception(err); + _timer_total = timer.duration(); + return; + } - timer_modif_Ybus = CustTimer(); - readd_to_Ybus(Ybus_, coeffs_modif, ac_solver_used); - _timer_modif_Ybus += timer_modif_Ybus.duration(); + // multi-threaded path: one solver + one Ybus copy + one AlgoControl per thread. + // Thread 0 reuses the member solver / Ybus_ / control (already warmed up) to + // save one copy + one warm-up. The remaining threads build a fresh solver of + // the same type and warm it up so its factorization / sparsity pattern match. + std::vector > extra_algos(nb_thread - 1); + std::vector controls(nb_thread); + std::vector > ybus_copies(nb_thread - 1); + std::vector th_timer_modif(nb_thread, 0.); + std::vector th_nb_solved(nb_thread, 0); + std::vector th_timer_solver(nb_thread, 0.); + std::vector th_err(nb_thread); + + controls[0] = _algo_controler; // already "nothing changed" after preprocessing + for(int t = 1; t < nb_thread; ++t){ + extra_algos[t - 1] = std::make_unique(); + AlgorithmSelector & algo = *extra_algos[t - 1]; + algo.set_lsgrid(&_grid_model); + algo.change_algorithm(get_algo_type()); + algo.set_config(_algo.get_config()); // match the member solver's configuration + controls[t] = _algo_controler; // copy, made independent by warmup_solver + warmup_solver(algo, controls[t], Vinit_solver, max_iter, tol); + ybus_copies[t - 1] = Ybus_; // thread-local working copy of the admittance + } + + // contiguous split of [0, nb_cont) across the threads + auto algo_for = [&](int t) -> AlgorithmSelector & { + return t == 0 ? _algo : *extra_algos[t - 1]; + }; + auto ybus_for = [&](int t) -> Eigen::SparseMatrix & { + return t == 0 ? Ybus_ : ybus_copies[t - 1]; + }; + const size_t base = nb_cont / static_cast(nb_thread); + const size_t rem = nb_cont % static_cast(nb_thread); + auto range_of = [&](int t, size_t & b, size_t & e){ + // contiguous split: the first `rem` threads get one extra contingency + b = static_cast(t) * base + std::min(static_cast(t), rem); + e = b + base + (static_cast(t) < rem ? 1 : 0); + }; + + // spawn threads 1..nb_thread-1, then run thread 0's share inline + std::vector threads; + threads.reserve(nb_thread - 1); + for(int t = 1; t < nb_thread; ++t){ + size_t b, e; + range_of(t, b, e); + threads.emplace_back([=, &algo_for, &ybus_for, &controls, &th_timer_modif, + &th_nb_solved, &th_timer_solver, &th_err, &Vinit_solver](){ + run_contingency_range( + b, e, + algo_for(t), controls[t], ybus_for(t), Vinit_solver, + ac_solver_used, mask_mode, max_iter, tol, sn_mva, + th_timer_modif[t], th_nb_solved[t], th_timer_solver[t], th_err[t]); + }); + } + { + size_t b, e; + range_of(0, b, e); + run_contingency_range( + b, e, + _algo, controls[0], Ybus_, Vinit_solver, + ac_solver_used, mask_mode, max_iter, tol, sn_mva, + th_timer_modif[0], th_nb_solved[0], th_timer_solver[0], th_err[0]); + } + + for(auto & th : threads) th.join(); - if(conv){ - // masked buses are not simulated: report 0 voltage for them - for(int b : masked) if(b >= 0 && b < V.size()) V(b) = cplx_type(0., 0.); - do_store = true; + // surface the first error (if any) and merge the per-thread accumulators + for(int t = 0; t < nb_thread; ++t){ + if(th_err[t]) std::rethrow_exception(th_err[t]); + } + for(int t = 0; t < nb_thread; ++t){ + _timer_modif_Ybus += th_timer_modif[t]; + _nb_solved += th_nb_solved[t]; + _timer_solver += th_timer_solver[t]; + } + + _timer_total = timer.duration(); +} + +void ContingencyAnalysis::run_contingency_range( + size_t cont_begin, + size_t cont_end, + AlgorithmSelector & algo, + AlgoControl & control, + Eigen::SparseMatrix & Ybus, + const CplxVect & Vinit_solver, + bool ac_solver_used, + bool mask_mode, + int max_iter, + real_type tol, + real_type sn_mva, + double & timer_modif_Ybus_acc, + int & nb_solved, + double & timer_solver, + std::exception_ptr & err) +{ + try { + CplxVect V; + for(size_t cont_id = cont_begin; cont_id < cont_end; ++cont_id){ + const std::vector & coeffs_modif = _li_coeffs[cont_id]; + auto timer_modif_Ybus = CustTimer(); + bool do_store = false; + bool conv = false; + + if(mask_mode){ + // disconnected-grid handling: solve the largest component while masking + // the rest, unless the contingency strands the chosen reference slack. + if(!_skip_mask[cont_id]){ + const std::vector & masked = _li_masked[cont_id]; + remove_from_Ybus(Ybus, coeffs_modif, ac_solver_used, algo); // invertibility handled by masking + timer_modif_Ybus_acc += timer_modif_Ybus.duration(); + + algo.set_masked_buses(masked); + const RealVect sw = masked.empty() ? slack_weights_ : masked_slack_weights(masked); + V = Vinit_solver; // Vinit is reused for each contingencies + conv = compute_one_powerflow( + algo, control, nb_solved, timer_solver, + Ybus, V, Sbus_, + slack_ids_me_.as_eigen(), sw, + bus_pv_.as_eigen(), bus_pq_.as_eigen(), + max_iter, tol / sn_mva); + algo.set_masked_buses(std::vector()); // reset for the next contingency + + timer_modif_Ybus = CustTimer(); + readd_to_Ybus(Ybus, coeffs_modif, ac_solver_used, algo); + timer_modif_Ybus_acc += timer_modif_Ybus.duration(); + + if(conv){ + // masked buses are not simulated: report 0 voltage for them + for(int b : masked) if(b >= 0 && b < V.size()) V(b) = cplx_type(0., 0.); + do_store = true; + } + } + // skipped contingency: conv stays false, voltages stay 0 + } else { + // legacy behaviour: skip the contingency if it disconnects the grid + bool invertible = remove_from_Ybus(Ybus, coeffs_modif, ac_solver_used, algo); + timer_modif_Ybus_acc += timer_modif_Ybus.duration(); + + if(invertible) + { + V = Vinit_solver; // Vinit is reused for each contingencies + conv = compute_one_powerflow( + algo, control, nb_solved, timer_solver, + Ybus, + V, + Sbus_, + slack_ids_me_.as_eigen(), + slack_weights_, + bus_pv_.as_eigen(), + bus_pq_.as_eigen(), + max_iter, + tol / sn_mva); } - } - // skipped contingency: conv stays false, voltages stay 0 - } else { - // legacy behaviour: skip the contingency if it disconnects the grid - bool invertible = remove_from_Ybus(Ybus_, coeffs_modif, ac_solver_used); - _timer_modif_Ybus += timer_modif_Ybus.duration(); - if(invertible) - { - V = Vinit_solver; // Vinit is reused for each contingencies - conv = compute_one_powerflow( - Ybus_, - V, - Sbus_, - slack_ids_me_.as_eigen(), - slack_weights_, - bus_pv_.as_eigen(), - bus_pq_.as_eigen(), - max_iter, - tol / sn_mva); + timer_modif_Ybus = CustTimer(); + readd_to_Ybus(Ybus, coeffs_modif, ac_solver_used, algo); + timer_modif_Ybus_acc += timer_modif_Ybus.duration(); + do_store = conv && invertible; } - timer_modif_Ybus = CustTimer(); - readd_to_Ybus(Ybus_, coeffs_modif, ac_solver_used); - _timer_modif_Ybus += timer_modif_Ybus.duration(); - do_store = conv && invertible; + if (do_store) _voltages.row(cont_id)(id_solver_to_me_.as_eigen()) = V.array(); } - - if (do_store) _voltages.row(cont_id)(id_solver_to_me_.as_eigen()) = V.array(); - ++cont_id; + } catch(...) { + err = std::current_exception(); } - _timer_total = timer.duration(); } // by default the flows are not 0 when the powerline is connected in the original topology diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 67b740fb..48850310 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -11,6 +11,7 @@ #include "BaseBatchSolverSynch.hpp" #include +#include namespace ls2g { @@ -117,6 +118,14 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch bool get_handle_disconnected_grid() const {return _handle_disconnected_grid;} void set_handle_disconnected_grid(bool val) {_handle_disconnected_grid = val;} + // number of OS threads used to solve the contingencies (default: 1). + // With nb_thread == 1 the behaviour is identical to the legacy sequential + // path. With nb_thread > 1 the contingency list is split into contiguous + // ranges, each solved by its own thread (own solver + own Ybus copy), + // writing to disjoint rows of the result matrix. Values < 1 are clamped to 1. + int get_nb_thread() const {return _nb_thread;} + void set_nb_thread(int n) {_nb_thread = (n < 1 ? 1 : n);} + // make the computation void compute(const CplxVect & Vinit, int max_iter, real_type tol); IntVect is_grid_connected_after_contingency(); @@ -168,10 +177,11 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch } } void init_li_coeffs(bool ac_solver_used, const SolverBusIdVect &id_me_to_solver); - // remove the line parameters from Ybus, this is to emulate its disconnection - bool remove_from_Ybus(Eigen::SparseMatrix & Ybus, const std::vector & coeffs, bool ac_solver_used); + // remove the line parameters from Ybus, this is to emulate its disconnection. + // `algo` is the solver whose internal Ybus is updated in DC mode (one per thread). + bool remove_from_Ybus(Eigen::SparseMatrix & Ybus, const std::vector & coeffs, bool ac_solver_used, AlgorithmSelector & algo); // after the coefficient has been removed with "remove_from_Ybus", add it back to Ybus - void readd_to_Ybus(Eigen::SparseMatrix & Ybus, const std::vector & coeffs, bool ac_solver_used); + void readd_to_Ybus(Eigen::SparseMatrix & Ybus, const std::vector & coeffs, bool ac_solver_used, AlgorithmSelector & algo); // by default the flows are not 0 when the powerline is connected in the original topology // this function sorts this out @@ -194,11 +204,36 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch // rescaled so its total is preserved (slack power stays among live slacks). RealVect masked_slack_weights(const std::vector & masked) const; + // solve the contingencies in [cont_begin, cont_end) using the passed solver + // (one per thread), its own AlgoControl and a thread-local Ybus copy. Writes + // directly to the (disjoint) rows of _voltages; book-keeping goes to the + // passed accumulators (merged by the caller after all threads join). Any + // thrown exception is captured into `err` instead of crossing the thread + // boundary. This is the body shared by the single- and multi-threaded paths. + void run_contingency_range(size_t cont_begin, + size_t cont_end, + AlgorithmSelector & algo, + AlgoControl & control, + Eigen::SparseMatrix & Ybus, + const CplxVect & Vinit_solver, + bool ac_solver_used, + bool mask_mode, + int max_iter, + real_type tol, + real_type sn_mva, + double & timer_modif_Ybus, + int & nb_solved, + double & timer_solver, + std::exception_ptr & err); + private: // li_default std::set > _li_defaults; // do not use unordered_set here, we rely on the order for different functions ! std::vector > _li_coeffs; // for each n-k, stores the coefficients I need to modify in the Ybus + // number of OS threads used to solve the contingencies (see set_nb_thread) + int _nb_thread = 1; + // "handle disconnected grid" mode (see set_handle_disconnected_grid) bool _handle_disconnected_grid = false; std::vector > _li_masked; // per contingency: masked solver bus ids (mask mode) From 2293913eb0d24602007b483832c463358c23709b Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 25 Jun 2026 10:56:17 +0200 Subject: [PATCH 005/166] support multiple version of pypowsybl in the parameters factory Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_params.py | 252 +++++++++++++----- 1 file changed, 190 insertions(+), 62 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 63c789b0..0b8f8099 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -17,18 +17,127 @@ consistent results between the two engines on grids where no outer loop would trigger anyway; on grids where the loops do act, combine it with :func:`lightsim2grid.network.bake_outer_loops`. + +The factory is written to be consistent across **every** pypowsybl version from +1.0.0 to 1.15.0. The pypowsybl ``Parameters`` constructor and the OLF provider +parameters changed over time (renamed kwargs, new enums, parameters that did not +exist yet, and values rejected by older releases). Rather than hard-code one +version, the factory introspects the installed pypowsybl and only passes what it +accepts: + +* top-level kwargs are filtered against ``Parameters.__init__`` and renamed + parameters fall back to their old spelling (``use_reactive_limits`` -> + ``no_generator_reactive_limits``, ``shunt_compensator_voltage_control_on`` -> + ``simul_shunt``, ``component_mode`` -> ``connected_component_mode``); +* enums are resolved from wherever the installed version exposes them; +* provider parameters are filtered against the OLF metadata (valid name *and*, + for enum-valued ones, valid value); +* the empty ``outerLoopNames`` allow-list is only used from pypowsybl 1.4.0 on, + because older OLF rejects the empty value ("Unknown outer loop ''"). Below + 1.4.0 the explicit trigger flags already disable every outer loop that exists. """ import inspect from typing import Iterable, Optional, Union +import pypowsybl as _pp import pypowsybl.loadflow as _lf -# pypowsybl >= 1.15 renamed ``connected_component_mode`` -> ``component_mode`` -# (with the ``ComponentMode`` enum). Pick whichever the installed version -# exposes so we neither trigger the deprecation warning on recent pypowsybl nor -# break on older releases. Both spellings select the *main connected* component. -_HAS_COMPONENT_MODE = "component_mode" in inspect.signature(_lf.Parameters.__init__).parameters + +def _ver_tuple(v: str): + out = [] + for part in str(v).split("."): + num = "" + for ch in part: + if ch.isdigit(): + num += ch + else: + break + out.append(int(num) if num else 0) + return tuple(out) + + +_PARAM_SIG = set(inspect.signature(_lf.Parameters.__init__).parameters) +_PYPOWSYBL_VER = _ver_tuple(_pp.__version__) +# OLF rejects an empty ``outerLoopNames`` value before 1.4.0 (and the parameter +# does not exist at all in 1.0.0); only use the empty-allow-list trick where it +# is accepted. +_OUTERLOOP_EMPTY_OK = _PYPOWSYBL_VER >= (1, 4) + +# Lazily-computed cache of the OLF provider parameter metadata +# ({name: set(possible_values) or None}); ``False`` means "not yet computed". +_OLF_PROVIDER_PARAMS = False + + +def _enum(name: str): + """Resolve an enum class by name from wherever pypowsybl exposes it.""" + for mod in (_lf, getattr(_pp, "_pypowsybl", None)): + if mod is not None and hasattr(mod, name): + return getattr(mod, name) + return None + + +def _olf_provider_params(): + """Return ``{name: set(possible_values) or None}`` for OpenLoadFlow, or + ``None`` if the installed pypowsybl does not expose the metadata.""" + global _OLF_PROVIDER_PARAMS + if _OLF_PROVIDER_PARAMS is not False: + return _OLF_PROVIDER_PARAMS + + result = None + fn = getattr(_lf, "get_provider_parameters", None) + if fn is not None: + try: + df = fn("OpenLoadFlow") + has_pv = "possible_values" in df.columns + result = {} + for name in df.index: + vals = None + if has_pv: + raw = df.loc[name, "possible_values"] + if isinstance(raw, str) and raw.strip() not in ("", "[]"): + vals = {x.strip() for x in raw.strip("[]").split(",") if x.strip()} + result[name] = vals + except Exception: + result = None + if result is None: + fn = getattr(_lf, "get_provider_parameters_names", None) + if fn is not None: + try: + result = {n: None for n in fn("OpenLoadFlow")} + except Exception: + result = None + + _OLF_PROVIDER_PARAMS = result + return result + + +# The full "ZERO outer loops" OLF provider-parameter wish-list. Entries that the +# installed OLF does not know (or whose value it rejects) are filtered out. +_DESIRED_PROVIDER = { + # ---- empty allow-list: ZERO outer loops, future-proof (>= 1.4.0) ---- + "outerLoopNames": "", + # ---- every outer-loop creation trigger forced off (belt and suspenders) ---- + "areaInterchangeControl": "false", + "svcVoltageMonitoring": "false", + "secondaryVoltageControl": "false", + "transformerReactivePowerControl": "false", + "simulateAutomationSystems": "false", + "voltageRemoteControl": "false", + "generatorVoltageRemoteControl": "false", + # ---- control modes pushed to inner-loop / harmless ---- + "transformerVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", + "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", + "phaseShifterControlMode": "CONTINUOUS_WITH_DISCRETISATION", + # ---- NR inner-solver settings forced (the only thing left active) ---- + "maxNewtonRaphsonIterations": "15", + "newtonRaphsonConvEpsPerEq": "1.0E-4", + "stateVectorScalingMode": "NONE", + "lineSearchStateVectorScalingMaxIteration": "10", # inert with NONE; forced anyway + # ---- hard backstop (0 is rejected by OLF; 1 is moot with empty allow-list) ---- + "maxOuterLoopIterations": "1", + "slackDistributionFailureBehavior": "FAIL", +} def get_pypowsybl_loopfree_parameters( @@ -40,12 +149,14 @@ def get_pypowsybl_loopfree_parameters( The returned parameters disable distributed slack, reactive limits, transformer / shunt / phase-shifter voltage control, area-interchange and - secondary-voltage control, automation systems, etc. The mechanism that - makes this future-proof is the empty ``outerLoopNames`` allow-list: it - registers *zero* outer loops regardless of which loops a future OLF release - adds. Every individual trigger flag is also forced off (belt and - suspenders), and the control modes are pushed to the inner Newton-Raphson - loop so nothing escalates to an outer loop. + secondary-voltage control, automation systems, etc. From pypowsybl 1.4.0 on, + the empty ``outerLoopNames`` allow-list also registers *zero* outer loops + regardless of which loops a future OLF release adds; on older pypowsybl the + explicit trigger flags below disable every outer loop that exists. + + The factory is consistent across pypowsybl 1.0.0 .. 1.15.0: it only passes + constructor keywords and provider parameters the installed version accepts + (see the module docstring). Parameters ---------- @@ -57,7 +168,8 @@ def get_pypowsybl_loopfree_parameters( (``read_slack_bus = True``). **overrides Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to override - on the returned object (e.g. ``distributed_slack=True``). + on the returned object (e.g. ``distributed_slack=True``). Only applied if + the installed pypowsybl accepts that keyword. Returns ------- @@ -66,61 +178,77 @@ def get_pypowsybl_loopfree_parameters( Notes ----- - Some ``provider_parameters`` keys are OLF-specific; other load-flow - providers may ignore or warn on them. ``maxOuterLoopIterations`` is set to - ``"1"`` because OLF rejects ``"0"``; with an empty ``outerLoopNames`` the - iteration cap is moot (there is no outer loop to iterate). Verified against - pypowsybl 1.15.0. + ``maxOuterLoopIterations`` is set to ``"1"`` because OLF rejects ``"0"``; + with an empty ``outerLoopNames`` the iteration cap is moot. Verified to + converge on IEEE-14 against every pypowsybl from 1.0.0 to 1.15.0. """ - provider_parameters = { - # ---- empty allow-list: ZERO outer loops, future-proof ---- - "outerLoopNames": "", - # ---- every outer-loop creation trigger forced off (belt and suspenders) ---- - "areaInterchangeControl": "false", - "svcVoltageMonitoring": "false", - "secondaryVoltageControl": "false", - "transformerReactivePowerControl": "false", - "simulateAutomationSystems": "false", - "voltageRemoteControl": "false", - "generatorVoltageRemoteControl": "false", - # ---- control modes pushed to inner-loop / harmless ---- - "transformerVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", - "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", - "phaseShifterControlMode": "CONTINUOUS_WITH_DISCRETISATION", - # ---- NR inner-solver settings forced (the only thing left active) ---- - "maxNewtonRaphsonIterations": "15", - "newtonRaphsonConvEpsPerEq": "1.0E-4", - "stateVectorScalingMode": "NONE", - "lineSearchStateVectorScalingMaxIteration": "10", # inert with NONE; forced anyway - # ---- hard backstop (0 is rejected by OLF; 1 is moot with empty allow-list) ---- - "maxOuterLoopIterations": "1", - "slackDistributionFailureBehavior": "FAIL", - } + kwargs = {} + + def put(name, value): + if name in _PARAM_SIG: + kwargs[name] = value + return True + return False + + put("distributed_slack", False) + put("transformer_voltage_control_on", False) + put("phase_shifter_regulation_on", False) + put("write_slack_bus", True) + put("dc_use_transformer_ratio", True) + put("twt_split_shunt_admittance", True) + + vim = _enum("VoltageInitMode") + if vim is not None: + put("voltage_init_mode", vim.UNIFORM_VALUES) + bt = _enum("BalanceType") + if bt is not None: + # forced; inert with distributed slack off + put("balance_type", bt.PROPORTIONAL_TO_GENERATION_P_MAX) + + # reactive limits: new ``use_reactive_limits`` vs old ``no_generator_reactive_limits`` + if not put("use_reactive_limits", False): + put("no_generator_reactive_limits", True) + # shunt voltage control: new ``shunt_compensator_voltage_control_on`` vs old ``simul_shunt`` + if not put("shunt_compensator_voltage_control_on", False): + put("simul_shunt", False) read_slack_bus = slack_bus_ids is None + put("read_slack_bus", read_slack_bus) + + # connected component: ``component_mode`` (>= 1.15) vs ``connected_component_mode`` + if "component_mode" in _PARAM_SIG: + cm = _enum("ComponentMode") + if cm is not None and hasattr(cm, "MAIN_CONNECTED"): + kwargs["component_mode"] = cm.MAIN_CONNECTED + elif "connected_component_mode" in _PARAM_SIG: + ccm = _enum("ConnectedComponentMode") + if ccm is not None and hasattr(ccm, "MAIN"): + kwargs["connected_component_mode"] = ccm.MAIN + + desired = dict(_DESIRED_PROVIDER) + if not _OUTERLOOP_EMPTY_OK: + desired.pop("outerLoopNames", None) if slack_bus_ids is not None: if not isinstance(slack_bus_ids, str): slack_bus_ids = ",".join(str(s) for s in slack_bus_ids) - provider_parameters["slackBusSelectionMode"] = "NAME" - provider_parameters["slackBusesIds"] = f"{slack_bus_ids}" - - kwargs = dict( - distributed_slack=False, - balance_type=_lf.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, # forced; inert with slack off - use_reactive_limits=False, - phase_shifter_regulation_on=False, - transformer_voltage_control_on=False, - shunt_compensator_voltage_control_on=False, - voltage_init_mode=_lf.VoltageInitMode.UNIFORM_VALUES, - read_slack_bus=read_slack_bus, - write_slack_bus=True, - dc_use_transformer_ratio=True, - twt_split_shunt_admittance=True, - provider_parameters=provider_parameters, - ) - if _HAS_COMPONENT_MODE: - kwargs["component_mode"] = _lf.ComponentMode.MAIN_CONNECTED + desired["slackBusSelectionMode"] = "NAME" + desired["slackBusesIds"] = f"{slack_bus_ids}" + + valid = _olf_provider_params() + if valid is not None: + provider_parameters = {} + for name, value in desired.items(): + if name not in valid: + continue # parameter unknown in this pypowsybl version + possible = valid[name] + if possible is not None and value not in possible: + continue # value not accepted in this version + provider_parameters[name] = value else: - kwargs["connected_component_mode"] = _lf.ConnectedComponentMode.MAIN - kwargs.update(overrides) + provider_parameters = desired + put("provider_parameters", provider_parameters) + + # only forward overrides the installed Parameters constructor accepts + for name, value in overrides.items(): + put(name, value) return _lf.Parameters(**kwargs) From 401e69b8cde2aeb611cfadcd50e1514a9d757149 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 25 Jun 2026 13:46:39 +0200 Subject: [PATCH 006/166] fix thread configuration Signed-off-by: DONNOT Benjamin --- src/core/cmake/lightsim2grid_coreConfig.cmake.in | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/cmake/lightsim2grid_coreConfig.cmake.in b/src/core/cmake/lightsim2grid_coreConfig.cmake.in index 60459013..7bd29011 100644 --- a/src/core/cmake/lightsim2grid_coreConfig.cmake.in +++ b/src/core/cmake/lightsim2grid_coreConfig.cmake.in @@ -1,8 +1,18 @@ @PACKAGE_INIT@ +include(CMakeFindDependencyMacro) + # Include directory — path computed relative to this config file's installed location set_and_check(lightsim2grid_core_INCLUDE_DIR "@PACKAGE_LS2G_INSTALL_INCLUDEDIR@") +# The exported target carries Threads::Threads in its PUBLIC link interface +# (used by the multi-threaded ContingencyAnalysis). A consumer doing +# find_package(lightsim2grid_core) must re-discover that imported target, +# otherwise including the targets file below fails with +# "target ... contains: Threads::Threads but the target was not found". +set(THREADS_PREFER_PTHREAD_FLAG ON) +find_dependency(Threads) + # Import the library target (lightsim2grid::core) if(NOT TARGET lightsim2grid::core) include("${CMAKE_CURRENT_LIST_DIR}/lightsim2grid_coreTargets.cmake") From 1990591fdf0ae611d16731aaf3b28402c4d2c5b4 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 25 Jun 2026 13:47:16 +0200 Subject: [PATCH 007/166] add possibility to 'bake' from an initial powerflow only the main connected component Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_bake.py | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 892e939f..e75ec13c 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -67,12 +67,19 @@ regulating voltage, a shunt, and ratio + phase tap changers. """ -import numpy as np import pandas as pd # Tolerance (MVAr) for deciding a reactive injection sits "at" its Q limit. _Q_LIMIT_TOL = 1e-3 + + +def _keep_only_main_comp(df_el, df_bus): + mask_conn = df_el["connected"] + bus_els = df_bus.loc[df_el.loc[mask_conn, "bus_id"]] + mask_main = (bus_els["synchronous_component"] == 0).values + df_el = df_el.loc[df_el.loc[mask_conn][mask_main].index] + return df_el def _reactive_limits(df: pd.DataFrame): @@ -111,6 +118,7 @@ def bake_outer_loops( bake_active_power: bool = True, balance_on_loads: bool = False, load_power_factor_constant: bool = False, + keep_only_main_comp: bool=True ): """Rewrite ``network`` input setpoints to the converged outer-loop state. @@ -138,6 +146,8 @@ def bake_outer_loops( load_power_factor_constant Mirror OLF's ``loadPowerFactorConstant``: also rewrite load q0 so the power factor is preserved. + keep_only_main_comp + Only elements of the main connected components are updated (True by default) Notes ----- @@ -146,19 +156,21 @@ def bake_outer_loops( it in IIDM, so PV->PQ is detected from the realized Q sitting at a limit. """ if bake_taps: - _bake_taps_and_sections(network) + _bake_taps_and_sections(network, keep_only_main_comp) if bake_reactive_limits: - _bake_reactive_limit_switches(network) + _bake_reactive_limit_switches(network, keep_only_main_comp) if bake_active_power: _bake_active_power( network, balance_on_loads=balance_on_loads, load_power_factor_constant=load_power_factor_constant, + keep_only_main_comp=keep_only_main_comp ) -def _bake_taps_and_sections(network): +def _bake_taps_and_sections(network, keep_only_main_comp=True): # Ratio tap changers (transformer voltage control outer loop). + df_bus = network.get_buses(attributes=["synchronous_component"]) rtc = network.get_ratio_tap_changers( attributes=["tap", "solved_tap_position", "regulating"] ) @@ -171,7 +183,7 @@ def _bake_taps_and_sections(network): if keep.any(): upd = pd.DataFrame(index=rtc.index[keep]) upd["tap"] = rtc["solved_tap_position"][keep].astype(int) - upd["regulating"] = False + # upd["regulating"] = False network.update_ratio_tap_changers(upd) # Phase tap changers (phase-shifter regulation outer loop). @@ -183,13 +195,21 @@ def _bake_taps_and_sections(network): if keep.any(): upd = pd.DataFrame(index=ptc.index[keep]) upd["tap"] = ptc["solved_tap_position"][keep].astype(int) - upd["regulating"] = False + # upd["regulating"] = False network.update_phase_tap_changers(upd) # Shunt compensators (shunt voltage control outer loop). sh = network.get_shunt_compensators( - attributes=["section_count", "solved_section_count", "voltage_regulation_on"] + attributes=[ + "section_count", + "solved_section_count", + "voltage_regulation_on", + "connected", + "bus_id" + ] ) + if keep_only_main_comp: + sh = _keep_only_main_comp(sh, df_bus) if len(sh): keep = sh["voltage_regulation_on"] & sh["solved_section_count"].notna() if keep.any(): @@ -199,26 +219,35 @@ def _bake_taps_and_sections(network): network.update_shunt_compensators(upd) -def _bake_reactive_limit_switches(network): +def _bake_reactive_limit_switches( + network, + keep_only_main_comp=True): + df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ "voltage_regulator_on", "q", "min_q", "max_q", "min_q_at_p", "max_q_at_p", + "connected", "bus_id" ] ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) mask, q_gen = _bound_at_qlimit(gen, gen["voltage_regulator_on"]) if mask.any(): upd = pd.DataFrame(index=gen.index[mask]) upd["target_q"] = q_gen[mask] upd["voltage_regulator_on"] = False network.update_generators(upd) - + vsc = network.get_vsc_converter_stations( attributes=[ "voltage_regulator_on", "q", "min_q", "max_q", "min_q_at_p", "max_q_at_p", + "connected", "bus_id" ] ) + if keep_only_main_comp: + vsc = _keep_only_main_comp(vsc, df_bus) if len(vsc): mask, q_gen = _bound_at_qlimit(vsc, vsc["voltage_regulator_on"]) if mask.any(): @@ -231,22 +260,30 @@ def _bake_reactive_limit_switches(network): # docstring. Hook for susceptance-envelope saturation would go here. -def _bake_active_power(network, balance_on_loads, load_power_factor_constant): - gen = network.get_generators(attributes=["p"]) +def _bake_active_power(network, balance_on_loads, load_power_factor_constant, keep_only_main_comp=True): + df_bus = network.get_buses(attributes=["synchronous_component"]) + + gen = network.get_generators(attributes=["p", "connected", "bus_id"]) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) if len(gen): # result p is load convention; target_p is generator convention network.update_generators( pd.DataFrame({"target_p": -gen["p"]}, index=gen.index) ) - bat = network.get_batteries(attributes=["p"]) + bat = network.get_batteries(attributes=["p", "connected", "bus_id"]) + if keep_only_main_comp: + bat = _keep_only_main_comp(bat, df_bus) if len(bat): network.update_batteries( pd.DataFrame({"target_p": -bat["p"]}, index=bat.index) ) if balance_on_loads: - load = network.get_loads(attributes=["p", "q"]) + load = network.get_loads(attributes=["p", "q", "connected", "bus_id"]) + if keep_only_main_comp: + load = _keep_only_main_comp(load, df_bus) if len(load): upd = pd.DataFrame(index=load.index) upd["p0"] = load["p"] # load convention both sides, no flip From f6a1814d259baa406ceab6628838eed4fe722cf3 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 25 Jun 2026 15:02:10 +0200 Subject: [PATCH 008/166] add support for islanding grid in DC security analysis Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 10 +- docs/security_analysis.rst | 19 +-- lightsim2grid/contingencyAnalysis.py | 9 +- .../tests/test_ContingencyAnalysis_split.py | 80 ++++++++++++- src/bindings/python/binding_batch.cpp | 3 +- .../batch_algorithm/BaseBatchSolverSynch.cpp | 6 +- .../batch_algorithm/ContingencyAnalysis.cpp | 89 +++++++++----- .../batch_algorithm/ContingencyAnalysis.hpp | 9 +- src/core/powerflow_algorithm/BaseDCAlgo.hpp | 17 +++ src/core/powerflow_algorithm/BaseDCAlgo.tpp | 110 +++++++++++++++--- 10 files changed, 283 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2ff2c5ab..3349c560 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -164,8 +164,14 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. unchanged. The reference slack is chosen once, up-front, to minimise the number of contingencies that have to be skipped (those that would strand the chosen reference), and a stranded slack generator has its slack weight zeroed and the remaining weights rescaled. - Requires a Newton-Raphson algorithm (an AC non-NR solver raises a clear error). Tested in - `test_ContingencyAnalysis_split`. + Supported both in AC (Newton-Raphson) and in DC: in DC the masked buses' rows of the reduced + system are forced to the identity (angle 0), the masked injections are dropped and the slack + imbalance is computed on the live component only, again **without re-triggering the symbolic + factorization**. An AC non-NR solver (Gauss-Seidel / Fast-Decoupled) raises a clear error. + Tested in `test_ContingencyAnalysis_split`. +- [IMPROVED] `ContingencyAnalysisCPP.is_grid_connected_after_contingency` now also reports the + splitting contingencies in DC (it used to always claim "connected"), by labelling the connected + components of the real `Bbus`. - [ADDED] (to interpret the new Jacobian layout) the methods `get_theta_to_J_col()`, `get_vm_to_J_col()` and `get_q_to_J_col()` on the Newton-Raphson algorithms (and on the `AlgorithmSelector`). Each returns a vector, **indexed by the solver bus id**, diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index eec6ff69..438367cf 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -86,16 +86,21 @@ When this mode is enabled and a contingency splits the grid: remaining slack weights are rescaled so the slack power is shared only among the live slacks. This is implemented **without re-triggering the symbolic factorization** of the linear solver -(the Jacobian sparsity pattern is left unchanged), so it stays compatible with the speed of the -contingency analysis. To make it possible, the reference slack is chosen once, before the -computation, so as to minimise the number of contingencies that still have to be skipped (those -that would disconnect the chosen reference slack itself). +(the Jacobian, resp. the DC matrix, sparsity pattern is left unchanged), so it stays compatible +with the speed of the contingency analysis. To make it possible, the reference slack is chosen +once, before the computation, so as to minimise the number of contingencies that still have to be +skipped (those that would disconnect the chosen reference slack itself). + +The mode works both in **AC** (with a Newton-Raphson algorithm) and in **DC**: in DC the masked +buses' rows of the reduced system are forced to the identity (so their angle is 0), the masked +injections are dropped and the slack imbalance is computed on the live component only. In both +cases the masked buses are reported as ``0.``. .. note:: - This mode **requires a Newton-Raphson algorithm** (the default). Selecting an AC non - Newton-Raphson algorithm (*eg* Gauss-Seidel or Fast-Decoupled) and enabling the mode raises - an error. In DC the connectivity is already handled internally, so the flag has no effect. + This mode **requires a Newton-Raphson algorithm** (AC, the default) or the **DC** solver. + Selecting an AC *non* Newton-Raphson algorithm (*eg* Gauss-Seidel or Fast-Decoupled) and + enabling the mode raises an error. Running the contingencies on multiple threads --------------------------------------------- diff --git a/lightsim2grid/contingencyAnalysis.py b/lightsim2grid/contingencyAnalysis.py index a7b1fac7..d6fc1ea9 100644 --- a/lightsim2grid/contingencyAnalysis.py +++ b/lightsim2grid/contingencyAnalysis.py @@ -78,10 +78,11 @@ class __ContingencyAnalysis(object): By default, a contingency that splits the grid in multiple connected components is not simulated (its voltages are left at 0.). If you set the `handle_disconnected_grid` attribute - to ``True`` (it requires a Newton-Raphson algorithm), such contingencies are instead simulated - on their largest connected component: the buses of the other component(s) are "masked" and - their voltage is reported as 0. This is done without triggering any extra matrix - re-factorization (the symbolic factorization of the solver is reused). + to ``True``, such contingencies are instead simulated on their largest connected component: + the buses of the other component(s) are "masked" and their voltage is reported as 0. This is + done without triggering any extra matrix re-factorization (the symbolic factorization of the + solver is reused). It is supported by the Newton-Raphson family (AC) and by the DC solver; a + non Newton-Raphson AC algorithm (*eg* Gauss-Seidel or Fast-Decoupled) is rejected. """ STR_TYPES = (str, np.str_) # np.str deprecated in numpy 1.20 and earlier versions not supported anyway diff --git a/lightsim2grid/tests/test_ContingencyAnalysis_split.py b/lightsim2grid/tests/test_ContingencyAnalysis_split.py index e6ed6c77..9106b7c6 100644 --- a/lightsim2grid/tests/test_ContingencyAnalysis_split.py +++ b/lightsim2grid/tests/test_ContingencyAnalysis_split.py @@ -103,14 +103,32 @@ def test_error_on_non_nr_ac_algorithm(self): with self.assertRaises(RuntimeError): SA.compute(1. * self.V0, self.max_it, self.tol) - def test_dc_flag_on_is_a_noop(self): - # DC handles connectivity internally: enabling the flag must not raise and - # must keep the legacy DC behaviour (no exception, computation runs). + def test_dc_flag_off_skips_split(self): + # legacy DC behaviour: a split contingency diverges (no slack reference in the + # stranded island) and is not stored -> all 0. + SA = ContingencyAnalysisCPP(self.grid) + SA.change_algorithm(AlgorithmType.DC_SparseLU) + SA.add_n1(2) + SA.handle_disconnected_grid = False + SA.compute(1. * self.V0, self.max_it, self.tol) + v = SA.get_voltages() + assert np.max(np.abs(v[0])) == 0., f"DC split contingency should be skipped, got {v[0]}" + + def test_dc_flag_on_solves_main_component(self): + # with the flag ON, DC solves the largest component (masking the island). SA = ContingencyAnalysisCPP(self.grid) SA.change_algorithm(AlgorithmType.DC_SparseLU) SA.add_n1(2) SA.handle_disconnected_grid = True - SA.compute(1. * self.V0, self.max_it, self.tol) # must not raise + SA.compute(1. * self.V0, self.max_it, self.tol) + v = SA.get_voltages()[0] + # the surviving component {0,1,2} matches a DC powerflow on the reference grid + Vref_dc = self.ref.dc_pf(np.ones(self.ref.get_bus_vn_kv().shape[0], dtype=complex), + self.max_it, self.tol) + assert np.max(np.abs(np.angle(v[:3]) - np.angle(Vref_dc))) <= 1e-6, \ + f"DC main component mismatch: {np.max(np.abs(np.angle(v[:3]) - np.angle(Vref_dc))):.2e}" + # the isolated bus 3 is masked -> reported as exactly 0 + assert v[3] == 0., f"masked bus should be 0, got {v[3]}" def test_is_grid_connected_no_segfault(self): # regression: is_grid_connected_after_contingency() used to segfault because @@ -126,6 +144,19 @@ def test_is_grid_connected_no_segfault(self): after = np.asarray(SA.is_grid_connected_after_contingency()) assert np.array_equal(standalone, after) + def test_is_grid_connected_dc(self): + # in DC the connectivity is now reported off the real Bbus (it used to always + # claim "connected"); both radial cuts disconnect the grid. + SA = ContingencyAnalysisCPP(self.grid) + SA.change_algorithm(AlgorithmType.DC_SparseLU) + SA.add_n1(2) + SA.add_n1(0) + standalone = np.asarray(SA.is_grid_connected_after_contingency()) + assert np.all(standalone == 0), f"both radial cuts disconnect the grid, got {standalone}" + SA.compute(1. * self.V0, self.max_it, self.tol) + after = np.asarray(SA.is_grid_connected_after_contingency()) + assert np.array_equal(standalone, after) + class TestContingencySplitMultiSlack(unittest.TestCase): """Two slacks at the two ends of a line; a contingency strands one of them. @@ -177,6 +208,21 @@ def test_stranded_slack_is_masked(self): # the stranded island {3, 4} is masked -> reported as 0 assert np.max(np.abs(v[0, 3:5])) == 0., f"masked island should be 0, got {v[0, 3:5]}" + def test_stranded_slack_is_masked_dc(self): + # same in DC: the stranded second slack's weight is zeroed, the live slack 0 + # absorbs the imbalance and the island is reported as 0. + SA = ContingencyAnalysisCPP(self.grid) + SA.change_algorithm(AlgorithmType.DC_SparseLU) + SA.add_n1(2) + SA.handle_disconnected_grid = True + SA.compute(1. * self.V0, self.max_it, self.tol) + v = SA.get_voltages() + Vref_dc = self.ref.dc_pf(np.ones(self.ref.get_bus_vn_kv().shape[0], dtype=complex), + self.max_it, self.tol) + assert np.max(np.abs(np.angle(v[0, :3]) - np.angle(Vref_dc))) <= 1e-6, \ + f"DC main component mismatch: {np.max(np.abs(np.angle(v[0, :3]) - np.angle(Vref_dc))):.2e}" + assert np.max(np.abs(v[0, 3:5])) == 0., f"masked island should be 0, got {v[0, 3:5]}" + class TestContingencySplitCase14(unittest.TestCase): """Integration test on l2rpn_case14_sandbox: enabling the mode must leave the @@ -194,8 +240,10 @@ def setUp(self): def tearDown(self): self.env.close() - def _voltages(self, handle_disconnected): + def _voltages(self, handle_disconnected, algorithm=None): SA = ContingencyAnalysisCPP(self.env.backend._grid) + if algorithm is not None: + SA.change_algorithm(algorithm) SA.add_all_n1() SA.handle_disconnected_grid = handle_disconnected SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) @@ -227,6 +275,28 @@ def test_regression_and_new_contingencies(self): f"cont {i}: unrealistic voltages on the live component [{live.min()}, {live.max()}]" assert np.sum(vm <= 1e-9) >= 1, f"cont {i}: expected at least one masked (0) bus" + def test_regression_and_new_contingencies_dc(self): + # same as above but with the DC solver + voff = self._voltages(False, algorithm=AlgorithmType.DC_SparseLU) + von = self._voltages(True, algorithm=AlgorithmType.DC_SparseLU) + nb_sub = self.nb_sub + + # contingencies solved when the mode is OFF must be bit-identical when ON + off_solved = np.array([np.any(np.abs(voff[i, :nb_sub]) > 1e-9) for i in range(voff.shape[0])]) + assert off_solved.any(), "sanity: some DC contingencies should be solved with the mode off" + assert np.max(np.abs(von[off_solved] - voff[off_solved])) == 0., \ + "connected DC contingencies must be unchanged when the mode is enabled" + + # at least one split contingency (skipped when OFF) is now solved on its + # largest connected component + newly_solved = [i for i in range(voff.shape[0]) + if (not off_solved[i]) and np.any(np.abs(von[i, :nb_sub]) > 1e-9)] + assert len(newly_solved) >= 1, "the DC mode should solve at least one split contingency" + for i in newly_solved: + assert np.all(np.isfinite(von[i, :nb_sub])), f"DC cont {i}: non-finite voltage" + assert np.sum(np.abs(von[i, :nb_sub]) <= 1e-9) >= 1, \ + f"DC cont {i}: expected at least one masked (0) bus" + if __name__ == "__main__": unittest.main() diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index 0a344007..b86362d3 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -71,7 +71,8 @@ void bind_batch(py::module_& m) { "are skipped (their voltages are left at 0), reproducing the legacy " "behaviour. When True, the largest connected component is solved while " "the buses of the other component(s) are masked (their voltage is " - "reported as 0). Requires a Newton-Raphson algorithm.)mydelim") + "reported as 0). Supported by the Newton-Raphson family (AC) and the DC " + "solver; a non Newton-Raphson AC algorithm is rejected.)mydelim") .def_property("nb_thread", [](const ContingencyAnalysis & self){ return self.get_nb_thread(); }, [](ContingencyAnalysis & self, int val){ self.set_nb_thread(val); }, diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index c377dca9..c86e5191 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -67,8 +67,10 @@ bool BaseBatchSolverSynch::compute_one_powerflow( tol); } else { // native real DC entry point: Bbus_ is the (constant) real admittance built in - // prepare_solver_input_base; Pbus is the real part of the (possibly per-step) injection - const RealVect Pbus = Sbus.real(); + // prepare_solver_input_base; Pbus is the real part of the (possibly per-step) + // injection. ContingencyAnalysis leaves Sbus_ empty in DC (it relies on the + // member Pbus_ built once), so fall back to Pbus_ when no complex Sbus is given. + const RealVect Pbus = (Sbus.size() == 0) ? Pbus_ : RealVect(Sbus.real()); conv = algo.compute_pf_dc( Bbus_, V, diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 9d1e7b25..35260a79 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -50,8 +50,9 @@ bool ContingencyAnalysis::check_invertible(const Eigen::SparseMatrix return ok; } -std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatrix & Ybus) const{ - const int n = static_cast(Ybus.cols()); +template +std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatrix & mat) const{ + const int n = static_cast(mat.cols()); std::vector comp_of_bus(n, -1); // connected-component label of each bus int nb_comp = 0; std::queue neighborhood; @@ -63,9 +64,9 @@ std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatr while(!neighborhood.empty()){ const int col_id = neighborhood.front(); neighborhood.pop(); - for (Eigen::SparseMatrix::InnerIterator it(Ybus, col_id); it; ++it){ + for (typename Eigen::SparseMatrix::InnerIterator it(mat, col_id); it; ++it){ const int row = static_cast(it.row()); - if(comp_of_bus[row] == -1 && abs(it.value()) > 1e-8){ + if(comp_of_bus[row] == -1 && std::abs(it.value()) > 1e-8){ comp_of_bus[row] = nb_comp; neighborhood.push(row); } @@ -92,14 +93,19 @@ std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatr return masked; } +// explicit instantiations: AC works on the complex Ybus_, DC on the real Bbus_ +template std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatrix &) const; +template std::vector ContingencyAnalysis::disconnected_buses(const Eigen::SparseMatrix &) const; + void ContingencyAnalysis::select_ref_slack_and_masks(){ const size_t nb_cont = _li_coeffs.size(); _li_masked.assign(nb_cont, std::vector()); _skip_mask.assign(nb_cont, 0); - // 1) per-contingency masked bus set (largest component is kept). - // Work on a copy so the member Ybus_ used by the n-powerflow is left untouched. - { + // 1) per-contingency masked bus set (largest component is kept). Work on a copy of + // the admittance matrix so the member used by the n-powerflow is left untouched. + // AC uses the complex Ybus_; DC uses the real Bbus_ (Ybus_ is empty in DC mode). + if(_algo.ac_solver_used()){ Eigen::SparseMatrix Ybus = Ybus_; size_t cont_id = 0; for(const auto & coeffs_modif: _li_coeffs){ @@ -108,6 +114,15 @@ void ContingencyAnalysis::select_ref_slack_and_masks(){ for(const auto & c: coeffs_modif) Ybus.coeffRef(c.row_id, c.col_id) += c.value; ++cont_id; } + } else { + Eigen::SparseMatrix Bbus = Bbus_; + size_t cont_id = 0; + for(const auto & coeffs_modif: _li_coeffs){ + for(const auto & c: coeffs_modif) Bbus.coeffRef(c.row_id, c.col_id) -= std::real(c.value); + _li_masked[cont_id] = disconnected_buses(Bbus); + for(const auto & c: coeffs_modif) Bbus.coeffRef(c.row_id, c.col_id) += std::real(c.value); + ++cont_id; + } } // 2) candidate reference slacks (treated as solver bus ids, consistent with the @@ -266,31 +281,42 @@ bool ContingencyAnalysis::remove_from_Ybus(Eigen::SparseMatrix & Ybus IntVect ContingencyAnalysis::is_grid_connected_after_contingency(){ const bool ac_solver_used = _algo.ac_solver_used(); - if(!ac_solver_used){ - // in DC mode the solver takes responsibility for the connectivity (see remove_from_Ybus), - // so every contingency is reported as "connected". No need to build / cast a Ybus here. - return IntVect::Constant(_li_defaults.size(), 1); - } - // Build the solver inputs (Ybus_, id_me_to_solver_) and the per-contingency - // coefficients if they are not available yet (i.e. compute() was not called). - // NB: we use the (correctly indexed) member Ybus_, NOT _grid_model.get_Ybus_solver(): - // the latter is the grid model's own Ybus, which is never built by this class (it - // works on Ybus_) and would be an empty 0x0 matrix here -> out-of-bounds coeffRef. - if(Ybus_.cols() == 0 || _li_coeffs.size() != _li_defaults.size()){ + // Build the solver inputs (the AC complex Ybus_ or the DC real Bbus_, plus + // id_me_to_solver_) and the per-contingency coefficients if they are not available + // yet (i.e. compute() was not called). + // NB: we use the (correctly indexed) member Ybus_ / Bbus_, NOT + // _grid_model.get_Ybus_solver(): the latter is the grid model's own Ybus, never built + // by this class (it works on Ybus_ / Bbus_) and would be an empty 0x0 matrix here -> + // out-of-bounds coeffRef. + const bool inputs_ready = (ac_solver_used ? Ybus_.cols() != 0 : Bbus_.cols() != 0); + if(!inputs_ready || _li_coeffs.size() != _li_defaults.size()){ const size_t nb_total_bus = _grid_model.total_bus(); CplxVect Vinit = CplxVect::Constant(static_cast(nb_total_bus), {_grid_model.get_init_vm_pu(), 0.}); prepare_solver_input_base(Vinit, ac_solver_used); init_li_coeffs(ac_solver_used, id_me_to_solver_); } - Eigen::SparseMatrix Ybus = Ybus_; // correctly-indexed copy IntVect res = IntVect::Constant(_li_coeffs.size(), 0); - int cont_id = 0; - for(const auto & coeffs_modif: _li_coeffs){ - if(remove_from_Ybus(Ybus, coeffs_modif, true, _algo)) res(cont_id) = 1; - else res(cont_id) = 0; - readd_to_Ybus(Ybus, coeffs_modif, true, _algo); - ++cont_id; + if(ac_solver_used){ + Eigen::SparseMatrix Ybus = Ybus_; // correctly-indexed copy + int cont_id = 0; + for(const auto & coeffs_modif: _li_coeffs){ + if(remove_from_Ybus(Ybus, coeffs_modif, true, _algo)) res(cont_id) = 1; + else res(cont_id) = 0; + readd_to_Ybus(Ybus, coeffs_modif, true, _algo); + ++cont_id; + } + } else { + // DC: BFS the (real) Bbus_ directly. Unlike the AC `remove_from_Ybus`, this does + // not touch the solver's internal dc matrix, so it is side-effect free. + Eigen::SparseMatrix Bbus = Bbus_; // correctly-indexed copy + int cont_id = 0; + for(const auto & coeffs_modif: _li_coeffs){ + for(const auto & c: coeffs_modif) Bbus.coeffRef(c.row_id, c.col_id) -= std::real(c.value); + res(cont_id) = disconnected_buses(Bbus).empty() ? 1 : 0; + for(const auto & c: coeffs_modif) Bbus.coeffRef(c.row_id, c.col_id) += std::real(c.value); + ++cont_id; + } } return res; } @@ -339,14 +365,17 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ // "handle disconnected grid" mode: pre-compute the masked bus set of each // contingency and choose the reference slack (BEFORE the n-powerflow, so the - // symbolic factorization is built once with that reference and reused). - const bool mask_mode = _handle_disconnected_grid && ac_solver_used; + // symbolic factorization is built once with that reference and reused). Supported + // by the Newton-Raphson family (AC) and the native DC solver; both expose bus + // masking. A non-NR AC algorithm (eg Gauss-Seidel / Fast-Decoupled) is rejected. + const bool mask_mode = _handle_disconnected_grid; if(mask_mode){ if(!_algo.supports_bus_masking()){ throw std::runtime_error("ContingencyAnalysis: the `handle_disconnected_grid` mode " - "requires a Newton-Raphson algorithm (the active algorithm " - "does not support bus masking). Use `change_algorithm` to " - "select an NR solver (e.g. NR_KLU / NR_SLU)."); + "requires a Newton-Raphson algorithm (AC) or the DC solver " + "(the active algorithm does not support bus masking). Use " + "`change_algorithm` to select an NR solver (e.g. NR_KLU / " + "NR_SLU) or the DC solver."); } select_ref_slack_and_masks(); } diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 48850310..9a2ab500 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -192,9 +192,12 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch bool check_invertible(const Eigen::SparseMatrix & Ybus) const; // ----- "handle disconnected grid" mode helpers (mask mode only) ----------- - // connected-component labelling of Ybus: returns the solver bus ids that are - // NOT part of the largest connected component (empty if Ybus is connected). - std::vector disconnected_buses(const Eigen::SparseMatrix & Ybus) const; + // connected-component labelling of the admittance matrix: returns the solver + // bus ids that are NOT part of the largest connected component (empty if it is + // connected). Templated on the scalar type so it works on both the AC (complex + // Ybus_) and the DC (real Bbus_) matrices. Explicitly instantiated in the .cpp. + template + std::vector disconnected_buses(const Eigen::SparseMatrix & mat) const; // pre-pass run before the (n-)powerflow: fills _li_masked (the masked bus set // of each contingency), chooses the reference slack that minimises the number // of skipped contingencies (reordering slack_ids_me_ so it is index 0) and diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index 7ad92947..baa3fbe9 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -110,6 +110,19 @@ class BaseDCAlgo final: public BaseAlgo if(!add) need_refactor_ = true; } + // ----- bus masking ("handle disconnected grid" mode) ----------------------- + // ContingencyAnalysis can ask the DC solver to "mask" the buses of a + // disconnected island: their reduced-system row becomes identity (theta = 0) + // and their injection is dropped, so the largest connected component still + // solves while the masked buses are reported as 0. The masking is applied to + // a working copy of dcYbus_noslack_, so the (incrementally maintained) + // persistent matrix and the symbolic factorization are left untouched (only a + // numeric refactorize is needed). See compute_pf_dc. + virtual bool supports_bus_masking() const final { return true; } + virtual void set_masked_buses(const std::vector & solver_bus_ids) final{ + masked_buses_ = solver_bus_ids; + } + private: // no copy allowed BaseDCAlgo(const BaseDCAlgo&) = delete; @@ -164,6 +177,10 @@ class BaseDCAlgo final: public BaseAlgo // connected angle-droop hvdc lines (solver bus ids, pu), refreshed at // every compute_pf; a change forces a rebuild of dcYbus / dcSbus HvdcDroopSolverData hvdc_droop_data_; + + // solver bus ids (with-slack indexing) masked by the "handle disconnected + // grid" mode (empty by default => no masking). See set_masked_buses. + std::vector masked_buses_; }; #include "BaseDCAlgo.tpp" diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 4f1a1f78..553d532d 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -46,6 +46,12 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix(Bbus.rows()); + // "handle disconnected grid" mode: when masked_buses_ is non-empty the rhs and the + // factorized matrix are built in a masked-aware way (see below). We never touch the + // persistent dcYbus_noslack_ / dcSbus_noslack_ members so the next (possibly + // un-masked) contingency keeps reusing them as today. + const bool has_mask = !masked_buses_.empty(); + // hvdc angle-droop: refresh the droop data; a change (eg a `status_droop` // flip between two solves) modifies the dc matrix values, so it forces a // rebuild + refactorization @@ -97,7 +103,9 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf_dc(const Eigen::SparseMatrix is_masked; // size sizeYbus_with_slack_ when has_mask + Eigen::SparseMatrix masked_mat; + if(has_mask){ + is_masked.assign(sizeYbus_with_slack_, 0); + for(int b : masked_buses_) if(b >= 0 && b < sizeYbus_with_slack_) is_masked[b] = 1; + // which *reduced* rows are masked (the reference slack maps to -1 and is skipped) + std::vector masked_row(sizeYbus_without_slack_, 0); + for(int b : masked_buses_){ + if(b < 0 || b >= sizeYbus_with_slack_) continue; + const int mr = mat_bus_id_(b); + if(mr != -1) masked_row[mr] = 1; + } + masked_mat = dcYbus_noslack_; // copy, leaving the persistent matrix intact + // single in-place pass over the (column-major) copy: every masked row becomes + // the identity row e_mr (off-diagonals -> 0, diagonal -> 1). Only *existing* + // structural entries are written (via valueRef), so the sparsity pattern -- and + // therefore the symbolic factorization -- is left unchanged (refactorize only). + for(int col = 0; col < masked_mat.outerSize(); ++col){ + for(typename Eigen::SparseMatrix::InnerIterator it(masked_mat, col); it; ++it){ + const int row = static_cast(it.row()); + if(!masked_row[row]) continue; + it.valueRef() = (row == col) ? 1. : 0.; + } + } + } + // the system matrix actually handed to the linear solver + Eigen::SparseMatrix & sys_mat = has_mask ? masked_mat : dcYbus_noslack_; + // analyze (structure) + factorize (values) if topology changed + bool factorized_now = false; if(need_factorize_){ // std::cout << "\t\t\tneed to factorize\n"; auto timer_an = CustTimer(); - ErrorType status_init = _linear_solver.analyze(dcYbus_noslack_); + ErrorType status_init = _linear_solver.analyze(sys_mat); const double dur_an = timer_an.duration(); timer_initialize_ += dur_an; if(status_init != ErrorType::NoError){ @@ -140,7 +182,7 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf_dc(const Eigen::SparseMatrix() << std::endl; // TODO DEBUG WINDOWS - // std::cout << "\t\tBaseDCAlgo.tpp: Va_dc_without_slack (l1 norm): " << Va_dc_without_slack.lpNorm<1>() << std::endl; // TODO DEBUG WINDOWS - // std::cout << "\t\tBaseDCAlgo.tpp: V (l1 norm): " << V.lpNorm<1>() << std::endl; // TODO DEBUG WINDOWS - // std::cout << "\t\tBaseDCAlgo.tpp: Sbus (l1 norm): " << Sbus.lpNorm<1>() << std::endl; // TODO DEBUG WINDOWS - if(need_refactor_){ + RealVect Va_dc_without_slack; + if(has_mask){ + // masked-aware rhs: the masked island is not simulated, so its buses inject + // nothing and are excluded from the slack imbalance; their (identity) rows get a + // 0 rhs => theta = 0. Built locally so dcSbus_noslack_ stays valid for un-masked + // contingencies. + Va_dc_without_slack = RealVect::Constant(sizeYbus_without_slack_, my_zero_); + for(int k = 0; k < sizeYbus_with_slack_; ++k){ + if(is_masked[k]) continue; + const int col_res = mat_bus_id_(k); + if(col_res == -1) continue; // reference slack: removed from the system + Va_dc_without_slack(col_res) = Pbus(k); + } + if(slack_weights.size() == sizeYbus_with_slack_){ + real_type imbalance = my_zero_; + for(int k = 0; k < sizeYbus_with_slack_; ++k){ + if(!is_masked[k]) imbalance -= Pbus(k); // -sum(Pbus) over live buses only + } + for(int k = 0; k < sizeYbus_with_slack_; ++k){ + if(is_masked[k] || slack_weights(k) <= my_zero_) continue; + const int col_res = mat_bus_id_(k); + if(col_res == -1) continue; // reference slack: share is implicit + Va_dc_without_slack(col_res) += slack_weights(k) * imbalance; + } + } + // angle-droop hvdc: only lines fully inside the live component are stamped (a + // droop line crossing into a masked island is out of scope for v1, see header). + const int nb_droop_m = hvdc_droop_data_.size(); + for(int kk = 0; kk < nb_droop_m; ++kk){ + if(hvdc_droop_data_.status(kk) != 0) continue; // saturated: fixed injection (in Pbus) + const int b1 = hvdc_droop_data_.bus1(kk); + const int b2 = hvdc_droop_data_.bus2(kk); + if(is_masked[b1] || is_masked[b2]) continue; + const int m1 = mat_bus_id_(b1); + const int m2 = mat_bus_id_(b2); + if(m1 != -1) Va_dc_without_slack(m1) -= hvdc_droop_data_.p0(kk); + if(m2 != -1) Va_dc_without_slack(m2) += hvdc_droop_data_.p0(kk); + } + } else { + Va_dc_without_slack = dcSbus_noslack_; + } + timer_pre_proc_ += timer_pre.duration(); + + // refactorize (numeric only) when Ybus changed (n-1) or in mask mode (the working + // matrix differs from the one currently factorized). Skipped right after a factorize. + if(!factorized_now && (need_refactor_ || has_mask)){ // we should end-up here only in case of n-1 simulation (handled in contingency analysis) // set to true in update_internal_Ybus // std::cout << "\t\t\tneed to refactorize\n"; auto timer_s = CustTimer(); - ErrorType error = _linear_solver.refactorize(dcYbus_noslack_); + ErrorType error = _linear_solver.refactorize(sys_mat); const double dur_refacto = timer_s.duration(); timer_refactor_ += dur_refacto; if(error != ErrorType::NoError){ @@ -363,6 +442,7 @@ void BaseDCAlgo::reset(){ mat_bus_id_ = Eigen::VectorXi(); nonslack_ybus_ids_ = Eigen::VectorXi(); hvdc_droop_data_.clear(); + masked_buses_.clear(); } template From 8aad093349ae2df06cbfbf7ee7a8344e55ca5f9d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 26 Jun 2026 10:03:53 +0200 Subject: [PATCH 009/166] add possibility to have slack and remote voltage control Signed-off-by: DONNOT Benjamin --- lightsim2grid/network/__init__.py | 2 + .../network/from_pypowsybl/__init__.py | 6 +- .../network/from_pypowsybl/_from_pypowsybl.py | 148 +++++++++++++++++- .../network/from_pypowsybl/_olf_bake.py | 58 ++++++- .../network/from_pypowsybl/_olf_params.py | 47 ++++++ lightsim2grid/tests/test_olf_bake.py | 74 ++++++++- .../tests/test_voltage_control_pypowsybl.py | 86 +++++++++- src/core/LSGrid.cpp | 46 +++++- src/core/LSGrid.hpp | 14 +- .../element_container/GeneratorContainer.hpp | 11 ++ .../element_container/TwoSidesContainer.hpp | 24 +-- src/core/powerflow_algorithm/CMakeLists.txt | 1 + src/core/powerflow_algorithm/NRSystem.hpp | 47 ++++-- .../NRSystemMultiSlack.cpp | 37 +++++ 14 files changed, 555 insertions(+), 46 deletions(-) create mode 100644 src/core/powerflow_algorithm/NRSystemMultiSlack.cpp diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index c5b251ad..15f9464f 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -22,10 +22,12 @@ from lightsim2grid.network.from_pypowsybl import init as init_from_pypowsybl # noqa from lightsim2grid.network.from_pypowsybl import bake_outer_loops # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_parameters # noqa + from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_distributed_slack_parameters # noqa from lightsim2grid.network.from_pypowsybl import compare_baked, ComparisonResult # noqa __all__.append("init_from_pypowsybl") __all__.append("bake_outer_loops") __all__.append("get_pypowsybl_loopfree_parameters") + __all__.append("get_pypowsybl_loopfree_distributed_slack_parameters") __all__.append("compare_baked") __all__.append("ComparisonResult") except ImportError: diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index 7f83c6c9..bd30c997 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -9,10 +9,14 @@ __all__ = ["init", "bake_outer_loops", "get_pypowsybl_loopfree_parameters", + "get_pypowsybl_loopfree_distributed_slack_parameters", "compare_baked", "ComparisonResult"] from ._from_pypowsybl import init from ._olf_bake import bake_outer_loops -from ._olf_params import get_pypowsybl_loopfree_parameters +from ._olf_params import ( + get_pypowsybl_loopfree_parameters, + get_pypowsybl_loopfree_distributed_slack_parameters, +) from ._olf_compare import compare_baked, ComparisonResult diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 4030565f..5af42e8d 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -8,6 +8,7 @@ import warnings import copy +from collections import deque import numpy as np import pandas as pd import pypowsybl as pypo @@ -100,6 +101,134 @@ def _aux_regulated_bus_view_ids(net, regulated_ids): return np.array([lookup[rid] for rid in regulated_ids], dtype=object) +def _default_distributed_slack(net, df_gen): + """Build the default distributed slack matching OpenLoadFlow's behaviour. + + Returns an *ordered* ``{generator_name: weight}`` dict (the reference generator + first, so it becomes ``slack_ids[0]`` -- the angle datum) suitable for + ``gen_slack_id``, or ``None`` if no participating generator is found (the caller + then falls back to :meth:`LSGrid.assign_slack_to_most_connected`). + + The participating set and the sharing key mirror OLF's default distributed + slack: + + * participants are the connected generators with ``max_p > 0`` that take part in + active-power control (``participate`` flag of the ``activePowerControl`` + extension); if that extension is absent, every connected producing generator + participates; + * participants are restricted to the *main country* (the country hosting the + most buses) when substation ``country`` metadata is available -- this keeps + the slack out of foreign border-equivalent generators; + * the per-generator weight is the distributed-slack key: the + ``participation_factor`` from the ``activePowerControl`` extension when it is + set, otherwise the generator's ``max_p`` (matching OLF's + ``PROPORTIONAL_TO_GENERATION_P_MAX`` default). + + The reference generator (angle datum) follows OLF's grid-connection logic: + follow each candidate's step-up transformer to the highest-voltage bus it + reaches, pick the bus carrying the most generator active power, then the + generator injecting the most into it. + """ + names = df_gen.index + n = len(names) + if n == 0: + return None + connected = df_gen["connected"].to_numpy(bool) + max_p = df_gen["max_p"].to_numpy(float) + target_p = df_gen["target_p"].to_numpy(float) + gen_bus = df_gen["bus_id"].to_numpy() + + # participation set + sharing key from the activePowerControl extension + try: + apc = net.get_extensions("activePowerControl") + except Exception: + apc = None + if apc is not None and len(apc) and "participate" in apc.columns: + # a generator listed in the extension uses its flag; one absent from it + # participates by default (OLF treats a missing extension as participating) + participate = apc["participate"].reindex(names).fillna(True).to_numpy(bool) + if "participation_factor" in apc.columns: + pfac = apc["participation_factor"].reindex(names).to_numpy(float) + else: + pfac = np.full(n, np.nan) + else: + participate = np.ones(n, bool) # no (or empty) extension -> everything participates + pfac = np.full(n, np.nan) + + # sharing key (PROPORTIONAL_TO_GENERATION_P_MAX): participation_factor when set, + # else max_p. + weight = np.where(np.isfinite(pfac) & (pfac > 0.), pfac, max_p) + + # participants: connected, *started* (positive target P -- OLF does not + # distribute on zero-MW generators), participating, with a usable positive weight. + mask = connected & participate & (target_p > 0.) & np.isfinite(weight) & (weight > 0.) + + # main-component filter: a slack generator must sit in the main component, + # otherwise lightsim2grid's `consider_only_main_component` deactivates its + # (islanded) bus and the solver then trips on a disconnected slack. "Main" is + # the largest synchronous component, which matches the line + transformer + # connectivity lightsim2grid keeps (HVDC excluded). Real grids carry many small + # boundary islands that satisfy the country/participation tests above. + df_bus = net.get_buses(attributes=["synchronous_component", "voltage_level_id"]) + main_sync = df_bus["synchronous_component"].value_counts().idxmax() + gen_sync = df_gen["bus_id"].map(df_bus["synchronous_component"]).to_numpy() + mask = mask & (gen_sync == main_sync) + + # country filter (main country = the one hosting the most buses) + subs = net.get_substations() + vls = net.get_voltage_levels() + if "country" in subs.columns and subs["country"].notna().any(): + vl2sub = vls["substation_id"] + bus_country = df_bus["voltage_level_id"].map(vl2sub).map(subs["country"]) + main_country = bus_country.value_counts().idxmax() + gen_country = df_gen["voltage_level_id"].map(vl2sub).map(subs["country"]).to_numpy() + mask = mask & (gen_country == main_country) + + if not mask.any(): + return None + + # reference (angle datum): follow the step-up transformer(s) to the + # highest-voltage node, take the node with the most generator active power, then + # the generator injecting the most into it. + nomv = net.get_buses()["voltage_level_id"].map(vls["nominal_v"]).to_dict() + tw = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "connected1", "connected2"]) + tc = tw[tw["connected1"] & tw["connected2"]] + adj = {} + for b1, b2 in zip(tc["bus1_id"], tc["bus2_id"]): + adj.setdefault(b1, []).append(b2) + adj.setdefault(b2, []).append(b1) + + def follow_gsu(bus, max_hops=4): + seen = {bus} + q = deque([(bus, 0)]) + best = bus + while q: + b, d = q.popleft() + if nomv.get(b, 0.) > nomv.get(best, 0.): + best = b + if d < max_hops: + for nb in adj.get(b, []): + if nb not in seen: + seen.add(nb) + q.append((nb, d + 1)) + return best + + cand = np.where(mask)[0] + conn = {i: follow_gsu(gen_bus[i]) for i in cand} + cvolt = {i: nomv.get(conn[i], 0.) for i in cand} + vmax = max(cvolt.values()) + node_p = {} + for i in cand: + if cvolt[i] == vmax: + node_p[conn[i]] = node_p.get(conn[i], 0.) + target_p[i] + ref_node = max(node_p, key=node_p.get) + ref_i = max((i for i in cand if cvolt[i] == vmax and conn[i] == ref_node), + key=lambda i: target_p[i]) + + order = [int(ref_i)] + [int(i) for i in cand if i != ref_i] + return {names[i]: float(weight[i]) for i in order} + + def init(net : pypo.network.Network, gen_slack_id: Union[int, str, Iterable[str], Dict[str, float]] = None, slack_bus_id: int = None, @@ -733,13 +862,15 @@ def _aux_hvdc_station_data(df_side): model.deactivate_storage(batt_id) model.set_storage_names(df_batt.index) - # TODO dist slack if gen_slack_id is None and slack_bus_id is None: - # if nothing is given, by default I assign a slack bus to a bus where a lot of lines are connected - # quite central in the grid - bus_id, gen_id = model.assign_slack_to_most_connected() - gen_slack_ids_int = [gen_id] - elif gen_slack_id is not None: + # Default: reproduce OpenLoadFlow's distributed slack, sharing the + # active-power mismatch over the participating generators (see + # _default_distributed_slack). Returns None -- handled by the single + # most-connected slack fallback below -- when no participating generator + # is found. + gen_slack_id = _default_distributed_slack(net, df_gen) + + if gen_slack_id is not None: if slack_bus_id is not None: raise RuntimeError("You provided both gen_slack_id and slack_bus_id " "which is not possible.") @@ -777,7 +908,10 @@ def _aux_hvdc_station_data(df_side): gen_slack_ids_int.append(gen_id) model.add_gen_slackbus(gen_id, 1. / nb_conn) else: - raise RuntimeError("You need to provide at least one slack with `gen_slack_id` or `slack_bus_id`") + # nothing provided and the default distributed slack found no participating + # generator: fall back to a single slack on the most-connected generator bus + bus_id, gen_id = model.assign_slack_to_most_connected() + gen_slack_ids_int = [gen_id] # TODO checks # no 3windings trafo and other exotic stuff diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index e75ec13c..a91314e5 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -116,6 +116,7 @@ def bake_outer_loops( bake_taps: bool = True, bake_reactive_limits: bool = True, bake_active_power: bool = True, + bake_remote_voltage_control: bool = True, balance_on_loads: bool = False, load_power_factor_constant: bool = False, keep_only_main_comp: bool=True @@ -140,6 +141,11 @@ def bake_outer_loops( bake_active_power Write realized active power back into generator/battery target P (and load p0/q0 if ``balance_on_loads``). + bake_remote_voltage_control + Rewrite remote voltage control to local control at the solved terminal + voltage (see :func:`_bake_remote_voltage_control`). Needed so that + remote-regulating generators can sit on a (distributed) slack bus, which + lightsim2grid v1 does not otherwise support. balance_on_loads Set if the slack was distributed on loads (BalanceType PROPORTIONAL_TO_LOAD / CONFORM_LOAD). @@ -147,7 +153,7 @@ def bake_outer_loops( Mirror OLF's ``loadPowerFactorConstant``: also rewrite load q0 so the power factor is preserved. keep_only_main_comp - Only elements of the main connected components are updated (True by default) + Only elements of the main connected component are updated (True by default) Notes ----- @@ -166,6 +172,8 @@ def bake_outer_loops( load_power_factor_constant=load_power_factor_constant, keep_only_main_comp=keep_only_main_comp ) + if bake_remote_voltage_control: + _bake_remote_voltage_control(network, keep_only_main_comp) def _bake_taps_and_sections(network, keep_only_main_comp=True): @@ -260,6 +268,54 @@ def _bake_reactive_limit_switches( # docstring. Hook for susceptance-envelope saturation would go here. +def _bake_remote_voltage_control(network, keep_only_main_comp=True): + """Rewrite *remote* voltage control into *local* control at the solved terminal. + + A generator regulating a bus other than its own terminal (``regulated_element_id`` + resolving to a different bus -- e.g. holding the 400 kV grid-connection point + across its step-up transformer) is rewritten to regulate its OWN terminal at that + terminal's solved magnitude, and the remote regulation is cleared so the converter + treats it as local control. + + At the converged operating point this is exact: the generator's own terminal + already sits at ``v_mag`` with the realized reactive output, so fixing it locally + reproduces the same fixed point (same V everywhere, same Q; the formerly-regulated + remote bus still lands on its solved value). A subsequent loop-free solve -- OLF + (whose loop-free parameters disable remote control anyway) or lightsim2grid -- + reproduces the baked state. + + Why it exists: lightsim2grid v1 cannot host a remote voltage controller on a slack + bus, and the default distributed slack puts many remote-regulating generators on + slack buses. Making every controller local sidesteps that. + + .. warning:: + This is a *base-case* faithful approximation. Under a topology change the + generator then holds its own terminal magnitude instead of the remote bus, so + the post-contingency reactive behaviour differs from true remote control. + + Operates in place; idempotent (an already-local generator is left untouched). + """ + df_bus = network.get_buses(attributes=["v_mag", "synchronous_component"]) + gen = network.get_generators( + attributes=["voltage_regulator_on", "regulated_element_id", "connected", "bus_id"] + ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) + if not len(gen): + return + # "remote" matches the converter's own test (see _from_pypowsybl.init): a non-empty + # regulated element that is not the generator's own id. + reg = gen["regulated_element_id"].fillna("") + remote = gen["voltage_regulator_on"] & gen["connected"] & (reg != "") & (reg != gen.index) + gen = gen[remote] + if not len(gen): + return + upd = pd.DataFrame(index=gen.index) + upd["target_v"] = df_bus.loc[gen["bus_id"].values, "v_mag"].values # own terminal, kV + upd["regulated_element_id"] = gen.index # regulate own terminal -> local control + network.update_generators(upd) + + def _bake_active_power(network, balance_on_loads, load_power_factor_constant, keep_only_main_comp=True): df_bus = network.get_buses(attributes=["synchronous_component"]) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 0b8f8099..3b10a108 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -252,3 +252,50 @@ def put(name, value): for name, value in overrides.items(): put(name, value) return _lf.Parameters(**kwargs) + + +def get_pypowsybl_loopfree_distributed_slack_parameters( + slack_bus_ids: Optional[Union[str, Iterable[str]]] = None, + max_outer_loop_iterations: int = 20, + **overrides, +) -> _lf.Parameters: + """Loop-free OLF parameters EXCEPT the active-power slack distribution. + + Identical to :func:`get_pypowsybl_loopfree_parameters` but with OLF's + *distributed slack* enabled: the active-power mismatch is shared over the + participating generators proportionally to their maximum active power + (``PROPORTIONAL_TO_GENERATION_P_MAX`` -- already the loop-free balance type), + which is what lightsim2grid's default distributed slack reproduces. Every + *other* outer loop stays disabled, so OLF (loop-free + distributed slack) and + lightsim2grid agree on a baked grid. + + Implementation note: in OpenLoadFlow the slack distribution is itself an AC + outer loop (``"DistributedSlack"``), so the empty ``outerLoopNames`` allow-list + used by the loop-free factory would suppress it. Here that allow-list is set to + exactly that one loop, and the outer-loop iteration cap (pinned to ``1`` by the + loop-free factory, where it is moot) is restored to ``max_outer_loop_iterations`` + so the distribution can iterate to convergence. On OpenLoadFlow older than + 1.4.0 the empty allow-list is not used, and ``distributed_slack=True`` alone + enables the distribution. + + Parameters + ---------- + slack_bus_ids : str or iterable of str, optional + Forwarded to :func:`get_pypowsybl_loopfree_parameters`. + max_outer_loop_iterations : int + Outer-loop iteration cap for the distribution (default 20, the upstream + OLF default). + **overrides + Forwarded to :func:`get_pypowsybl_loopfree_parameters`. + """ + overrides.setdefault("distributed_slack", True) + params = get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_bus_ids, **overrides) + prov = dict(params.provider_parameters) + # Allow ONLY the distributed-slack outer loop (kept out by the empty allow-list + # otherwise); restore a usable outer-loop budget so it can converge. + if "outerLoopNames" in prov: + prov["outerLoopNames"] = "DistributedSlack" + if "maxOuterLoopIterations" in prov: + prov["maxOuterLoopIterations"] = str(int(max_outer_loop_iterations)) + params.provider_parameters = prov + return params diff --git a/lightsim2grid/tests/test_olf_bake.py b/lightsim2grid/tests/test_olf_bake.py index 96e31b1e..c84c2bfc 100644 --- a/lightsim2grid/tests/test_olf_bake.py +++ b/lightsim2grid/tests/test_olf_bake.py @@ -49,16 +49,78 @@ TOL_VM_KV = 1e-2 # for the pure-OLF kV-space check +# Default OLF provider parameters of pypowsybl 1.15.0 in the project's reference +# venv (``venv_ls``). They are pinned EXPLICITLY here, never read from the +# installed build's defaults: some pypowsybl builds ship the same version string +# but different defaults (e.g. ``extrapolateReactiveLimits``, +# ``stateVectorScalingMode``, ``maxRealisticVoltage``), which would otherwise make +# the with-loops solve -- and hence the "baked grid is inert" check -- behave +# differently from one environment to the next. Pinning every field makes these +# tests reproducible across builds. +_REF_PROVIDER_PARAMS = { + 'maxVoltageMismatch': '1.0E-4', 'generatorVoltageControlMinNominalVoltage': '-1.0', + 'startWithFrozenACEmulation': 'false', 'networkCacheEnabled': 'false', + 'reactiveRangeCheckMode': 'MAX', 'maxVoltageChangeStateVectorScalingMaxDphi': '0.17453292519943295', + 'maxRatioMismatch': '1.0E-5', 'areaInterchangeControl': 'false', 'loadPowerFactorConstant': 'false', + 'acSolverType': 'NEWTON_RAPHSON', 'actionableTransformersIds': '', + 'maxVoltageChangeStateVectorScalingMaxDv': '0.1', 'incrementalShuntControlOuterLoopMaxSectionShift': '3', + 'maxSusceptanceMismatch': '1.0E-4', 'maxNewtonKrylovIterations': '100', + 'reactivePowerDispatchMode': 'Q_EQUAL_PROPORTION', 'phaseShifterControlMode': 'CONTINUOUS_WITH_DISCRETISATION', + 'extrapolateReactiveLimits': 'false', 'asymmetrical': 'false', 'slackBusPMaxMismatch': '1.0', + 'maxActivePowerMismatch': '0.01', 'disableVoltageControlOfGeneratorsOutsideActivePowerLimits': 'false', + 'maxSlackBusCount': '1', 'mostMeshedSlackBusSelectorMaxNominalVoltagePercentile': '95.0', + 'maxNewtonRaphsonIterations': '15', 'minPlausibleTargetVoltage': '0.8', 'secondaryVoltageControl': 'false', + 'useActiveLimits': 'true', 'stateVectorScalingMode': 'NONE', 'useLoadModel': 'false', + 'voltageRemoteControlRobustMode': 'true', 'maxOuterLoopIterations': '20', + 'generatorReactivePowerRemoteControl': 'false', 'newtonRaphsonConvEpsPerEq': '1.0E-4', + 'lowImpedanceBranchMode': 'REPLACE_BY_ZERO_IMPEDANCE_LINE', 'actionableSwitchesIds': '', + 'maxRealisticVoltage': '2.0', 'fictitiousGeneratorVoltageControlCheckMode': 'FORCED', + 'maxReactivePowerMismatch': '0.01', 'transformerReactivePowerControl': 'false', 'minRealisticVoltage': '0.5', + 'fixVoltageTargets': 'false', 'acDcNetwork': 'false', 'simulateAutomationSystems': 'false', + 'forceTargetQInReactiveLimits': 'false', 'plausibleActivePowerLimit': '10000.0', + 'voltagePerReactivePowerControl': 'false', 'dcApproximationType': 'IGNORE_R', 'linePerUnitMode': 'IMPEDANCE', + 'referenceBusSelectionMode': 'FIRST_SLACK', 'newtonRaphsonStoppingCriteriaType': 'UNIFORM_CRITERIA', + 'disableInconsistentVoltageControls': 'false', 'generatorsWithZeroMwTargetAreNotStarted': 'true', + 'svcVoltageMonitoring': 'true', 'alwaysUpdateNetwork': 'false', 'slackBusSelectionMode': 'MOST_MESHED', + 'voltageInitModeOverride': 'NONE', 'slackBusCountryFilter': '', 'minNominalVoltageTargetVoltageCheck': '20.0', + 'areaInterchangePMaxMismatch': '2.0', 'maxPlausibleTargetVoltage': '1.2', 'lowImpedanceThreshold': '1.0E-8', + 'transformerVoltageControlUseInitialTapPosition': 'false', 'newtonKrylovLineSearch': 'false', + 'maxAngleMismatch': '1.0E-5', 'transformerVoltageControlMode': 'INCREMENTAL_VOLTAGE_CONTROL', + 'voltageRemoteControl': 'true', 'slackBusesIds': '', 'reportedFeatures': '', + 'areaInterchangeControlAreaType': 'ControlArea', 'shuntVoltageControlMode': 'WITH_GENERATOR_VOLTAGE_CONTROL', + 'lineSearchStateVectorScalingStepFold': '1.3333333333333333', 'reactiveLimitsMaxPqPvSwitch': '3', + 'incrementalTransformerRatioTapControlOuterLoopMaxTapShift': '3', 'lineSearchStateVectorScalingMaxIteration': '10', + 'slackDistributionFailureBehavior': 'FAIL', 'minNominalVoltageRealisticVoltageCheck': '0.0', + 'writeReferenceTerminals': 'true', 'voltageTargetPriorities': 'VOLTAGE_SOURCE_CONVERTER,GENERATOR,TRANSFORMER,SHUNT', +} + + def _with_loops_params(): - # ``twt_split_shunt_admittance=True`` matches the transformer model the - # loop-free / lightsim2grid solve uses (see get_pypowsybl_loopfree_parameters); - # keeping it consistent here isolates the outer-loop effect, which is what + # Every field is pinned to the ``venv_ls`` (pypowsybl 1.15.0) default so the + # outer-loop solve is identical across pypowsybl builds (see + # ``_REF_PROVIDER_PARAMS``). The single intentional deviation from that default + # is ``twt_split_shunt_admittance=True`` (default is False): it matches the + # transformer model the loop-free / lightsim2grid solve uses (see + # get_pypowsybl_loopfree_parameters), which isolates the outer-loop effect that # baking neutralizes. return lf.Parameters( - distributed_slack=True, + voltage_init_mode=lf.VoltageInitMode.UNIFORM_VALUES, + transformer_voltage_control_on=False, use_reactive_limits=True, - balance_type=pp.loadflow.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, - twt_split_shunt_admittance=True, + phase_shifter_regulation_on=False, + twt_split_shunt_admittance=True, # intentional deviation; see above + shunt_compensator_voltage_control_on=False, + read_slack_bus=True, + write_slack_bus=True, + distributed_slack=True, + balance_type=lf.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, + dc_use_transformer_ratio=True, + countries_to_balance=[], + component_mode=lf.ComponentMode.MAIN_CONNECTED, + dc_power_factor=1.0, + hvdc_ac_emulation=True, + dc=False, + provider_parameters=dict(_REF_PROVIDER_PARAMS), ) diff --git a/lightsim2grid/tests/test_voltage_control_pypowsybl.py b/lightsim2grid/tests/test_voltage_control_pypowsybl.py index 70676a40..9252a4c8 100644 --- a/lightsim2grid/tests/test_voltage_control_pypowsybl.py +++ b/lightsim2grid/tests/test_voltage_control_pypowsybl.py @@ -22,6 +22,7 @@ try: import pypowsybl as pp from lightsim2grid.network import init_from_pypowsybl + from lightsim2grid.network.from_pypowsybl import bake_outer_loops HAS_PYPOWSYBL = True except ImportError: HAS_PYPOWSYBL = False @@ -102,6 +103,29 @@ def _svc_net(mode=VOLTAGE, target_v=398.0, target_q=0.0, slope=0.0, remote=False return n +def _dist_slack_pq_net(): + """3 radial buses for a distributed slack with a PQ participant: G0@B0 is a + voltage-regulating gen, G1@B1 is a NON-regulating gen (voltage_regulator_on= + False, e.g. baked to PQ at its Q-limit). Both join the distributed slack, so + G1's bus is a slack bus that is NOT pinned by a local PV gen: it must keep a + free Vm unknown + Q equation, otherwise its magnitude is frozen at the init + value. The load exceeds generation so the slack actually distributes.""" + n = pp.network.create_empty() + n.create_substations(id=["S0", "S1", "S2"]) + n.create_voltage_levels(id=["VL0", "VL1", "VL2"], substation_id=["S0", "S1", "S2"], + topology_kind=["BUS_BREAKER"] * 3, nominal_v=[400.0] * 3) + n.create_buses(id=["B0", "B1", "B2"], voltage_level_id=["VL0", "VL1", "VL2"]) + n.create_lines(id=["L02", "L12"], voltage_level1_id=["VL0", "VL1"], voltage_level2_id=["VL2", "VL2"], + bus1_id=["B0", "B1"], bus2_id=["B2", "B2"], + r=[1.0, 1.0], x=[20.0, 20.0], g1=[0.0, 0.0], b1=[0.0, 0.0], g2=[0.0, 0.0], b2=[0.0, 0.0]) + n.create_loads(id="LD", voltage_level_id="VL2", bus_id="B2", p0=180.0, q0=60.0) + n.create_generators(id="G0", voltage_level_id="VL0", bus_id="B0", target_p=60.0, + target_q=0.0, target_v=400.0, voltage_regulator_on=True, max_p=1000.0, min_p=0.0) + n.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", target_p=60.0, + target_q=10.0, target_v=400.0, voltage_regulator_on=False, max_p=1000.0, min_p=0.0) + return n + + @unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") class TestVoltageControlPypowsybl(unittest.TestCase): def setUp(self): @@ -160,13 +184,63 @@ def test_remote_gen_two_share(self): q = {g.name: g.res_q_mvar for g in model.get_generators()} self.assertAlmostEqual(q["G1"] / q["G2"], 100.0 / 300.0, places=4) - def test_remote_gen_on_pv_bus_raises(self): - # a controller whose OWN bus is the slack has no Q equation -> v1 rejection + def test_remote_gen_on_slack(self): + # A remote controller whose OWN bus is the slack. The active-power slack + # role and the reactive/voltage role are decoupled: the slack bus is given + # a free Vm unknown + a Q equation (MultiSlack) so the remote control + # attaches, while its angle stays the reference. (This used to be rejected, + # cf the former test_remote_gen_on_pv_bus_raises.) Matches OLF when OLF's + # slack is pinned to the same voltage level. n = _star(g2_reg=None, g1_reg="LD", g1_tv=405.0) - with self.assertRaises(Exception): - # make G1 (the remote controller) the slack -> its bus loses its Q row - init_from_pypowsybl(n, gen_slack_id="G1", sort_index=True).ac_pf( - np.ones(3, dtype=np.complex128), 30, 1e-10) + self._compare(n, "VL1", "G1", "remote-gen-on-slack") + + def test_remote_gen_on_slack_baked(self): + # The same controller-on-slack case that test_remote_gen_on_pv_bus_raises + # rejects: bake_outer_loops rewrites G1's remote control to local control at + # its solved terminal voltage, so it loads and reproduces the (remote-control) + # OLF solution. OLF's slack is pinned to G1's voltage level so the angle + # references coincide; baking does not re-solve, so the network still carries + # the with-loops solution that _assert_bus_parity compares against. + n = _star(g2_reg=None, g1_reg="LD", g1_tv=405.0) + n.per_unit = False + ref = pp.loadflow.run_ac(n, parameters=self._params("VL1")) + self.assertEqual(ref[0].status, pp.loadflow.ComponentStatus.CONVERGED) + bake_outer_loops(n) + # G1 is now a LOCAL controller of its own terminal + self.assertEqual(n.get_generators().loc["G1", "regulated_element_id"], "G1") + _, V = self._run_ls(n, "G1") # used to raise; now supported via baking + self._assert_bus_parity(n, V, "remote-gen-on-slack-baked") + + def test_dist_slack_pq_participant(self): + # Regression: a distributed-slack participant whose generator is PQ + # (voltage_regulator_on=False) gets the active-power slack role on its P + # row, but its magnitude must still be solved through a Q equation -- it is + # NOT pinned by a local PV gen. Before MultiSlack handled this, the bus was + # Vm-fixed at the init value (1.0), which on a real grid froze the magnitude + # of every non-regulating distributed-slack gen. Compare magnitudes to OLF's + # distributed slack (proportional to Pmax), with G0's voltage level as the + # angle reference. + n = _dist_slack_pq_net() + n.per_unit = False + params = pp.loadflow.Parameters( + distributed_slack=True, use_reactive_limits=False, + balance_type=pp.loadflow.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, + provider_parameters={"slackBusSelectionMode": "NAME", "slackBusesIds": "VL0"}) + ref = pp.loadflow.run_ac(n, parameters=params) + self.assertEqual(ref[0].status, pp.loadflow.ComponentStatus.CONVERGED) + self.assertGreater(abs(ref[0].distributed_active_power), 1.0) # slack really distributes + + model, V = self._run_ls(n, {"G0": 1.0, "G1": 1.0}) + # magnitude parity with OLF (the frozen bug showed up on |V|; the angle + # datum of a distributed slack is reference-dependent, so check |V| only) + self._assert_bus_parity(n, V, "dist-slack-pq", check_angle=False) + # G1's bus (VL1) must be solved away from the frozen init value, and G1 + # keeps its fixed reactive setpoint (PQ behaviour) + vl1 = list(n.get_buses().index).index( + n.get_buses().index[n.get_buses()["voltage_level_id"] == "VL1"][0]) + self.assertGreater(abs(abs(V[vl1]) - 1.0), 1e-3, "VL1 magnitude was frozen at init") + q = {g.name: g.res_q_mvar for g in model.get_generators()} + self.assertAlmostEqual(q["G1"], 10.0, places=4) # ----- SVC -------------------------------------------------------------- def test_svc_local_voltage(self): diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 434dd440..0aab3d34 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -442,6 +442,15 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b const int b = bus_pq_(k).cast_int(); if(b >= 0 && b < nb_bus_solver) is_pq[b] = true; } + // Slack buses are not PQ in the base block, but a slack bus that is not pinned + // by a local PV generator is given a Q equation + free Vm by the MultiSlack + // extension (see LSGrid::get_free_vm_slack_solver_buses), so a controller on + // such a slack bus is supported even though `is_pq` is false there. + std::vector is_slack(nb_bus_solver, false); + for(int k = 0; k < static_cast(slack_bus_id_ac_solver_.size()); ++k){ + const int b = slack_bus_id_ac_solver_(k).cast_int(); + if(b >= 0 && b < nb_bus_solver) is_slack[b] = true; + } // 1. collect the active voltage-mode controllers (remote-regulating gens for // now; voltage-mode SVCs are added with the SvcContainer). Per controller: @@ -469,11 +478,11 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b << " regulates a disconnected bus."; throw std::runtime_error(exc_.str()); } - if(!is_pq[ctrl_solver]){ + if(!is_pq[ctrl_solver] && !is_slack[ctrl_solver]){ std::ostringstream exc_; exc_ << "LSGrid::fill_voltage_control_solver_data: generator " << gen_id << " regulates a remote bus but its OWN bus has no reactive (Q) equation" - " (it is a slack or PV bus). This is not supported in v1."; + " (it is a PV bus that is not a slack). This is not supported in v1."; throw std::runtime_error(exc_.str()); } if(!is_pq[reg_solver]){ @@ -603,6 +612,39 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b } } +std::set LSGrid::get_free_vm_slack_solver_buses() const +{ + std::set res; + // solver-bus ids of the slack buses + std::set slack; + for(int k = 0; k < static_cast(slack_bus_id_ac_solver_.size()); ++k){ + slack.insert(slack_bus_id_ac_solver_(k).cast_int()); + } + if(slack.empty()) return res; + + // A slack bus is Vm-fixed (PV-like, no Q equation) only when a LOCAL + // voltage-regulating generator pins its magnitude. Collect those buses. + std::set locally_vfixed; + const SolverBusIdVect & id_me_to_solver = id_me_to_ac_solver_; + const int nb_gen = static_cast(generators_.nb()); + const GlobalBusIdVect & gen_buses = generators_.get_buses(); + for(int gen_id = 0; gen_id < nb_gen; ++gen_id){ + if(!generators_.gen_is_local_voltage_controller(gen_id)) continue; + const int ctrl_grid = gen_buses(gen_id).cast_int(); + const int ctrl_solver = id_me_to_solver[ctrl_grid].cast_int(); + if(ctrl_solver == GenericContainer::_deactivated_bus_id) continue; + locally_vfixed.insert(ctrl_solver); + } + + // Every slack bus whose magnitude is NOT pinned locally needs a free Vm + // unknown + Q equation: distributed-slack PQ participants (the common case), + // remote-voltage controllers, and SVC-regulated slack buses all fall here. + for(int b : slack){ + if(!locally_vfixed.count(b)) res.insert(b); + } + return res; +} + void LSGrid::check_solution_q_values_onegen(CplxVect & res, int bus_id, real_type min_q_mvar, diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 38dec979..ff042504 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -11,7 +11,7 @@ #include #include -// #include +#include #include #include // for int32 #include @@ -872,6 +872,18 @@ class LS2G_API LSGrid final * probe #3). Only valid once `pre_process_solver` ran. */ void fill_voltage_control_solver_data(VoltageControlSolverData & data, bool ac) const; + /** + * Solver-bus ids of the slack buses that need a free Vm unknown and a Q + * equation (added by the MultiSlack NR extension), i.e. every slack bus + * whose magnitude is NOT pinned by a local voltage-regulating generator. + * This covers distributed-slack participants whose generator is PQ + * (voltage_regulator_on == false), slack buses hosting a remote-voltage + * controller, and SVC-regulated slack buses. A slack bus that DOES host a + * local PV generator stays Vm-fixed (PV-like) with no Q equation. AC + * labelling. Only valid once `pre_process_solver` ran (it needs + * `id_me_to_ac_solver_` / `slack_bus_id_ac_solver_`). + */ + std::set get_free_vm_slack_solver_buses() const; /** * Set the grid bus whose voltage generator `gen_id` regulates (remote * voltage control). `bus_id` == the generator's own bus restores ordinary diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index a4e88a05..85aa63cc 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -226,6 +226,17 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter if((!turnedoff_gen_pv_) && is_pseudo_off(gen_id)) return false; return true; } + // true iff this generator pins the magnitude of its OWN bus (the PV path): + // exactly the gating used by fillpv. A bus with such a generator is + // Vm-fixed; a bus without one is PQ-for-voltage (free Vm + Q equation), + // even when it also carries the active-power slack role. + bool gen_is_local_voltage_controller(int gen_id) const { + if(!status_[gen_id]) return false; + if(!voltage_regulator_on_[gen_id]) return false; + if(regulates_remote(gen_id)) return false; + if((!turnedoff_gen_pv_) && is_pseudo_off(gen_id)) return false; + return true; + } real_type get_target_vm_pu(int gen_id) const {return target_vm_pu_(gen_id);} real_type get_min_q(int gen_id) const {return min_q_.coeff(gen_id);} real_type get_max_q(int gen_id) const {return max_q_.coeff(gen_id);} diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index 29c4bee6..c695115d 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -177,20 +177,22 @@ class TwoSidesContainer : public GenericContainer side_2_.deactivate(i, unused_solver_control); continue; } - GlobalBusId bus_side_1 = bus_side_1_id_(i); - GlobalBusId bus_side_2 = bus_side_2_id_(i); - if(!busbar_in_main_component[bus_side_1.cast_int()]) - { + // A side is "outside the main component" only if it is CONNECTED + // (its bus is a real bus, not the deactivated/open marker) AND that + // bus is not flagged in the main component. An open side (bus == + // _deactivated_bus_id, e.g. a half-open line) imposes no constraint: + // such a branch stays as long as its connected side(s) are in main. + const int b1 = bus_side_1_id_(i).cast_int(); + const int b2 = bus_side_2_id_(i).cast_int(); + const bool s1_outside = (b1 != _deactivated_bus_id) && !busbar_in_main_component[b1]; + const bool s2_outside = (b2 != _deactivated_bus_id) && !busbar_in_main_component[b2]; + if(s1_outside || s2_outside){ + // island, boundary, or (defensively) a branch straddling two + // components: drop the whole element rather than throw. Keeping + // the main component well-posed is the goal of this function. side_1_.deactivate(i, unused_solver_control); side_2_.deactivate(i, unused_solver_control); if(!ignore_status_global_) status_global_[i] = false; - if(busbar_in_main_component[bus_side_2.cast_int()]){ - // a powerline is connected, both its ends should be on the same connected component - throw std::runtime_error("A connected line has an end connected to a given connected component, and another one in another. This should not happen."); - } - } - if(!busbar_in_main_component[bus_side_2.cast_int()] && busbar_in_main_component[bus_side_1.cast_int()]){ - throw std::runtime_error("A connected line has an end connected to a given connected component, and another one in another. This should not happen."); } } } diff --git a/src/core/powerflow_algorithm/CMakeLists.txt b/src/core/powerflow_algorithm/CMakeLists.txt index 78ab4a40..8fc2d564 100644 --- a/src/core/powerflow_algorithm/CMakeLists.txt +++ b/src/core/powerflow_algorithm/CMakeLists.txt @@ -4,5 +4,6 @@ list(APPEND SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/BaseAlgo.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemHvdc.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemVoltageControl.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemMultiSlack.cpp" ) set(SOURCES ${SOURCES} PARENT_SCOPE) diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 76016047..a182f93e 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include // Public API (used by NRAlgo, in order) @@ -258,20 +259,18 @@ class LS2G_API MultiSlack // distributed-slack extension slack_col_(-1), slack_absorbed_(static_cast(0.)) {} - // call at the beginning of each NR solve - // once. It is not called for - // NR iterations + // call at the beginning of each NR solve once. It is not called for NR + // iterations. Defined out-of-line (NRSystemMultiSlack.cpp) because it needs + // the full LSGrid type (NRSystem.hpp only forward-declares it): besides + // caching slack_weights / slack_absorbed it pulls the set of slack buses + // that need a free Vm unknown + Q equation, used by register_in. void update_state( - const Base * /*nr_system_base_ptr*/, - const LSGrid * /*lsgrid_ptr*/, - const Eigen::SparseMatrix& /*Ybus*/, + const Base * nr_system_base_ptr, + const LSGrid * lsgrid_ptr, + const Eigen::SparseMatrix& Ybus, const CplxVect& Sbus, const RealVect& slack_weights - ){ - slack_weights_ = slack_weights; - // initial slack absorbed (see MultiSlackPolicy::initial_slack_absorbed) - slack_absorbed_ = std::real(Sbus.sum()); - } + ); // call after update_state // at the beginning of each solve @@ -300,6 +299,27 @@ class LS2G_API MultiSlack // distributed-slack extension slack_p_rows_.push_back(ledger.add_p_equation(slack_buses_[k])); } slack_p_rows_.push_back(ledger.add_p_equation(ref_slack_id_)); + // A slack bus whose magnitude is NOT pinned by a local PV generator must + // keep a free Vm unknown and a Q equation. This is the common case for a + // *distributed* slack: a participant whose generator is PQ + // (voltage_regulator_on == false) carries the active-power slack role on + // its P row, but its magnitude is still an unknown solved through its Q + // equation -- exactly like an ordinary PQ bus. It also covers slack buses + // hosting a remote-voltage controller or an SVC (the VoltageControl + // extension looks up q_row(bus) / vm_col(reg_bus) and attaches here). + // Without this the bus would be Vm-fixed (PV-like) at its init value, + // which is wrong unless a local PV generator actually holds it. + // The reference slack keeps its angle fixed (no theta unknown), so adding + // Vm + Q turns it into a "theta-fixed PQ" bus (fixed angle, free Vm). The + // new rows/cols are filled by the generic dS pass and the ledger-driven + // residual loop of NRSystem; nothing extra is needed here. (+1 Vm col and + // +1 Q row per such bus keep the Jacobian square.) + for (int k = 0; k < my_size_; ++k) { + if (free_vm_slack_buses_.count(slack_buses_[k])) { + ledger.add_vm_unknown(slack_buses_[k]); + ledger.add_q_equation(slack_buses_[k]); + } + } slack_col_ = ledger.add_custom_col(); // slack_absorbed unknown } @@ -348,6 +368,7 @@ class LS2G_API MultiSlack // distributed-slack extension feature_handles_.clear(); slack_weights_ = RealVect(); slack_absorbed_ = static_cast(0.); + free_vm_slack_buses_.clear(); } private: @@ -358,6 +379,10 @@ class LS2G_API MultiSlack // distributed-slack extension std::vector slack_p_rows_; // P row of each slack bus (same order) std::vector feature_handles_; // FeatureSink handles (same order) RealVect slack_weights_; // size: nb_bus + // solver-bus ids of slack buses NOT pinned by a local PV generator (PQ + // distributed-slack participants, remote-voltage / SVC controllers); those + // get an extra Vm unknown + Q equation in register_in (set by update_state) + std::set free_vm_slack_buses_; real_type slack_absorbed_; }; diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp new file mode 100644 index 00000000..a036a0bc --- /dev/null +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -0,0 +1,37 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#include "NRSystem.hpp" + +// out-of-line on purpose: NRSystem.hpp only forward-declares LSGrid (it is +// included BY LSGrid.hpp), the full type is needed to query the slack buses that +// need a free Vm unknown + Q equation. +#include "LSGrid.hpp" + +namespace ls2g { + +void MultiSlack::update_state( + const Base * /*nr_system_base_ptr*/, + const LSGrid * lsgrid_ptr, + const Eigen::SparseMatrix& /*Ybus*/, + const CplxVect& Sbus, + const RealVect& slack_weights +) +{ + slack_weights_ = slack_weights; + // initial slack absorbed (see MultiSlackPolicy::initial_slack_absorbed) + slack_absorbed_ = std::real(Sbus.sum()); + // slack buses not pinned by a local PV generator need a free Vm + Q equation + // (added in register_in): PQ distributed-slack participants, and remote-voltage + // / SVC controllers (to which the VoltageControl extension then attaches). + free_vm_slack_buses_.clear(); + if(lsgrid_ptr != nullptr) + free_vm_slack_buses_ = lsgrid_ptr->get_free_vm_slack_solver_buses(); +} + +} // namespace ls2g From 898614e3d7f5717fdd4d4b20a3d757f263e91596 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 26 Jun 2026 17:34:09 +0200 Subject: [PATCH 010/166] fix some issues to make lightsim2grid more realistic Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 18 +++ benchmarks/benchmark_ca.py | 142 ++++++++++++++++++ .../network/from_pypowsybl/_from_pypowsybl.py | 95 ++++++++++-- .../network/from_pypowsybl/_olf_bake.py | 7 +- .../tests/test_hvdc_main_component.py | 79 ++++++++++ .../tests/test_line_disco_one_side.py | 99 +++++++++++- lightsim2grid/tests/test_pst_tap_impedance.py | 84 +++++++++++ src/bindings/python/binding_batch.cpp | 2 + src/bindings/python/binding_lsgrid.cpp | 8 + src/core/LSGrid.cpp | 9 +- src/core/LSGrid.hpp | 13 ++ .../batch_algorithm/ContingencyAnalysis.cpp | 53 ++++--- .../batch_algorithm/ContingencyAnalysis.hpp | 13 +- .../element_container/HvdcLineContainer.hpp | 45 ++++++ 14 files changed, 629 insertions(+), 38 deletions(-) create mode 100644 benchmarks/benchmark_ca.py create mode 100644 lightsim2grid/tests/test_hvdc_main_component.py create mode 100644 lightsim2grid/tests/test_pst_tap_impedance.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3349c560..d812734f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -123,6 +123,24 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `test_hvdc_converter_stations`, `test_hvdc_pickle`, `test_hvdc_no_hvdc_bit_identical`). - [ADDED] reading HVDC lines (and their converter stations) from a pypowsybl grid (`init_from_pypowsybl`), tested for parity (see `test_hvdc_pypowsybl`). +- [FIXED] reading a transformer from pypowsybl (`init_from_pypowsybl`) ignored the + **tap-changer step r / x / g / b corrections** (the per-step percentage deltas): + only `rho` / `alpha` were folded in, while r/x/g/b were left at their neutral-tap + value. For phase-shifting transformers whose series impedance varies with the tap + (common on RTE grids) the through-flow was wrong by tens of MW vs PowSyBl Open Load + Flow. The step deltas are now applied. See `test_pst_tap_impedance` and + `HVDC_OLF_FINDINGS.md`. NB: an *in-place* tap change via `change_ratio_trafo` / + `change_shift_trafo` still does not refresh r/x (re-import the grid to follow such a + change). +- [FIXED] `consider_only_main_component` (and `init_from_pypowsybl(..., only_main_component=True)`, + the default) used to deactivate an **entire HVDC line** as soon as one of its two + converters fell outside the main component. For a cross-border / asynchronous HVDC + link (the two converters are in different *synchronous* components) this silently + dropped the in-main converter's scheduled injection -- hundreds of MW on real RTE + grids -- making lightsim2grid disagree with PowSyBl Open Load Flow. The in-main + converter is now kept injecting (like OLF's boundary injection) and only the + out-of-main converter is opened; a line with both converters outside is still fully + dropped. See `test_hvdc_main_component` and `HVDC_OLF_FINDINGS.md`. - [ADDED] **Static Var Compensators (SVC)**: a dedicated `SvcContainer` / `SvcInfo` (exposed through `lightsim2grid.elements`) supporting OFF / VOLTAGE / REACTIVE_POWER regulation modes (with an optional voltage / reactive slope), together with the LSGrid diff --git a/benchmarks/benchmark_ca.py b/benchmarks/benchmark_ca.py new file mode 100644 index 00000000..7cbadc6b --- /dev/null +++ b/benchmarks/benchmark_ca.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid a implements a c++ backend targeting the Grid2Op platform. + +import warnings +import copy +import pandapower as pp +import numpy as np +import hashlib +from scipy.interpolate import interp1d +import matplotlib.pyplot as plt +from grid2op import make, Parameters +from grid2op.Chronics import FromNPY +from grid2op.Backend import PandaPowerBackend +from lightsim2grid import LightSimBackend, TimeSerie +try: + from lightsim2grid import ContingencyAnalysis +except ImportError: + from lightsim2grid import SecurityAnalysis as ContingencyAnalysis + +from lightsim2grid.solver import SolverType + +from tqdm import tqdm +import os +from utils_benchmark import print_configuration, get_env_name_displayed +from benchmark_solvers import solver_names + +try: + from tabulate import tabulate + TABULATE_AVAIL = True +except ImportError: + print("The tabulate package is not installed. Some output might not work properly") + TABULATE_AVAIL = False + +VERBOSE = True + +from benchmark_grid_size import ( + make_grid2op_env +) + +case_names = [ + "case14.json", + "case14.json", + "case118.json", + "case_illinois200.json", + "case300.json", + "case1354pegase.json", + "case1888rte.json", + # "GBnetwork.json", # 2224 buses + "case2848rte.json", + "case2869pegase.json", + "case3120sp.json", + "case6495rte.json", + "case6515rte.json", + "case9241pegase.json" + ] + + + +if __name__ == "__main__": + + for case_name in tqdm(case_names): + + if not os.path.exists(case_name): + import pandapower.networks as pn + case = getattr(pn, os.path.splitext(case_name)[0])() + pp.to_json(case, case_name) + + # load the case file + case = pp.from_json(case_name) + pp.runpp(case) # for slack + + # extract reference data + load_p_init = case.load["p_mw"].to_numpy().copy() + load_q_init = case.load["q_mvar"].to_numpy().copy() + gen_p_init = case.gen["p_mw"].to_numpy().copy() + sgen_p_init = case.sgen["p_mw"].to_numpy().copy() + + nb_ts = 1 + # add slack ! + slack_gens = np.zeros((nb_ts, case.ext_grid.shape[0])) + if "res_ext_grid" in case: + slack_gens += np.tile(case.res_ext_grid["p_mw"].to_numpy(), (nb_ts, 1)) + gen_p_g2op = np.concatenate((gen_p_init, slack_gens.reshape(-1))) + + env_lightsim = make_grid2op_env(case, + case_name, + load_p_init.reshape((1,-1)), + load_q_init.reshape((1,-1)), + gen_p_g2op.reshape((1,-1)), + sgen_p_init.reshape((1,-1))) + cases_by_threads = {} + for nb_threads in [1, 2, 3, 4, 5, 6, 7, 8]: + env_lightsim.reset() + sa = ContingencyAnalysis(env_lightsim) + for i in range(env_lightsim.n_line): + sa.add_single_contingency(i) + if i >= 1000: + break + sa.init_from_n_powerflow = True + sa.nb_thread = nb_threads + p_or, a_or, voltages = sa.get_flows() + computer_sa = sa.computer + res_time = 1. + res_unit = "s" + + if VERBOSE: + # print detailed results if needed + print("=====================================================") + print(f"For environment: {case_name} ({env_lightsim.n_sub} substations) [{computer_sa.nb_solved()} powerflows], using {nb_threads} threads") + # if nb_threads == 1: + print(f"Total time spent in \"computer\" to solve everything: {res_time*computer_sa.total_time():.2f}{res_unit} " + f"({computer_sa.nb_solved() / computer_sa.total_time():.0f} pf / s), " + f"{1000.*computer_sa.total_time() / computer_sa.nb_solved():.2f} ms / pf)") + + total_time = computer_sa.total_time() + computer_sa.amps_computation_time() + print(f"Compute time: {computer_sa.solve_time():.2e} / {total_time:.2e} " + f"({100. * computer_sa.solve_time() / total_time:.1f} % of total time)") + print(f"Compute time (per thread): {computer_sa.solve_time() / nb_threads:.2e} / {total_time:.2e} " + f"({100. * computer_sa.solve_time() / nb_threads / total_time:.1f} % of total time per thread)") + print(f"\tExtra threading time: {computer_sa.thread_init_time():.2e} / {total_time:.2e} " + f"({100. * computer_sa.thread_init_time() / total_time:.1f} % of total time)") + print(f"\tExtra pre proc time: {computer_sa.preprocessing_time():.2e} / {total_time:.2e} " + f"({100. * computer_sa.preprocessing_time() / total_time:.1f} % of total time)") + print(f"\tExtra post proc time: {computer_sa.amps_computation_time():.2e} / {total_time:.2e} " + f"({100. * computer_sa.amps_computation_time() / total_time:.1f} % of total time)") + print("=====================================================\n") + + # sa_times.append(computer_sa.total_time() + computer_sa.amps_computation_time()) + # sa_speeds.append(computer_sa.nb_solved() / (computer_sa.total_time() + computer_sa.amps_computation_time()) ) + # sa_sizes.append(env_lightsim.n_sub) + cases_by_threads[nb_threads] = computer_sa.total_time() + computer_sa.amps_computation_time() + # close the env + linear_solver_used_str = solver_names[env_lightsim.backend._grid.get_solver_type()] + for k, v in cases_by_threads.items(): + print(f"{case_name}, nb_threads={k}: {cases_by_threads[1] / v:.1f}x speed-up vs 1 thread") + env_lightsim.close() + break \ No newline at end of file diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 5af42e8d..e9a6a429 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -57,6 +57,38 @@ def _aux_get_bus(vl_df, bus_df, first_bus_per_vl, el_type, df, conn_key="connect return bus_id, mask_disco.values, sub_id +def _aux_tap_step_rxgb_correction(trafo_index, tap_changers, steps): + """Per-transformer (r%, x%, g%, b%) impedance/admittance correction carried by + a tap-changer step at its current position, aligned to ``trafo_index`` (0 where + there is no such tap changer). + + pypowsybl exposes the transformer r / x / g / b at the *neutral* tap. The + per-unit ``rho`` / ``alpha`` already include the current tap, but the per-step + r / x / g / b deltas (given **in percent**, to be applied as + ``value * (1 + delta / 100)``) do NOT. They matter for e.g. RTE phase-shifting + transformers whose series impedance varies strongly with the tap position + (without this, the through-flow can be off by tens of MW).""" + n = len(trafo_index) + dr = np.zeros(n); dx = np.zeros(n); dg = np.zeros(n); db = np.zeros(n) + if (tap_changers is None or tap_changers.shape[0] == 0 or + steps is None or steps.shape[0] == 0 or "tap" not in tap_changers.columns): + return dr, dx, dg, db + cur_tap = {tid: t for tid, t in zip(tap_changers.index, tap_changers["tap"].values)} + for i, tid in enumerate(trafo_index): + t = cur_tap.get(tid) + if t is None or not np.isfinite(t): + continue + key = (tid, int(t)) + if key not in steps.index: + continue + s = steps.loc[key] + dr[i] = s.get("r", 0.0) if np.isfinite(s.get("r", 0.0)) else 0.0 + dx[i] = s.get("x", 0.0) if np.isfinite(s.get("x", 0.0)) else 0.0 + dg[i] = s.get("g", 0.0) if np.isfinite(s.get("g", 0.0)) else 0.0 + db[i] = s.get("b", 0.0) if np.isfinite(s.get("b", 0.0)) else 0.0 + return dr, dx, dg, db + + def _aux_regulated_bus_view_ids(net, regulated_ids): """Resolve voltage-controller regulated elements to their terminal bus. @@ -241,6 +273,7 @@ def init(net : pypo.network.Network, n_busbar_per_sub: Optional[int]=None, # new in 0.9.1 buses_for_sub:Optional[bool]=None, # new in 0.9.1 init_vm_pu:float=1.06, + keep_half_open_lines: bool=False, ) -> LSGrid: """ This function is available under the `init_from_pypowsybl` in lightsim2grid @@ -320,7 +353,18 @@ def init(net : pypo.network.Network, :param init_vm_pu: The voltage magnitude with which the init vector of AC powerflow will be set. :type init_vm_pu: float - + + :param keep_half_open_lines: If True, a powerline or transformer connected on only + one terminal (``connected1 != connected2``, *eg* a dangling + boundary stub in a real grid) is modeled as "half-open": the + energized side is kept in the admittance matrix and the open end + is Kron-reduced out, instead of deactivating the whole branch. + This sets ``synch_status_both_side=False`` on the returned model, + so a later one-sided topology change is no longer mirrored to the + other side. Branches disconnected on *both* sides are still fully + deactivated. Default False (whole-branch deactivation, as before). + :type keep_half_open_lines: bool + :return: The properly initialized network. :rtype: :class:`LSGrid` """ @@ -331,7 +375,12 @@ def init(net : pypo.network.Network, sn_mva_used = float(sn_mva) model.set_sn_mva(sn_mva_used) model.set_init_vm_pu(float(init_vm_pu)) - + if keep_half_open_lines: + # allow branches connected on a single terminal (the open end is Kron-reduced + # in the C++ model); a one-sided disconnection is no longer mirrored to the + # other side. + model.set_synch_status_both_side(False) + if gen_slack_id is not None and slack_bus_id is not None: raise RuntimeError("Impossible to intialize a grid with both gen_slack_id and slack_bus_id") @@ -560,9 +609,13 @@ def init(net : pypo.network.Network, lex_bus ) for line_id, (is_or_disc, is_ex_disc) in enumerate(zip(lor_disco, lex_disco)): - if is_or_disc or is_ex_disc: + if is_or_disc and is_ex_disc: model.deactivate_powerline(line_id) - model.set_line_names(df_line.index) + elif is_or_disc: + model.deactivate_powerline_side1(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) + elif is_ex_disc: + model.deactivate_powerline_side2(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) + model.set_line_names(df_line.index) # for trafo # I extract trafo with `all_attributes=True` so that I have access to the `rho` @@ -595,11 +648,27 @@ def init(net : pypo.network.Network, shift_ = np.zeros(df_trafo.shape[0]) # tap is side 2 in IIDM is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) - trafo_r = df_trafo_pu["r"].values - trafo_x = df_trafo_pu["x"].values - trafo_h = (df_trafo_pu["g"].values + 1j * df_trafo_pu["b"].values) - - # now get the ratio + trafo_r = df_trafo_pu["r"].values.astype(float) + trafo_x = df_trafo_pu["x"].values.astype(float) + trafo_h = (df_trafo_pu["g"].values + 1j * df_trafo_pu["b"].values).astype(complex) + + # apply the tap-changer step r / x / g / b corrections (in percent): pypowsybl + # gives r/x/g/b at the neutral tap and only folds the tap into rho / alpha, so + # the per-step impedance deltas (e.g. RTE phase-shifters whose impedance varies + # with the tap) must be applied here, or the through-flow is wrong. + def _safe_steps(getter): + try: + return getattr(net, getter)() + except Exception: # noqa: BLE001 - not available on legacy pypowsybl + return None + for _tc, _steps in ((net.get_phase_tap_changers(), _safe_steps("get_phase_tap_changer_steps")), + (ratio_tap_changer, _safe_steps("get_ratio_tap_changer_steps"))): + dr, dx, dg, db = _aux_tap_step_rxgb_correction(df_trafo.index, _tc, _steps) + trafo_r = trafo_r * (1. + dr / 100.) + trafo_x = trafo_x * (1. + dx / 100.) + trafo_h = trafo_h.real * (1. + dg / 100.) + 1j * (trafo_h.imag * (1. + db / 100.)) + + # now get the ratio # in lightsim2grid (cpp) if "rho" in df_trafo_pu: ratio = 1. * df_trafo_pu["rho"].values @@ -627,10 +696,14 @@ def init(net : pypo.network.Network, tor_bus, tex_bus, False, # ignore_tap_side_for_phase_shift is False for pypowsybl - ) + ) for t_id, (is_or_disc, is_ex_disc) in enumerate(zip(tor_disco, tex_disco)): - if is_or_disc or is_ex_disc: + if is_or_disc and is_ex_disc: model.deactivate_trafo(t_id) + elif is_or_disc: + model.deactivate_trafo_side1(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) + elif is_ex_disc: + model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) model.set_trafo_names(df_trafo.index) # for shunt diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index a91314e5..18bd02b6 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -75,9 +75,14 @@ def _keep_only_main_comp(df_el, df_bus): + """ + keep only element (modeled in df_el) that are on the main component => bus_els["synchronous_component"] == 0 + + This does not deactivate anything. + """ mask_conn = df_el["connected"] bus_els = df_bus.loc[df_el.loc[mask_conn, "bus_id"]] - mask_main = (bus_els["synchronous_component"] == 0).values + mask_main = (bus_els["synchronous_component"] == 0).to_numpy() df_el = df_el.loc[df_el.loc[mask_conn][mask_main].index] return df_el diff --git a/lightsim2grid/tests/test_hvdc_main_component.py b/lightsim2grid/tests/test_hvdc_main_component.py new file mode 100644 index 00000000..17e24a2d --- /dev/null +++ b/lightsim2grid/tests/test_hvdc_main_component.py @@ -0,0 +1,79 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""HVDC line across the boundary of ``consider_only_main_component``. + +An HVDC line bridges two AC grids that are NOT synchronous: ``get_graph`` adds no +edge for it, so the connectivity BFS of ``consider_only_main_component`` really +splits the grid into *synchronous* components. When the two converters land in +different components only one of them is in the main (solved) one. + +OpenLoadFlow keeps that in-main converter as a fixed boundary injection (it still +imports / exports its scheduled HVDC power). lightsim2grid used to deactivate the +WHOLE line as soon as one side was out of the main component, silently dropping +that injection (hundreds of MW on real RTE grids -- see HVDC_OLF_FINDINGS.md). +These tests pin the fixed behaviour: keep the line connected, keep the in-main +converter injecting, open only the out-of-main converter. +""" + +import os +import sys +import unittest +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _aux_make_hvdc import make_case14_hvdc # noqa: E402 + +# bus 7 of case14 is a leaf (degree 1, no load): isolating it leaves the rest of the +# grid connected and well-posed, so it is the perfect "other synchronous component". +_LEAF_BUS = 7 + + +class TestHvdcMainComponent(unittest.TestCase): + def _isolate_leaf(self, model): + """Open every branch touching ``_LEAF_BUS`` so the bus (and the converter + wired to it) becomes its own connected component.""" + for line in model.get_lines(): + if _LEAF_BUS in (line.bus1_id, line.bus2_id): + model.deactivate_powerline(line.id) + for trafo in model.get_trafos(): + if _LEAF_BUS in (trafo.bus1_id, trafo.bus2_id): + model.deactivate_trafo(trafo.id) + + def test_one_converter_out_of_main_keeps_injection(self): + # side 1 (bus 3, rectifier) is in the main component, side 2 (bus 7) is + # isolated. The line must stay connected and side 1 must keep injecting. + psp = 30.0 + net, model = make_case14_hvdc(3, _LEAF_BUS, converters_mode=0, p_setpoint=psp) + self._isolate_leaf(model) + model.consider_only_main_component() + + h = model.get_dclines()[0] + self.assertTrue(h.connected_global, "the HVDC line must stay connected_global") + self.assertTrue(h.connected1, "the in-main converter (side 1) must stay connected") + self.assertFalse(h.connected2, "the out-of-main converter (side 2) must be opened") + + V = model.ac_pf(np.ones(net.bus.shape[0], dtype=np.complex128), 30, 1e-10) + self.assertGreater(V.shape[0], 0, "ac_pf diverged after isolating the HVDC far end") + # the in-main rectifier still draws its full setpoint (generator convention), + # exactly as if the line were fully connected: the injection is preserved. + h = model.get_dclines()[0] + self.assertAlmostEqual(h.res_p1_mw, -psp, places=6) + + def test_both_converters_in_main_unchanged(self): + # control case: both converters in the main component -> nothing is opened. + net, model = make_case14_hvdc(3, 9, converters_mode=0, p_setpoint=30.0) + model.consider_only_main_component() + h = model.get_dclines()[0] + self.assertTrue(h.connected_global) + self.assertTrue(h.connected1) + self.assertTrue(h.connected2) + + +if __name__ == "__main__": + unittest.main() diff --git a/lightsim2grid/tests/test_line_disco_one_side.py b/lightsim2grid/tests/test_line_disco_one_side.py index cc04ed59..c68b3a0d 100644 --- a/lightsim2grid/tests/test_line_disco_one_side.py +++ b/lightsim2grid/tests/test_line_disco_one_side.py @@ -606,6 +606,103 @@ def test_gridmodel_trafo_side1_alpha_ac(self, is_dc=False): def test_gridmodel_trafo_side1_alpha_dc(self): self.test_gridmodel_trafo_side1_alpha_ac(is_dc=True) - + + +class TestImportHalfOpen(unittest.TestCase): + """`init_from_pypowsybl(keep_half_open_lines=True)` models a branch connected on a + single terminal as half-open (energized side kept, open end Kron-reduced), instead + of fully deactivating it. Covers the static import path (the `TestPFOk` class above + only exercises the grid2op `update_topo` path).""" + + LID = "L6-7-1" # a line: side 1 opened + TID = "T8-5-1" # a transformer: side 2 opened + GEN_SLACK_ID = 29 + + def _open_sides(self, n): + n.update_lines(id=self.LID, connected1=False) + n.update_2_windings_transformers(id=self.TID, connected2=False) + + def _line(self, model): + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + return model.get_lines()[li] + + def _trafo(self, model): + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + return model.get_trafos()[ti] + + def test_import_creates_half_open(self): + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + # line: side 1 open, side 2 energized, branch still globally connected + line = self._line(model) + assert not line.connected1 + assert line.connected2 + assert line.connected_global + # open end Kron-reduced out; only the side-2 self admittance survives + assert np.isclose(line.yac_eff_11, 0.) + assert np.isclose(line.yac_eff_12, 0.) + assert np.isclose(line.yac_eff_21, 0.) + assert np.isclose(line.yac_eff_22, + line.yac_22 - line.yac_21 * line.yac_12 / line.yac_11) + # transformer: side 2 open, side 1 energized + tr = self._trafo(model) + assert tr.connected1 + assert not tr.connected2 + assert tr.connected_global + assert np.isclose(tr.yac_eff_22, 0.) + assert np.isclose(tr.yac_eff_11, + tr.yac_11 - tr.yac_21 * tr.yac_12 / tr.yac_22) + + def test_import_keep_false_deactivates(self): + """Default (keep_half_open_lines=False): a one-side-open branch is fully off.""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, sort_index=False) + line = self._line(model) + assert not line.connected1 + assert not line.connected2 + assert not line.connected_global + tr = self._trafo(model) + assert not tr.connected_global + + def _check_vs_olf(self, is_dc): + slack_vl, _ = get_same_slack("ieee118") + # reference: pypowsybl with the same one-sided outages, slack pinned by name + nref = pp_network.create_ieee118() + self._open_sides(nref) + params = get_pypowsybl_parameters(slack_vl) + res = pp_lf.run_dc(nref, params) if is_dc else pp_lf.run_ac(nref, params) + slack_abs = res[0].slack_bus_results[0].active_power_mismatch + gname = nref.get_generators().index[self.GEN_SLACK_ID] + nref.update_generators( + id=gname, p=nref.get_generators().iloc[self.GEN_SLACK_ID]["p"] - slack_abs) + + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + nb = model.total_bus() + V0 = np.full(nb, 1.0, dtype=complex) + V = model.dc_pf(V0, 1, 1e-8) if is_dc else model.ac_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + + buses = nref.get_buses() + vls = nref.get_voltage_levels() + vm_ref = (buses["v_mag"] / + vls.loc[buses["voltage_level_id"], "nominal_v"].values).to_numpy() + va_ref = np.deg2rad(buses["v_angle"].values) + # tolerances follow TestPFOk (the trafo half-open is the looser one) + assert np.abs(np.angle(V[:len(va_ref)]) - va_ref).max() <= 3e-5 + if not is_dc: + assert np.abs(np.abs(V[:len(vm_ref)]) - vm_ref).max() <= 3e-5 + + def test_import_half_open_ac_matches_olf(self): + self._check_vs_olf(is_dc=False) + + def test_import_half_open_dc_matches_olf(self): + self._check_vs_olf(is_dc=True) + # TODO trafo with alpha (phase shift) # TODO FDPF powerflow too diff --git a/lightsim2grid/tests/test_pst_tap_impedance.py b/lightsim2grid/tests/test_pst_tap_impedance.py new file mode 100644 index 00000000..1677b37a --- /dev/null +++ b/lightsim2grid/tests/test_pst_tap_impedance.py @@ -0,0 +1,84 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Tap-changer step r/x/g/b correction when reading a transformer from pypowsybl. + +pypowsybl exposes the transformer r/x/g/b at the *neutral* tap and only folds the +tap position into ``rho`` / ``alpha``. The per-step r/x/g/b deltas (in percent) +must be applied on top, otherwise the (phase-shifting) transformer impedance -- +and hence its through-flow -- is wrong. On real RTE grids this caused tens of MW +of disagreement with PowSyBl Open Load Flow (see HVDC_OLF_FINDINGS.md). + +The ``four_substations`` pypowsybl test network carries a phase-shifting +transformer ``TWT`` whose phase-tap step applies a ~-28.8% r/x correction, which +makes it a compact regression for the fix. +""" + +import unittest +import warnings + +import numpy as np + +try: + import pypowsybl.network as pn + import pypowsybl.loadflow as lf + from lightsim2grid.network import init_from_pypowsybl + HAS_4SUB = hasattr(pn, "create_four_substations_node_breaker_network") +except ImportError: + HAS_4SUB = False + + +class TestPstTapImpedance(unittest.TestCase): + def setUp(self): + if not HAS_4SUB: + self.skipTest("pypowsybl (with the four_substations network) is required") + + def _ls_trafo(self, model, name): + # the iterator yields a reused proxy: read the attributes during iteration + for el in model.get_trafos(): + if el.name == name: + return el.r_pu, el.x_pu + self.fail(f"transformer {name} not found in the lightsim2grid model") + + def test_phase_tap_step_rx_correction_applied(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + n = pn.create_four_substations_node_breaker_network() + lf.run_ac(n) + + # base (neutral-tap) per-unit impedance + the current phase-tap step delta + npu = pn.create_four_substations_node_breaker_network() + lf.run_ac(npu) + npu.per_unit = True + trpu = npu.get_2_windings_transformers(all_attributes=True) + base_r = float(trpu.loc["TWT", "r"]) + base_x = float(trpu.loc["TWT", "x"]) + ptc = n.get_phase_tap_changers() + tap = int(ptc.loc["TWT", "tap"]) + step = n.get_phase_tap_changer_steps().loc[("TWT", tap)] + self.assertGreater(abs(step["x"]), 1.0, "this test needs a non-trivial r/x step correction") + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(n, sort_index=False, buses_for_sub=False) + + r_pu, x_pu = self._ls_trafo(model, "TWT") + exp_r = base_r * (1.0 + step["r"] / 100.0) + exp_x = base_x * (1.0 + step["x"] / 100.0) + # the step correction must be applied ... + self.assertAlmostEqual(r_pu, exp_r, places=9, + msg=f"r_pu {r_pu} != corrected {exp_r}") + self.assertAlmostEqual(x_pu, exp_x, places=9, + msg=f"x_pu {x_pu} != corrected {exp_x}") + # ... and it must actually differ from the uncorrected (neutral) impedance + self.assertGreater(abs(x_pu - base_x), 1e-4, + "the step correction was not applied (x_pu == neutral x)") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index b86362d3..c8cd173f 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -125,5 +125,7 @@ void bind_batch(py::module_& m) { .def("preprocessing_time", &ContingencyAnalysis::preprocessing_time, DocSecurityAnalysis::preprocessing_time.c_str()) .def("amps_computation_time", &ContingencyAnalysis::amps_computation_time, DocComputers::amps_computation_time.c_str()) .def("modif_Ybus_time", &ContingencyAnalysis::modif_Ybus_time, DocSecurityAnalysis::modif_Ybus_time.c_str()) + .def("thread_init_time", &ContingencyAnalysis::thread_init_time, "TODO") + .def("solve_time", &ContingencyAnalysis::solve_time, "TODO") .def("nb_solved", &ContingencyAnalysis::nb_solved, DocComputers::nb_solved.c_str()); } diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 04296c4d..bc584c1f 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -152,6 +152,10 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("deactivate_powerline", &LSGrid::deactivate_powerline, DocLSGrid::_internal_do_not_use.c_str()) .def("reactivate_powerline", &LSGrid::reactivate_powerline, DocLSGrid::_internal_do_not_use.c_str()) + .def("deactivate_powerline_side1", &LSGrid::deactivate_powerline_side1, "Disconnect only side 1 of a powerline (half-open). Needs set_synch_status_both_side(False) to keep side 2 connected.") + .def("deactivate_powerline_side2", &LSGrid::deactivate_powerline_side2, "Disconnect only side 2 of a powerline (half-open). Needs set_synch_status_both_side(False) to keep side 1 connected.") + .def("reactivate_powerline_side1", &LSGrid::reactivate_powerline_side1, "Reconnect only side 1 of a powerline.") + .def("reactivate_powerline_side2", &LSGrid::reactivate_powerline_side2, "Reconnect only side 2 of a powerline.") .def("change_bus1_powerline", &LSGrid::change_bus1_powerline_python, DocLSGrid::_internal_do_not_use.c_str()) .def("change_bus2_powerline", &LSGrid::change_bus2_powerline_python, DocLSGrid::_internal_do_not_use.c_str()) .def("get_bus1_powerline", &LSGrid::get_bus1_powerline, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) @@ -159,6 +163,10 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("deactivate_trafo", &LSGrid::deactivate_trafo, DocLSGrid::_internal_do_not_use.c_str()) .def("reactivate_trafo", &LSGrid::reactivate_trafo, DocLSGrid::_internal_do_not_use.c_str()) + .def("deactivate_trafo_side1", &LSGrid::deactivate_trafo_side1, "Disconnect only side 1 of a transformer (half-open). Needs set_synch_status_both_side(False) to keep side 2 connected.") + .def("deactivate_trafo_side2", &LSGrid::deactivate_trafo_side2, "Disconnect only side 2 of a transformer (half-open). Needs set_synch_status_both_side(False) to keep side 1 connected.") + .def("reactivate_trafo_side1", &LSGrid::reactivate_trafo_side1, "Reconnect only side 1 of a transformer.") + .def("reactivate_trafo_side2", &LSGrid::reactivate_trafo_side2, "Reconnect only side 2 of a transformer.") .def("change_bus1_trafo", &LSGrid::change_bus1_trafo_python, DocLSGrid::_internal_do_not_use.c_str()) .def("change_bus2_trafo", &LSGrid::change_bus2_trafo_python, DocLSGrid::_internal_do_not_use.c_str()) .def("get_bus1_trafo", &LSGrid::get_bus1_trafo, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 0aab3d34..77787d24 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -703,8 +703,13 @@ void LSGrid::check_solution_q_values(CplxVect & res, bool check_q_limits) const{ } const auto & station_1 = hvdc.station_side_1; const auto & station_2 = hvdc.station_side_2; - check_solution_q_values_onegen(res, station_1.bus_id, station_1.min_q_mvar, station_1.max_q_mvar, check_q_limits); - check_solution_q_values_onegen(res, station_2.bus_id, station_2.min_q_mvar, station_2.max_q_mvar, check_q_limits); + // a side may be open while the line is still connected_global (a line whose + // remote converter is in another synchronous component, see + // HvdcLineContainer::disconnect_if_not_in_main_component): skip the open side + if(station_1.connected) + check_solution_q_values_onegen(res, station_1.bus_id, station_1.min_q_mvar, station_1.max_q_mvar, check_q_limits); + if(station_2.connected) + check_solution_q_values_onegen(res, station_2.bus_id, station_2.min_q_mvar, station_2.max_q_mvar, check_q_limits); } // ... and the VOLTAGE-mode static var compensators: their reactive injection is diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index ff042504..5c08a34d 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -643,6 +643,13 @@ class LS2G_API LSGrid final void reactivate_powerline(int powerline_id) { powerlines_.reactivate(powerline_id, algo_controler_); } + // per-side (de)activation: model a powerline connected on one terminal only + // ("half-open"). With set_synch_status_both_side(false), the other side stays + // as is; with the default (true) the other side follows (whole-line behaviour). + void deactivate_powerline_side1(int powerline_id) { powerlines_.deactivate_side_1(powerline_id, algo_controler_); } + void deactivate_powerline_side2(int powerline_id) { powerlines_.deactivate_side_2(powerline_id, algo_controler_); } + void reactivate_powerline_side1(int powerline_id) { powerlines_.reactivate_side_1(powerline_id, algo_controler_); } + void reactivate_powerline_side2(int powerline_id) { powerlines_.reactivate_side_2(powerline_id, algo_controler_); } /** * Change the bus on the "side 1" of the powerline powerline_id. @@ -681,6 +688,12 @@ class LS2G_API LSGrid final //deactivate trafo void deactivate_trafo(int trafo_id) {trafos_.deactivate(trafo_id, algo_controler_); } void reactivate_trafo(int trafo_id) {trafos_.reactivate(trafo_id, algo_controler_); } + // per-side (de)activation of a transformer terminal ("half-open"), see the + // powerline equivalents above. + void deactivate_trafo_side1(int trafo_id) { trafos_.deactivate_side_1(trafo_id, algo_controler_); } + void deactivate_trafo_side2(int trafo_id) { trafos_.deactivate_side_2(trafo_id, algo_controler_); } + void reactivate_trafo_side1(int trafo_id) { trafos_.reactivate_side_1(trafo_id, algo_controler_); } + void reactivate_trafo_side2(int trafo_id) { trafos_.reactivate_side_2(trafo_id, algo_controler_); } /** * Change the bus on the "side 1" of the trafo trafo_id. diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 35260a79..7ea0df49 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -348,6 +348,7 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ // perform some initial checks and reset timers size_t nb_total_bus = _reset_data_and_check_vinit(Vinit); _timer_modif_Ybus = 0.; + _timer_thread_init = 0.; // read from the grid the usefull information const auto & sn_mva = _grid_model.get_sn_mva(); @@ -391,6 +392,7 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ if(!n_powerflow_has_conv) return; + auto timer_thread = CustTimer(); // now perform the security analysis, possibly split across several threads const size_t nb_cont = _li_coeffs.size(); const int nb_thread = std::min(static_cast(nb_cont), std::max(1, _nb_thread)); @@ -403,42 +405,37 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ 0, nb_cont, _algo, _algo_controler, Ybus_, Vinit_solver, ac_solver_used, mask_mode, max_iter, tol, sn_mva, - _timer_modif_Ybus, _nb_solved, _timer_solver, err); + _timer_modif_Ybus, _nb_solved, _timer_solver, err, false); if(err) std::rethrow_exception(err); _timer_total = timer.duration(); return; } - // multi-threaded path: one solver + one Ybus copy + one AlgoControl per thread. - // Thread 0 reuses the member solver / Ybus_ / control (already warmed up) to - // save one copy + one warm-up. The remaining threads build a fresh solver of - // the same type and warm it up so its factorization / sparsity pattern match. - std::vector > extra_algos(nb_thread - 1); + // multi-threaded path: every thread owns its solver / control / Ybus copy. + std::vector > algos(nb_thread); std::vector controls(nb_thread); - std::vector > ybus_copies(nb_thread - 1); + std::vector > ybus_copies(nb_thread); std::vector th_timer_modif(nb_thread, 0.); std::vector th_nb_solved(nb_thread, 0); std::vector th_timer_solver(nb_thread, 0.); std::vector th_err(nb_thread); - controls[0] = _algo_controler; // already "nothing changed" after preprocessing - for(int t = 1; t < nb_thread; ++t){ - extra_algos[t - 1] = std::make_unique(); - AlgorithmSelector & algo = *extra_algos[t - 1]; + for(int t = 0; t < nb_thread; ++t){ + algos[t] = std::make_unique(); + AlgorithmSelector & algo = *algos[t]; algo.set_lsgrid(&_grid_model); algo.change_algorithm(get_algo_type()); algo.set_config(_algo.get_config()); // match the member solver's configuration - controls[t] = _algo_controler; // copy, made independent by warmup_solver - warmup_solver(algo, controls[t], Vinit_solver, max_iter, tol); - ybus_copies[t - 1] = Ybus_; // thread-local working copy of the admittance + controls[t] = _algo_controler; + ybus_copies[t] = Ybus_; // thread-local working copy of the admittance } // contiguous split of [0, nb_cont) across the threads auto algo_for = [&](int t) -> AlgorithmSelector & { - return t == 0 ? _algo : *extra_algos[t - 1]; + return *algos[t]; }; auto ybus_for = [&](int t) -> Eigen::SparseMatrix & { - return t == 0 ? Ybus_ : ybus_copies[t - 1]; + return ybus_copies[t]; }; const size_t base = nb_cont / static_cast(nb_thread); const size_t rem = nb_cont % static_cast(nb_thread); @@ -460,17 +457,19 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ b, e, algo_for(t), controls[t], ybus_for(t), Vinit_solver, ac_solver_used, mask_mode, max_iter, tol, sn_mva, - th_timer_modif[t], th_nb_solved[t], th_timer_solver[t], th_err[t]); + th_timer_modif[t], th_nb_solved[t], th_timer_solver[t], th_err[t], true); }); } + _timer_thread_init = timer_thread.duration(); + { size_t b, e; range_of(0, b, e); run_contingency_range( b, e, - _algo, controls[0], Ybus_, Vinit_solver, + algo_for(0), controls[0], ybus_for(0), Vinit_solver, ac_solver_used, mask_mode, max_iter, tol, sn_mva, - th_timer_modif[0], th_nb_solved[0], th_timer_solver[0], th_err[0]); + th_timer_modif[0], th_nb_solved[0], th_timer_solver[0], th_err[0], true); } for(auto & th : threads) th.join(); @@ -503,10 +502,16 @@ void ContingencyAnalysis::run_contingency_range( double & timer_modif_Ybus_acc, int & nb_solved, double & timer_solver, - std::exception_ptr & err) + std::exception_ptr & err, + bool needs_solver_init) { try { CplxVect V; + if(needs_solver_init){ + // Fresh per-thread solvers must analyze / factorize on their first actual solve. + control.tell_all_changed(); + } + for(size_t cont_id = cont_begin; cont_id < cont_end; ++cont_id){ const std::vector & coeffs_modif = _li_coeffs[cont_id]; auto timer_modif_Ybus = CustTimer(); @@ -530,6 +535,10 @@ void ContingencyAnalysis::run_contingency_range( slack_ids_me_.as_eigen(), sw, bus_pv_.as_eigen(), bus_pq_.as_eigen(), max_iter, tol / sn_mva); + if(needs_solver_init){ + control.tell_none_changed(); + needs_solver_init = false; + } algo.set_masked_buses(std::vector()); // reset for the next contingency timer_modif_Ybus = CustTimer(); @@ -562,6 +571,10 @@ void ContingencyAnalysis::run_contingency_range( bus_pq_.as_eigen(), max_iter, tol / sn_mva); + if(needs_solver_init){ + control.tell_none_changed(); + needs_solver_init = false; + } } timer_modif_Ybus = CustTimer(); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 9a2ab500..ed6bb814 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -26,7 +26,8 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch BaseBatchSolverSynch(init_grid_model), _li_defaults(), _li_coeffs(), - _timer_modif_Ybus(0.) + _timer_modif_Ybus(0.), + _timer_thread_init(0.) { } ~ContingencyAnalysis() noexcept = default; @@ -73,6 +74,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch _skip_mask.clear(); _timer_total = 0.; _timer_modif_Ybus = 0.; + _timer_thread_init = 0.; _timer_pre_proc = 0.; } void clear_results_only(){ @@ -81,6 +83,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch _skip_mask.clear(); _timer_total = 0.; _timer_modif_Ybus = 0.; + _timer_thread_init = 0.; _timer_pre_proc = 0.; } @@ -159,6 +162,8 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch double total_time() const {return _timer_total;} double preprocessing_time() const {return _timer_pre_proc;} double modif_Ybus_time() const {return _timer_modif_Ybus;} + double thread_init_time() const {return _timer_thread_init;} + double solve_time() const {return _timer_solver;} protected: // prevent the insertion of "out of range" elements @@ -227,7 +232,8 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch double & timer_modif_Ybus, int & nb_solved, double & timer_solver, - std::exception_ptr & err); + std::exception_ptr & err, + bool needs_solver_init); private: // li_default @@ -244,8 +250,9 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch //timers double _timer_modif_Ybus; // time to update the Ybus between the defaults simulation + double _timer_thread_init; }; } // namespace ls2g -#endif //COMPUTERS_H \ No newline at end of file +#endif //COMPUTERS_H diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index 42e5e42b..d6c94dff 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -205,6 +205,51 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & busbar_in_main_component) override { + const int nb_el = nb(); + DualAlgoControl unused_solver_control; + const GlobalBusIdVect & bus_side_1_id_ = get_buses_side_1(); + const GlobalBusIdVect & bus_side_2_id_ = get_buses_side_2(); + for(int i = 0; i < nb_el; ++i){ + if(!status_global_[i]){ + side_1_.deactivate(i, unused_solver_control); + side_2_.deactivate(i, unused_solver_control); + continue; + } + const int b1 = bus_side_1_id_(i).cast_int(); + const int b2 = bus_side_2_id_(i).cast_int(); + const bool s1_outside = (b1 != _deactivated_bus_id) && !busbar_in_main_component[b1]; + const bool s2_outside = (b2 != _deactivated_bus_id) && !busbar_in_main_component[b2]; + if(s1_outside && s2_outside){ + // both converters in (an)other synchronous component: drop it all + side_1_.deactivate(i, unused_solver_control); + side_2_.deactivate(i, unused_solver_control); + if(!ignore_status_global_) status_global_[i] = false; + } else if(s1_outside || s2_outside){ + // exactly one converter in the main synchronous component: keep + // the HVDC line active and that converter injecting its scheduled + // power, open only the converter that is out of the main component. + // Angle-droop ("AC emulation") cannot run across the cut (the + // remote angle is gone) -> fall back to the fixed power setpoint. + droop_enabled_[i] = false; + if(s1_outside) side_1_.deactivate(i, unused_solver_control); + else side_2_.deactivate(i, unused_solver_control); + // status_global_[i] stays true: the line still injects in-main + } + } + } + real_type get_qmin_or(int hvdc_id) {return side_1_.get_qmin(hvdc_id);} real_type get_qmax_or(int hvdc_id) {return side_1_.get_qmax(hvdc_id);} real_type get_qmin_ex(int hvdc_id) {return side_2_.get_qmin(hvdc_id);} From 8fc9ed8a6b4cde35cbe17fb04a3970abe54b0ea0 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 26 Jun 2026 18:01:13 +0200 Subject: [PATCH 011/166] fix some issues to make lightsim2grid more realistic Signed-off-by: DONNOT Benjamin --- .../element_container/TwoSidesContainer.hpp | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index c695115d..61e9d2e3 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -166,7 +166,7 @@ class TwoSidesContainer : public GenericContainer // (in this case this can do nothing if side_1 or side_2 is not connected) } - virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) final { + virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override { const int nb_el = nb(); DualAlgoControl unused_solver_control; const GlobalBusIdVect & bus_side_1_id_ = get_buses_side_1(); @@ -285,6 +285,32 @@ class TwoSidesContainer : public GenericContainer } } + // Per-side (de)activation: open / close a single terminal of the element. + // `resolve_status` enforces the synch_status_both_side_ policy (when true the + // other side follows, reproducing the whole-element behaviour; when false the + // element stays "half-open" with the other side untouched). Mirrors the + // change_bus_side_1 / change_bus_side_2 pattern below. + virtual void deactivate_side_1(int el_id, DualAlgoControl & solver_control) final { + bool one_changed = side_1_.deactivate(el_id, solver_control); + one_changed = resolve_status(el_id, true, solver_control) || one_changed; + if(one_changed) this->_update_effective_coeffs_one_el(el_id); + } + virtual void deactivate_side_2(int el_id, DualAlgoControl & solver_control) final { + bool one_changed = side_2_.deactivate(el_id, solver_control); + one_changed = resolve_status(el_id, false, solver_control) || one_changed; + if(one_changed) this->_update_effective_coeffs_one_el(el_id); + } + virtual void reactivate_side_1(int el_id, DualAlgoControl & solver_control) final { + bool one_changed = side_1_.reactivate(el_id, solver_control); + one_changed = resolve_status(el_id, true, solver_control) || one_changed; + if(one_changed) this->_update_effective_coeffs_one_el(el_id); + } + virtual void reactivate_side_2(int el_id, DualAlgoControl & solver_control) final { + bool one_changed = side_2_.reactivate(el_id, solver_control); + one_changed = resolve_status(el_id, false, solver_control) || one_changed; + if(one_changed) this->_update_effective_coeffs_one_el(el_id); + } + void reset_results_tsc(){ side_1_.reset_results(); side_2_.reset_results(); From c915475a41ace61d3c6f169c64e7d269800d0d85 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 26 Jun 2026 18:39:58 +0200 Subject: [PATCH 012/166] fix some tap changer cpp side Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 11 ++- .../network/from_pypowsybl/_from_pypowsybl.py | 91 ++++++++++--------- lightsim2grid/tests/test_pst_tap_impedance.py | 28 ++++++ src/bindings/python/binding_lsgrid.cpp | 13 +++ src/core/LSGrid.hpp | 13 +++ src/core/element_container/TrafoContainer.cpp | 74 ++++++++++++++- src/core/element_container/TrafoContainer.hpp | 51 ++++++++++- 7 files changed, 230 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d812734f..b695fb3c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -128,10 +128,13 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. only `rho` / `alpha` were folded in, while r/x/g/b were left at their neutral-tap value. For phase-shifting transformers whose series impedance varies with the tap (common on RTE grids) the through-flow was wrong by tens of MW vs PowSyBl Open Load - Flow. The step deltas are now applied. See `test_pst_tap_impedance` and - `HVDC_OLF_FINDINGS.md`. NB: an *in-place* tap change via `change_ratio_trafo` / - `change_shift_trafo` still does not refresh r/x (re-import the grid to follow such a - change). + Flow. lightsim2grid has no "tap" concept, so this ``alpha -> r/x correction`` + dependency is stored per transformer and the series impedance is refreshed from the + **current phase shift** (interpolated) whenever the coefficients are rebuilt -- so an + *in-place* ``change_shift_trafo`` / ``change_ratio_trafo`` keeps r/x correct too + (verified to match OLF after a tap change). Enabled only when reading from pypowsybl + (a flag, off for pandapower). See `LSGrid.set_trafo_shift_dependent_rx`, + `test_pst_tap_impedance` and `HVDC_OLF_FINDINGS.md`. - [FIXED] `consider_only_main_component` (and `init_from_pypowsybl(..., only_main_component=True)`, the default) used to deactivate an **entire HVDC line** as soon as one of its two converters fell outside the main component. For a cross-border / asynchronous HVDC diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index e9a6a429..2971a92a 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -57,36 +57,44 @@ def _aux_get_bus(vl_df, bus_df, first_bus_per_vl, el_type, df, conn_key="connect return bus_id, mask_disco.values, sub_id -def _aux_tap_step_rxgb_correction(trafo_index, tap_changers, steps): - """Per-transformer (r%, x%, g%, b%) impedance/admittance correction carried by - a tap-changer step at its current position, aligned to ``trafo_index`` (0 where - there is no such tap changer). +def _aux_phase_shift_rx_tables(trafo_index, net): + """Per-transformer phase-shift -> series-impedance dependency for + :meth:`LSGrid.set_trafo_shift_dependent_rx`. - pypowsybl exposes the transformer r / x / g / b at the *neutral* tap. The - per-unit ``rho`` / ``alpha`` already include the current tap, but the per-step - r / x / g / b deltas (given **in percent**, to be applied as - ``value * (1 + delta / 100)``) do NOT. They matter for e.g. RTE phase-shifting - transformers whose series impedance varies strongly with the tap position - (without this, the through-flow can be off by tens of MW).""" + Returns two lists-of-lists aligned to ``trafo_index``: the phase-shift sample + points ``alpha`` (in **radian**, matching lightsim2grid's ``shift_``) and the + matching r/x correction (**in percent**), read from the pypowsybl + phase-tap-changer steps. Inner lists are empty for transformers without a + phase-tap-changer. + + pypowsybl exposes the transformer r / x at the *neutral* tap and only folds the + tap into ``rho`` / ``alpha``; the per-step r/x deltas (percent) are dropped. They + matter for phase-shifting transformers whose series impedance varies with the + shift (RTE PSTs: tens of MW of through-flow). On those grids ``r% == x%`` for + every step, so a single correction table is applied to both r and x.""" n = len(trafo_index) - dr = np.zeros(n); dx = np.zeros(n); dg = np.zeros(n); db = np.zeros(n) - if (tap_changers is None or tap_changers.shape[0] == 0 or - steps is None or steps.shape[0] == 0 or "tap" not in tap_changers.columns): - return dr, dx, dg, db - cur_tap = {tid: t for tid, t in zip(tap_changers.index, tap_changers["tap"].values)} + alpha = [[] for _ in range(n)] + corr = [[] for _ in range(n)] + try: + ptc = net.get_phase_tap_changers() + steps = net.get_phase_tap_changer_steps() + except Exception: # noqa: BLE001 - not available on legacy pypowsybl + return alpha, corr + if ptc.shape[0] == 0 or steps.shape[0] == 0: + return alpha, corr + have = set(ptc.index) for i, tid in enumerate(trafo_index): - t = cur_tap.get(tid) - if t is None or not np.isfinite(t): + if tid not in have: continue - key = (tid, int(t)) - if key not in steps.index: + try: + st = steps.loc[tid] + except KeyError: continue - s = steps.loc[key] - dr[i] = s.get("r", 0.0) if np.isfinite(s.get("r", 0.0)) else 0.0 - dx[i] = s.get("x", 0.0) if np.isfinite(s.get("x", 0.0)) else 0.0 - dg[i] = s.get("g", 0.0) if np.isfinite(s.get("g", 0.0)) else 0.0 - db[i] = s.get("b", 0.0) if np.isfinite(s.get("b", 0.0)) else 0.0 - return dr, dx, dg, db + a = np.deg2rad(np.atleast_1d(st["alpha"].to_numpy(dtype=float))) + x = np.atleast_1d(st["x"].to_numpy(dtype=float)) # r% == x% on these PSTs + alpha[i] = [float(v) for v in a] + corr[i] = [float(v) for v in x] + return alpha, corr def _aux_regulated_bus_view_ids(net, regulated_ids): @@ -648,25 +656,12 @@ def init(net : pypo.network.Network, shift_ = np.zeros(df_trafo.shape[0]) # tap is side 2 in IIDM is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) - trafo_r = df_trafo_pu["r"].values.astype(float) - trafo_x = df_trafo_pu["x"].values.astype(float) - trafo_h = (df_trafo_pu["g"].values + 1j * df_trafo_pu["b"].values).astype(complex) - - # apply the tap-changer step r / x / g / b corrections (in percent): pypowsybl - # gives r/x/g/b at the neutral tap and only folds the tap into rho / alpha, so - # the per-step impedance deltas (e.g. RTE phase-shifters whose impedance varies - # with the tap) must be applied here, or the through-flow is wrong. - def _safe_steps(getter): - try: - return getattr(net, getter)() - except Exception: # noqa: BLE001 - not available on legacy pypowsybl - return None - for _tc, _steps in ((net.get_phase_tap_changers(), _safe_steps("get_phase_tap_changer_steps")), - (ratio_tap_changer, _safe_steps("get_ratio_tap_changer_steps"))): - dr, dx, dg, db = _aux_tap_step_rxgb_correction(df_trafo.index, _tc, _steps) - trafo_r = trafo_r * (1. + dr / 100.) - trafo_x = trafo_x * (1. + dx / 100.) - trafo_h = trafo_h.real * (1. + dg / 100.) + 1j * (trafo_h.imag * (1. + db / 100.)) + # neutral-tap impedance (the phase-shift -> r/x dependence of phase-shifting + # transformers is handled by lightsim2grid as a function of the shift alpha, see + # the model.set_trafo_shift_dependent_rx(...) call below). + trafo_r = df_trafo_pu["r"].values + trafo_x = df_trafo_pu["x"].values + trafo_h = (df_trafo_pu["g"].values + 1j * df_trafo_pu["b"].values) # now get the ratio # in lightsim2grid (cpp) @@ -705,7 +700,13 @@ def _safe_steps(getter): elif is_ex_disc: model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) model.set_trafo_names(df_trafo.index) - + # phase-shifting transformers: declare the (alpha -> r/x correction) dependency so + # lightsim2grid keeps the series impedance right when the shift changes, without any + # "tap" concept (the per-step r/x deltas pypowsybl carries only in its tap steps). + ps_alpha, ps_rx_corr = _aux_phase_shift_rx_tables(df_trafo.index, net) + if any(len(a) for a in ps_alpha): + model.set_trafo_shift_dependent_rx(True, ps_alpha, ps_rx_corr) + # for shunt if sort_index: df_shunt = net.get_shunt_compensators().sort_index() diff --git a/lightsim2grid/tests/test_pst_tap_impedance.py b/lightsim2grid/tests/test_pst_tap_impedance.py index 1677b37a..4466529a 100644 --- a/lightsim2grid/tests/test_pst_tap_impedance.py +++ b/lightsim2grid/tests/test_pst_tap_impedance.py @@ -79,6 +79,34 @@ def test_phase_tap_step_rx_correction_applied(self): self.assertGreater(abs(x_pu - base_x), 1e-4, "the step correction was not applied (x_pu == neutral x)") + def test_in_place_shift_change_refreshes_rx(self): + # changing the phase shift in place (no re-import, no "tap") must refresh the + # series impedance from the alpha -> r/x dependency, NOT leave it stale. + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + n = pn.create_four_substations_node_breaker_network() + lf.run_ac(n) + npu = pn.create_four_substations_node_breaker_network() + lf.run_ac(npu) + npu.per_unit = True + base_x = float(npu.get_2_windings_transformers(all_attributes=True).loc["TWT", "x"]) + model = init_from_pypowsybl(n, sort_index=False, buses_for_sub=False) + + steps = n.get_phase_tap_changer_steps() + tap_a = int(n.get_phase_tap_changers().loc["TWT", "tap"]) + # pick another tap whose step x% differs noticeably from the loaded one + x_a = float(steps.loc[("TWT", tap_a)]["x"]) + cands = [(t, float(steps.loc[("TWT", t)]["x"])) + for t in steps.loc["TWT"].index if abs(float(steps.loc[("TWT", t)]["x"]) - x_a) > 5.0] + tap_b, x_b_pct = cands[0] + alpha_b_rad = float(np.deg2rad(steps.loc[("TWT", tap_b)]["alpha"])) + + tid = next(i for i, el in enumerate(model.get_trafos()) if el.name == "TWT") + model.change_shift_trafo(tid, alpha_b_rad) # in-place, like an OLF tap change + _, x_pu = self._ls_trafo(model, "TWT") + self.assertAlmostEqual(x_pu, base_x * (1.0 + x_b_pct / 100.0), places=8, + msg="series impedance not refreshed after an in-place shift change") + if __name__ == "__main__": unittest.main() diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index bc584c1f..45e62bee 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -188,6 +188,19 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` )mydelimiter") .def("change_shift_trafo_deg", &LSGrid::change_shift_trafo_deg, "Same as ``change_shift_trafo`` but phase shift is expressed in degree and NOT in rad.") + .def("set_trafo_shift_dependent_rx", &LSGrid::set_trafo_shift_dependent_rx, + py::arg("enable"), py::arg("alpha_rad"), py::arg("rx_corr_pct"), + R"mydelimiter( + Declare that (some) transformers have a series impedance (r, x) that depends + on their phase-shift angle alpha, supplied as a per-transformer table of + sample points ``alpha (rad) -> r/x correction (%)`` (the per-step r/x deltas + of a pypowsybl phase-tap-changer; r% == x%). The effective r / x is then + ``base * (1 + corr(shift) / 100)``, interpolated on the current shift and + refreshed whenever ``change_shift_trafo`` / ``change_ratio_trafo`` is called. + There is NO "tap" concept: the dependency is purely on the (continuous) shift. + Pass an empty list for a transformer without such a dependency; ``enable`` is + kept False for pandapower (which has no such data). + )mydelimiter") .def("deactivate_load", &LSGrid::deactivate_load, DocLSGrid::_internal_do_not_use.c_str()) .def("reactivate_load", &LSGrid::reactivate_load, DocLSGrid::_internal_do_not_use.c_str()) .def("change_bus_load", &LSGrid::change_bus_load_python, DocLSGrid::_internal_do_not_use.c_str()) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 5c08a34d..2153ab4c 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -742,6 +742,19 @@ class LS2G_API LSGrid final real_type new_shift_rad = new_shift_deg / BaseConstants::my_180_pi_; change_shift_trafo(trafo_id, new_shift_rad); } + /** + * Declare the alpha (phase-shift) dependence of the transformers' series + * impedance: per transformer, sample points `alpha (rad) -> r/x correction (%)`. + * The effective r / x is then refreshed from these samples (interpolated on the + * current shift) whenever the coefficients are rebuilt, so changing the shift / + * ratio of a phase-shifting transformer keeps its impedance correct -- without + * any "tap" concept. `enable` is kept false for pandapower (no such data). + */ + void set_trafo_shift_dependent_rx(bool enable, + const std::vector > & alpha_rad, + const std::vector > & rx_corr_pct){ + trafos_.set_shift_dependent_rx(enable, alpha_rad, rx_corr_pct, algo_controler_); + } //load void deactivate_load(int load_id) {loads_.deactivate(load_id, algo_controler_); } diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 20ca20b2..10e087e9 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -69,6 +69,13 @@ void TrafoContainer::init( shift_ = trafo_shift_degree / my_180_pi_; // do not forget conversion degree / rad here ! is_tap_side1_ = trafo_tap_side1; ignore_tap_side_for_shift_ = ignore_tap_side_for_shift; + // no alpha-dependent r/x correction by default (neutral = the given r/x); the + // pypowsybl converter enables it afterwards via set_shift_dependent_rx + base_r_ = trafo_r; + base_x_ = trafo_x; + shift_dependent_rx_ = false; + rx_corr_alpha_ = std::vector >(size, std::vector()); + rx_corr_pct_ = std::vector >(size, std::vector()); init_tsc(trafo_hv_id, trafo_lv_id, "trafo"); _update_model_coeffs(); reset_results(); @@ -79,12 +86,19 @@ TrafoContainer::StateRes TrafoContainer::get_state() const std::vector ratio(ratio_.begin(), ratio_.end()); std::vector shift(shift_.begin(), shift_.end()); std::vector is_tap_hv_side = is_tap_side1_; + std::vector base_r(base_r_.begin(), base_r_.end()); + std::vector base_x(base_x_.begin(), base_x_.end()); TrafoContainer::StateRes res( get_tsc_rxha_state(), ratio, is_tap_hv_side, shift, - ignore_tap_side_for_shift_); + ignore_tap_side_for_shift_, + shift_dependent_rx_, + base_r, + base_x, + rx_corr_alpha_, + rx_corr_pct_); return res; } @@ -105,12 +119,70 @@ void TrafoContainer::set_state(TrafoContainer::StateRes & my_state) shift_ = RealVect::Map(&shift[0], size); is_tap_side1_ = is_tap_side1; ignore_tap_side_for_shift_ = std::get<4>(my_state); + + shift_dependent_rx_ = std::get<5>(my_state); + std::vector & base_r = std::get<6>(my_state); + std::vector & base_x = std::get<7>(my_state); + GenericContainer::check_size(base_r, size, "base_r"); + GenericContainer::check_size(base_x, size, "base_x"); + base_r_ = RealVect::Map(&base_r[0], size); + base_x_ = RealVect::Map(&base_x[0], size); + rx_corr_alpha_ = std::get<8>(my_state); + rx_corr_pct_ = std::get<9>(my_state); + _update_model_coeffs(); reset_results(); } +void TrafoContainer::set_shift_dependent_rx( + bool enable, + const std::vector > & alpha_rad, + const std::vector > & rx_corr_pct, + DualAlgoControl & solver_control) +{ + const auto size = nb(); + if(alpha_rad.size() != static_cast(size)) + throw std::runtime_error("TrafoContainer::set_shift_dependent_rx: alpha_rad has a wrong size"); + if(rx_corr_pct.size() != static_cast(size)) + throw std::runtime_error("TrafoContainer::set_shift_dependent_rx: rx_corr_pct has a wrong size"); + shift_dependent_rx_ = enable; + rx_corr_alpha_ = alpha_rad; + rx_corr_pct_ = rx_corr_pct; + // the stored r_ / x_ at this point are the neutral impedance: keep them as the base + base_r_ = r_; + base_x_ = x_; + // sort each (alpha -> correction) table by ascending alpha so the interpolation + // in _shift_rx_corr_pct is well defined + for(int el_id = 0; el_id < size; ++el_id){ + auto & xs = rx_corr_alpha_[el_id]; + auto & ys = rx_corr_pct_[el_id]; + if(xs.size() != ys.size()) + throw std::runtime_error("TrafoContainer::set_shift_dependent_rx: alpha and correction tables differ in size"); + std::vector order(xs.size()); + for(std::size_t i = 0; i < order.size(); ++i) order[i] = i; + std::sort(order.begin(), order.end(), [&xs](std::size_t a, std::size_t b){return xs[a] < xs[b];}); + std::vector sx(xs.size()), sy(ys.size()); + for(std::size_t i = 0; i < order.size(); ++i){ sx[i] = xs[order[i]]; sy[i] = ys[order[i]]; } + xs.swap(sx); ys.swap(sy); + } + // re-apply the (possibly corrected) impedance at the current shift + _update_model_coeffs(); + solver_control.ac_algo_controler().tell_recompute_ybus(); + solver_control.dc_algo_controler().tell_recompute_ybus(); +} + void TrafoContainer::_update_model_coeffs_one_el(int el_id) { + // phase-shifting transformers whose series impedance depends on the phase-shift + // angle: refresh the effective r / x from the neutral value and the correction + // interpolated at the current `shift_`. This makes `change_shift` / `change_ratio` + // (which call `_update_internal_coeffs`) keep r / x in sync with no "tap" concept. + if(shift_dependent_rx_ && !rx_corr_alpha_[el_id].empty()){ + const real_type corr = my_one_ + _shift_rx_corr_pct(el_id) / 100.; + r_(el_id) = base_r_(el_id) * corr; + x_(el_id) = base_x_(el_id) * corr; + } + // for AC // see https://matpower.org/docs/MATPOWER-manual.pdf eq. 3.2 const cplx_type ys = 1. / cplx_type(r_(el_id), x_(el_id)); diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 505cc5db..8d15d3d1 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -72,7 +72,12 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A, // ratio_ std::vector , // is_tap_hv_side std::vector, // shift_ - bool // ignore_tap_side_for_shift_ + bool, // ignore_tap_side_for_shift_ + bool, // shift_dependent_rx_ + std::vector, // base_r_ + std::vector, // base_x_ + std::vector >, // rx_corr_alpha_ + std::vector > // rx_corr_pct_ >; TrafoContainer() noexcept = default; @@ -107,6 +112,23 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A r/x correction (%)` + * (the per-step r/x deltas of a pypowsybl phase-tap-changer; r% == x%). The + * effective impedance is `base * (1 + corr(shift_) / 100)`, recomputed (by + * interpolation on `shift_`) every time the coefficients are rebuilt -- in + * particular whenever `change_shift` / `change_ratio` is called -- so there is + * NO notion of a discrete "tap" in lightsim2grid. Pass an empty inner vector + * for a transformer that has no such dependency. `enable` is the master flag + * (kept false for pandapower, which has no such data). + */ + void set_shift_dependent_rx(bool enable, + const std::vector > & alpha_rad, + const std::vector > & rx_corr_pct, + DualAlgoControl & solver_control); + virtual void hack_Sbus_for_dc_phase_shifter( CplxVect & Sbus, bool ac, @@ -260,11 +282,38 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A > rx_corr_alpha_; // per trafo: alpha (rad), ascending + std::vector > rx_corr_pct_; // per trafo: r/x correction (%) at each alpha (r% == x%) + //output data // model coefficients RealVect dc_x_tau_shift_; + // r/x correction (%) at the current `shift_(el_id)`, linearly interpolated on + // the stored `alpha -> correction` samples (clamped outside the range). 0 if + // the transformer carries no such dependency. + real_type _shift_rx_corr_pct(int el_id) const { + const std::vector & xs = rx_corr_alpha_[el_id]; + const std::vector & ys = rx_corr_pct_[el_id]; + const std::size_t n = xs.size(); + if(n == 0) return my_zero_; + const real_type a = shift_(el_id); + if(a <= xs.front()) return ys.front(); + if(a >= xs.back()) return ys.back(); + std::size_t hi = 1; + while(hi < n && xs[hi] < a) ++hi; + const real_type t = (a - xs[hi - 1]) / (xs[hi] - xs[hi - 1]); + return ys[hi - 1] + t * (ys[hi] - ys[hi - 1]); + } + protected: virtual real_type fillBf_for_PTDF_coeff(int tr_id) const override { From 62cf3bc79c5ca393ce5600a4ec831b564be66d1e Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 26 Jun 2026 21:03:38 +0200 Subject: [PATCH 013/166] improve the contingency analysis - multi thread- handling Signed-off-by: DONNOT Benjamin --- src/core/batch_algorithm/ContingencyAnalysis.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 7ea0df49..8d14c13d 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -420,7 +420,12 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ std::vector th_timer_solver(nb_thread, 0.); std::vector th_err(nb_thread); - for(int t = 0; t < nb_thread; ++t){ + // each worker builds its own solver / control / Ybus copy IN-THREAD so this + // (non-trivial) setup -- in particular the full sparse Ybus_ copy -- runs in + // parallel instead of serially on the main thread. Only reads shared state + // (_grid_model, _algo config, get_algo_type()); each thread writes solely its + // own slot of the pre-sized vectors, so no locking is needed. + auto init_thread = [&](int t){ algos[t] = std::make_unique(); AlgorithmSelector & algo = *algos[t]; algo.set_lsgrid(&_grid_model); @@ -428,7 +433,7 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ algo.set_config(_algo.get_config()); // match the member solver's configuration controls[t] = _algo_controler; ybus_copies[t] = Ybus_; // thread-local working copy of the admittance - } + }; // contiguous split of [0, nb_cont) across the threads auto algo_for = [&](int t) -> AlgorithmSelector & { @@ -451,8 +456,9 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ for(int t = 1; t < nb_thread; ++t){ size_t b, e; range_of(t, b, e); - threads.emplace_back([=, &algo_for, &ybus_for, &controls, &th_timer_modif, + threads.emplace_back([=, &init_thread, &algo_for, &ybus_for, &controls, &th_timer_modif, &th_nb_solved, &th_timer_solver, &th_err, &Vinit_solver](){ + init_thread(t); // build this worker's solver / control / Ybus copy in parallel run_contingency_range( b, e, algo_for(t), controls[t], ybus_for(t), Vinit_solver, @@ -465,6 +471,7 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ { size_t b, e; range_of(0, b, e); + init_thread(0); // thread 0 runs inline: build its solver / control / Ybus copy here run_contingency_range( b, e, algo_for(0), controls[0], ybus_for(0), Vinit_solver, From fbe62a9876436dd6810dad898796b8c19f09656d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 1 Jul 2026 09:10:48 +0200 Subject: [PATCH 014/166] add features for easier extensions writing Signed-off-by: DONNOT Benjamin --- src/bindings/python/binding_batch.cpp | 4 +++ src/bindings/python/binding_lsgrid.cpp | 5 +++ src/core/AlgorithmSelector.hpp | 11 ++++++ src/core/LSGrid.cpp | 16 +++++++++ src/core/LSGrid.hpp | 35 ++++++++++++++++++- .../batch_algorithm/ContingencyAnalysis.cpp | 19 ++++++++++ .../batch_algorithm/ContingencyAnalysis.hpp | 10 ++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 14 ++++++++ src/core/powerflow_algorithm/NRAlgo.hpp | 7 ++++ src/core/powerflow_algorithm/NRLedger.hpp | 7 ++++ src/core/powerflow_algorithm/NRSystem.hpp | 18 ++++++++++ 11 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index c8cd173f..07027792 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -108,6 +108,10 @@ void bind_batch(py::module_& m) { // inspect .def("my_defaults", &ContingencyAnalysis::my_defaults_vect, DocSecurityAnalysis::my_defaults_vect.c_str()) .def("is_grid_connected_after_contingency", &ContingencyAnalysis::is_grid_connected_after_contingency, DocLSGrid::_internal_do_not_use.c_str()) + .def("pick_reference_slack", &ContingencyAnalysis::pick_reference_slack, + "Over the registered contingencies, return the slack bus (gridmodel id) " + "stranded by the fewest of them — feed it to LSGrid.set_reference_slack_bus " + "before ac_pf so handle_disconnected_grid skips as few contingencies as possible.") // perform computation .def("compute", &ContingencyAnalysis::compute, py::call_guard(), DocSecurityAnalysis::compute.c_str()) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 45e62bee..176d271b 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -129,6 +129,11 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("update_slack_weights", &LSGrid::update_slack_weights, "TODO") .def("update_slack_weights_by_id", &LSGrid::update_slack_weights_by_id, "TODO") .def("assign_slack_to_most_connected", &LSGrid::assign_slack_to_most_connected, "TODO") + .def("set_reference_slack_bus", &LSGrid::set_reference_slack_bus, + "Force a (gridmodel) bus to be the angle reference among the slack buses " + "(reordered to slack_ids[0]) without changing the slack set/weights; -1 clears it.") + .def("get_reference_slack_bus", &LSGrid::get_reference_slack_bus, + "Forced angle-reference slack bus (gridmodel id), or -1 if none.") .def("consider_only_main_component", &LSGrid::consider_only_main_component, "TODO and TODO DC LINE: one side might be in the connected comp and not the other !") .def("set_ignore_status_global", &LSGrid::set_ignore_status_global, "Ignore the 'global_status' flags for powerlines and trafo (set to true if you want to control independantly each side of powerlines and trafo). Default: false.") .def("set_synch_status_both_side", &LSGrid::set_synch_status_both_side, "Synch the status of each side of the powerlines and trafo. It means that if you disconnect one side of a powerline / trafo, the other side will also be disconnected. Default: true.") diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 6371ae05..68743384 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -239,6 +239,14 @@ class LS2G_API AlgorithmSelector final check_right_solver("get_q_to_J_col"); return get_prt_solver("get_q_to_J_col", false)->get_q_to_J_col_python(); } + IntVect get_p_to_J_row_python() const { + check_right_solver("get_p_to_J_row"); + return get_prt_solver("get_p_to_J_row", false)->get_p_to_J_row_python(); + } + IntVect get_q_to_J_row_python() const { + check_right_solver("get_q_to_J_row"); + return get_prt_solver("get_q_to_J_row", false)->get_q_to_J_row_python(); + } // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu) + identity, in controller registration order. Empty @@ -253,6 +261,9 @@ class LS2G_API AlgorithmSelector final IntVect get_controller_elem_id() const { return get_prt_solver("get_controller_elem_id", false)->get_controller_elem_id(); } + int get_slack_col() const { + return get_prt_solver("get_slack_col", false)->get_slack_col(); + } double get_computation_time() const { return std::get<3>(get_prt_solver("get_computation_time", true)->get_timers()); diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 77787d24..6b8f0232 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -852,6 +852,22 @@ CplxVect LSGrid::_pre_process_solver_impl( slack_bus_id_me = generators_.get_slack_bus_id(); // this is the slack bus ids with the gridmodel ordering, not the solver ordering. // conversion to solver ordering is done in init_slack_bus + + // Optional forced angle reference: move the requested (gridmodel) bus to + // the front so the NR uses it as slack_ids[0] (the reference) without + // changing the slack set or weights. See LSGrid::set_reference_slack_bus. + if (_forced_ref_slack_bus_id >= 0){ + std::vector sids = slack_bus_id_me.to_int_vector(); + for (std::size_t i = 1; i < sids.size(); ++i){ + if (sids[i] == _forced_ref_slack_bus_id){ + const int ref = sids[i]; + sids.erase(sids.begin() + static_cast(i)); + sids.insert(sids.begin(), ref); + slack_bus_id_me = GlobalBusIdVect(sids); + break; + } + } + } } if (redo_all || solver_control.has_one_el_changed_bus()){ init_bus_status(); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 2153ab4c..852ad9cc 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -163,6 +163,20 @@ class LS2G_API LSGrid final generators_.update_slack_weights_by_id(slack_ids, algo_controler_); } + // Force a given (gridmodel) bus to be the angle reference among the slack + // buses, WITHOUT changing the slack set or weights: the slack list is + // reordered at solve time so this bus becomes slack_ids[0] (the NR + // reference). Pass -1 to clear (default: natural generator order). Used to + // align the base ac_pf with ContingencyAnalysis::pick_reference_slack() so + // the GPU companion inherits a reference stranded by the fewest + // contingencies. Triggers a slack re-evaluation on the next solve. + void set_reference_slack_bus(int bus_id){ + _forced_ref_slack_bus_id = bus_id; + algo_controler_.ac_algo_controler().tell_slack_participate_changed(); + algo_controler_.dc_algo_controler().tell_slack_participate_changed(); + } + int get_reference_slack_bus() const {return _forced_ref_slack_bus_id;} + const SGenContainer & get_static_generators_as_data() const {return sgens_;} const LoadContainer & get_loads_as_data() const {return loads_;} const LineContainer & get_powerlines_as_data() const {return powerlines_;} @@ -1375,7 +1389,21 @@ class LS2G_API LSGrid final Eigen::SparseMatrix get_J_python_solver() const{ return _algo.get_J_python(); // This is copied to python } - + + // Ledger maps of the augmented NR Jacobian, in solver bus numbering + // (size n_bus, -1 when the bus owns no such row/column). They describe + // the layout of get_J_solver() and let external batched solvers (e.g. + // gpusim2grid) rebuild the dS scatter / residual layout without + // re-deriving it from Ybus. Only valid for Newton-Raphson algorithms + // after a solve; throw otherwise (via the active algo). + IntVect get_theta_to_J_col_solver() const { return _algo.get_theta_to_J_col_python(); } + IntVect get_vm_to_J_col_solver() const { return _algo.get_vm_to_J_col_python(); } + IntVect get_q_to_J_col_solver() const { return _algo.get_q_to_J_col_python(); } + IntVect get_p_to_J_row_solver() const { return _algo.get_p_to_J_row_python(); } + IntVect get_q_to_J_row_solver() const { return _algo.get_q_to_J_row_python(); } + // MultiSlack slack_absorbed J column (-1 when distributed slack inactive). + int get_slack_col_solver() const { return _algo.get_slack_col(); } + real_type get_computation_time() const{ return _algo.get_computation_time();} real_type get_dc_computation_time() const{ return _dc_algo.get_computation_time();} @@ -1858,6 +1886,11 @@ class LS2G_API LSGrid final // to solve the newton raphson AlgorithmSelector _algo; AlgorithmSelector _dc_algo; + + // forced angle-reference slack bus (gridmodel id, -1 = none). Declared + // LAST so existing member offsets are unchanged (ABI-stable for the + // gpusim2grid cross-module LSGrid cast). See set_reference_slack_bus. + int _forced_ref_slack_bus_id = -1; }; diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 8d14c13d..363b65db 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -321,6 +321,25 @@ IntVect ContingencyAnalysis::is_grid_connected_after_contingency(){ return res; } +int ContingencyAnalysis::pick_reference_slack(){ + const bool ac_solver_used = _algo.ac_solver_used(); + // Build the solver inputs + per-contingency coefficients on demand (same + // pattern as is_grid_connected_after_contingency / compute). + const bool inputs_ready = (ac_solver_used ? Ybus_.cols() != 0 : Bbus_.cols() != 0); + if(!inputs_ready || _li_coeffs.size() != _li_defaults.size()){ + const size_t nb_total_bus = _grid_model.total_bus(); + CplxVect Vinit = CplxVect::Constant(static_cast(nb_total_bus), + {_grid_model.get_init_vm_pu(), 0.}); + prepare_solver_input_base(Vinit, ac_solver_used); + init_li_coeffs(ac_solver_used, id_me_to_solver_); + } + // select_ref_slack_and_masks() scores every candidate slack and reorders + // slack_ids_me_ so the stranded-by-fewest reference is index 0. + select_ref_slack_and_masks(); + if(slack_ids_me_.size() == 0) return -1; + return slack_ids_me_[static_cast(0)].cast_int(); // gridmodel bus id +} + void ContingencyAnalysis::readd_to_Ybus( Eigen::SparseMatrix & Ybus, const std::vector & coeffs, diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index ed6bb814..1d8df190 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -133,6 +133,16 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch void compute(const CplxVect & Vinit, int max_iter, real_type tol); IntVect is_grid_connected_after_contingency(); + // Choose, over the currently-registered contingencies, the reference slack + // bus (gridmodel numbering) that is stranded by the FEWEST of them — so the + // "handle disconnected grid" mode can keep its (untouched) Jacobian reused + // for as many contingencies as possible. Returns -1 if there is no slack. + // Side-effect free w.r.t. the registered defaults; prepares the solver + // inputs on demand (like is_grid_connected_after_contingency). Feed the + // result to LSGrid::set_reference_slack_bus() before the base ac_pf so the + // GPU companion (gpusim2grid) inherits this reference. + int pick_reference_slack(); + Eigen::Ref compute_flows() { compute_flows_from_Vs(); clean_flows(); diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 5d87df1e..4bcd8168 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -102,6 +102,16 @@ class LS2G_API BaseAlgo : public BaseConstants throw std::runtime_error("get_q_to_J_col: only available for Newton-Raphson solvers."); } + // bus_id -> Jacobian row of that bus' P / Q mismatch equation (-1 if none). + // The row counterpart of the *_to_J_col maps. Only Newton-Raphson solvers + // define a Jacobian, hence these throw by default. + virtual IntVect get_p_to_J_row_python() const { + throw std::runtime_error("get_p_to_J_row: only available for Newton-Raphson solvers."); + } + virtual IntVect get_q_to_J_row_python() const { + throw std::runtime_error("get_q_to_J_row: only available for Newton-Raphson solvers."); + } + // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu) and its (kind, element id) identity, in controller // registration order. Empty for algorithms without the extension (DC, @@ -110,6 +120,10 @@ class LS2G_API BaseAlgo : public BaseConstants virtual IntVect get_controller_kind() const { return IntVect(); } virtual IntVect get_controller_elem_id() const { return IntVect(); } + // MultiSlack: J column of the slack_absorbed unknown (-1 when the + // distributed-slack-in-Jacobian extension is not active). + virtual int get_slack_col() const { return -1; } + Eigen::Ref get_Va() const{ return Va_; } diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 5f2dabbf..6491c303 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -75,10 +75,17 @@ class NRAlgo : public BaseAlgo virtual IntVect get_vm_to_J_col_python() const override { return _to_intvect(_system.vm_to_J_col()); } virtual IntVect get_q_to_J_col_python() const override { return _to_intvect(_system.q_to_J_col()); } + // ----- row (equation) -> bus-id converters (NR-only) ----------------------- + // bus_id -> Jacobian row of that bus' P / Q mismatch equation (-1 if none). + // Same lifetime / semantics as the *_to_J_col converters above. + virtual IntVect get_p_to_J_row_python() const override { return _to_intvect(_system.p_to_J_row()); } + virtual IntVect get_q_to_J_row_python() const override { return _to_intvect(_system.q_to_J_row()); } + // ----- VoltageControl (remote gen + SVC) converged results ----------------- virtual RealVect get_controller_q() const override { return _system.controller_q(); } virtual IntVect get_controller_kind() const override { return _system.controller_kind(); } virtual IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } + virtual int get_slack_col() const override { return _system.slack_col(); } // ----- timers -------------------------------------------------------------- diff --git a/src/core/powerflow_algorithm/NRLedger.hpp b/src/core/powerflow_algorithm/NRLedger.hpp index 036065ad..30068cfe 100644 --- a/src/core/powerflow_algorithm/NRLedger.hpp +++ b/src/core/powerflow_algorithm/NRLedger.hpp @@ -120,6 +120,13 @@ class LS2G_API NRLedger const std::vector& vm_col_of_bus() const { return vm_col_of_bus_; } const std::vector& q_col_of_bus() const { return q_col_of_bus_; } + // bus_id -> Jacobian row converters (-1 when the bus owns no such + // equation); the row counterpart of the *_col_of_bus maps. Consumed by + // external solvers (e.g. gpusim2grid) that re-derive the dS scatter and + // residual layout of the augmented J from the ledger. + const std::vector& p_row_of_bus() const { return p_row_of_bus_; } + const std::vector& q_row_of_bus() const { return q_row_of_bus_; } + int size() const { assert(n_rows_ == n_cols_); return n_rows_; diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index a182f93e..a0c4a822 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -371,6 +371,12 @@ class LS2G_API MultiSlack // distributed-slack extension free_vm_slack_buses_.clear(); } + public: + // J column of the slack_absorbed unknown (custom column, not recorded in + // any bus-keyed map). -1 before register_in. Consumed by external batched + // solvers that re-stamp the slack feature entries on the GPU. + int slack_col() const { return slack_col_; } + private: int my_size_; int ref_slack_id_; @@ -894,6 +900,12 @@ class NRSystem const std::vector& vm_to_J_col() const { return ledger_.vm_col_of_bus(); } const std::vector& q_to_J_col() const { return ledger_.q_col_of_bus(); } + // bus_id -> Jacobian row of that bus' P / Q mismatch equation (-1 if none). + // The row counterpart of *_to_J_col; size n_bus, spans the augmented J. Used + // by external batched solvers to rebuild the dS scatter / residual layout. + const std::vector& p_to_J_row() const { return ledger_.p_row_of_bus(); } + const std::vector& q_to_J_row() const { return ledger_.q_row_of_bus(); } + size_t total_state_variables() const { return static_cast(ledger_.size()); } // ----- VoltageControl results (empty when the extension is not in the tuple) -- @@ -912,6 +924,12 @@ class NRSystem return vc ? IntVect(vc->controller_elem_id()) : IntVect(); } + // ----- MultiSlack: slack_absorbed J column (-1 when the extension is absent) -- + int slack_col() const { + const MultiSlack* ms = _find_extension(); + return ms ? ms->slack_col() : -1; + } + // ----- Scaling reductions ---------------------------------------------------- // max |angle step| / max |voltage-magnitude step| across all state variables. real_type max_abs_dtheta(const RealVect& dx) const { From 1564fca51797e01062b11cfb8b8dc5feeef7047b Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 1 Jul 2026 17:23:31 +0200 Subject: [PATCH 015/166] improve script to init grid from pypowsybl Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/__init__.py | 3 +- .../network/from_pypowsybl/_from_pypowsybl.py | 270 +++++++++++++++--- 2 files changed, 235 insertions(+), 38 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index bd30c997..294e04f2 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -7,13 +7,14 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. __all__ = ["init", + "dangling_line_boundary_bus", "bake_outer_loops", "get_pypowsybl_loopfree_parameters", "get_pypowsybl_loopfree_distributed_slack_parameters", "compare_baked", "ComparisonResult"] -from ._from_pypowsybl import init +from ._from_pypowsybl import init, dangling_line_boundary_bus from ._olf_bake import bake_outer_loops from ._olf_params import ( get_pypowsybl_loopfree_parameters, diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 2971a92a..daa921b9 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -22,6 +22,16 @@ PP_BUG_RATIO_TAP_CHANGER = version.parse("1.9") PYPOWSYBL_VER = version.parse(pypo.__version__) +# suffix of the synthetic branch lightsim2grid line id for a dangling line's +# equivalent boundary branch (see `_aux_dangling_lines_fictitious`) +_DANGLING_BOUNDARY_LINE_SUFFIX = "@boundary_line" + +# OpenLoadFlow's hardcoded fallback droop (in `AbstractLfGenerator.DEFAULT_DROOP`, +# "why not") used for every generator whose `activePowerControl` extension does not +# set its own droop, under the ``PROPORTIONAL_TO_GENERATION_P_MAX`` balance type -- +# see `_default_distributed_slack`. +_OLF_DEFAULT_DROOP = 4.0 + def _aux_get_bus(vl_df, bus_df, first_bus_per_vl, el_type, df, conn_key="connected", bus_key="bus_id", vl_key="voltage_level_id"): if df.shape[0] == 0: @@ -57,6 +67,65 @@ def _aux_get_bus(vl_df, bus_df, first_bus_per_vl, el_type, df, conn_key="connect return bus_id, mask_disco.values, sub_id +def _aux_dangling_lines_fictitious(net, sort_index): + """One fictitious 1-bus "substation" per dangling line, at the far + (boundary) end of its series impedance. + + Real full grids never have any dangling line -- they only appear when + zooming into a sub-area with + ``network.reduce_by_ids_and_depths(..., with_boundary_lines=True)`` + (*eg* in ``reduce_and_compare.py`` / ``validate_olf_vs_lightsim.py``), + which cuts the grid and represents everything beyond the cut as a + ``DanglingLine`` (series r/x/g/b + a constant-power p0/q0 "load" at the + boundary). Without this, ``_from_pypowsybl`` silently drops that + boundary injection (it never reads ``get_dangling_lines()``), which can + be tens to hundreds of MW and makes the reduced-grid comparison + meaningless. + + IIDM convention: the shunt admittance (g, b) of a dangling line sits + entirely on the connectable (network) side; the boundary side carries + none (same convention already used for transformers' single ``h``, see + ``trafo_h`` below). + + Returns ``(bus_extra, vl_extra, df_dl)``; ``bus_extra``/``vl_extra`` are + ``None`` and ``df_dl`` is empty if the grid has no dangling line. + ``df_dl`` carries two extra columns, ``boundary_bus_id`` / + ``boundary_vl_id``, used later to attach the equivalent branch + load. + """ + df_dl = net.get_dangling_lines() + if sort_index: + df_dl = df_dl.sort_index() + if df_dl.shape[0] == 0: + return None, None, df_dl + df_dl = df_dl.copy() + df_dl["boundary_bus_id"] = [f"{dl_id}@boundary_bus" for dl_id in df_dl.index] + df_dl["boundary_vl_id"] = [f"{dl_id}@boundary_vl" for dl_id in df_dl.index] + nominal_v = net.get_voltage_levels().loc[df_dl["voltage_level_id"].values, "nominal_v"].values + bus_extra = pd.DataFrame({"voltage_level_id": df_dl["boundary_vl_id"].values, "name": ""}, + index=pd.Index(df_dl["boundary_bus_id"].values, name=df_dl.index.name or "id")) + vl_extra = pd.DataFrame({"nominal_v": nominal_v}, + index=pd.Index(df_dl["boundary_vl_id"].values, name="id")) + return bus_extra, vl_extra, df_dl + + +def dangling_line_boundary_bus(model: LSGrid) -> Dict[str, int]: + """Dangling-line id -> lightsim2grid global bus id of its fictitious + boundary bus, for a grid built with ``init(..., convert_dangling_lines=True)`` + (see :func:`_aux_dangling_lines_fictitious`). Empty if the grid has no + dangling line, or was built with ``convert_dangling_lines=False``. + + Unlike every other bus, a dangling line's boundary bus has no counterpart in + ``network.get_buses()`` so it is not reachable through ``model._ls_to_orig``. + Rather than stashing extra state on ``model`` (a pybind11 object without + ``dynamic_attr``, so arbitrary attributes cannot be set on it), this is + reconstructed from the synthetic boundary branch's own (real, C++-backed) + ``bus2_id`` -- once built, that branch is an ordinary lightsim2grid line. + """ + suffix = _DANGLING_BOUNDARY_LINE_SUFFIX + return {el.name[:-len(suffix)]: el.bus2_id + for el in model.get_lines() if el.name.endswith(suffix)} + + def _aux_phase_shift_rx_tables(trafo_index, net): """Per-transformer phase-shift -> series-impedance dependency for :meth:`LSGrid.set_trafo_shift_dependent_rx`. @@ -156,18 +225,40 @@ def _default_distributed_slack(net, df_gen): active-power control (``participate`` flag of the ``activePowerControl`` extension); if that extension is absent, every connected producing generator participates; - * participants are restricted to the *main country* (the country hosting the - most buses) when substation ``country`` metadata is available -- this keeps - the slack out of foreign border-equivalent generators; - * the per-generator weight is the distributed-slack key: the - ``participation_factor`` from the ``activePowerControl`` extension when it is - set, otherwise the generator's ``max_p`` (matching OLF's - ``PROPORTIONAL_TO_GENERATION_P_MAX`` default). - - The reference generator (angle datum) follows OLF's grid-connection logic: - follow each candidate's step-up transformer to the highest-voltage bus it - reaches, pick the bus carrying the most generator active power, then the - generator injecting the most into it. + * participants are restricted to the *main synchronous component* (OLF's + ``ActivePowerDistribution.run`` only ever considers + ``LfSynchronousNetwork.getBuses()``) -- **not** to any particular country: + ``ActivePowerDistribution.filterParticipatingBuses`` has no country logic at + all, and OLF's own ``slackBusCountryFilter`` is empty by default, so foreign + border-equivalent generators (*eg* a cross-border interconnection's + equivalent generator, which can carry a very large ``max_p``/weight) do + participate. An earlier version of this function restricted participants to + the country hosting the most buses; that was a guess, not something OLF + actually does, and it was found to materially skew which generators absorb + the slack mismatch on real multi-country RTE grids -- removed; + * the per-generator weight matches OLF's default ``PROPORTIONAL_TO_GENERATION_P_MAX`` + balance type exactly (``GenerationActivePowerDistributionStep.getParticipationFactor``, + ``MAX`` case): ``max_p / droop``, where ``droop`` is the ``activePowerControl`` + extension's own droop when it sets one (> 0), otherwise OLF's hardcoded + ``DEFAULT_DROOP = 4`` for *every* generator regardless of the extension. + **Not** the extension's ``participation_factor`` -- that key is only used + under the (different, not reproduced here) ``PROPORTIONAL_TO_GENERATION_PARTICIPATION_FACTOR`` + balance type, even though real grids often set both fields together. + + The reference generator (angle datum, ``slack_ids[0]``) is a separate concern + from the participant/weight logic above and IS restricted to the main country + (the country hosting the most buses) when available: follow each candidate's + step-up transformer to the highest-voltage bus it reaches, pick the bus + carrying the most generator active power, then the generator injecting the + most into it -- a lightsim2grid-only proxy for OLF's own + ``slackBusSelectionMode=MOST_MESHED``. Without the country restriction here, a + single cross-border aggregate generator (*eg* a "whole neighbouring country" + equivalent injection, `max_p` in the tens of GW) reaches a high-voltage tie bus + and dominates this heuristic, becoming the reference -- which behaves nothing + like a real MOST_MESHED domestic bus and was empirically much worse (see the + country-filter history above; this restriction previously covered the + participant set too, until that was found to disagree with OLF and narrowed + down to just the reference pick). """ names = df_gen.index n = len(names) @@ -187,17 +278,20 @@ def _default_distributed_slack(net, df_gen): # a generator listed in the extension uses its flag; one absent from it # participates by default (OLF treats a missing extension as participating) participate = apc["participate"].reindex(names).fillna(True).to_numpy(bool) - if "participation_factor" in apc.columns: - pfac = apc["participation_factor"].reindex(names).to_numpy(float) + if "droop" in apc.columns: + droop = apc["droop"].reindex(names).to_numpy(float) else: - pfac = np.full(n, np.nan) + droop = np.full(n, np.nan) else: participate = np.ones(n, bool) # no (or empty) extension -> everything participates - pfac = np.full(n, np.nan) + droop = np.full(n, np.nan) - # sharing key (PROPORTIONAL_TO_GENERATION_P_MAX): participation_factor when set, - # else max_p. - weight = np.where(np.isfinite(pfac) & (pfac > 0.), pfac, max_p) + # sharing key (PROPORTIONAL_TO_GENERATION_P_MAX): max_p / droop, OLF's own + # DEFAULT_DROOP=4 when the extension does not set a droop of its own (pypowsybl + # reports an unset droop as 0.0, not NaN, hence the `> 0.` guard rather than + # `np.isfinite`). + droop_used = np.where(np.isfinite(droop) & (droop > 0.), droop, _OLF_DEFAULT_DROOP) + weight = max_p / droop_used # participants: connected, *started* (positive target P -- OLF does not # distribute on zero-MW generators), participating, with a usable positive weight. @@ -207,29 +301,21 @@ def _default_distributed_slack(net, df_gen): # otherwise lightsim2grid's `consider_only_main_component` deactivates its # (islanded) bus and the solver then trips on a disconnected slack. "Main" is # the largest synchronous component, which matches the line + transformer - # connectivity lightsim2grid keeps (HVDC excluded). Real grids carry many small - # boundary islands that satisfy the country/participation tests above. + # connectivity lightsim2grid keeps (HVDC excluded) and mirrors OLF's own + # `LfSynchronousNetwork.getBuses()` restriction. Real grids carry many small + # boundary islands that satisfy the participation tests above. df_bus = net.get_buses(attributes=["synchronous_component", "voltage_level_id"]) main_sync = df_bus["synchronous_component"].value_counts().idxmax() gen_sync = df_gen["bus_id"].map(df_bus["synchronous_component"]).to_numpy() mask = mask & (gen_sync == main_sync) - # country filter (main country = the one hosting the most buses) - subs = net.get_substations() - vls = net.get_voltage_levels() - if "country" in subs.columns and subs["country"].notna().any(): - vl2sub = vls["substation_id"] - bus_country = df_bus["voltage_level_id"].map(vl2sub).map(subs["country"]) - main_country = bus_country.value_counts().idxmax() - gen_country = df_gen["voltage_level_id"].map(vl2sub).map(subs["country"]).to_numpy() - mask = mask & (gen_country == main_country) - if not mask.any(): return None # reference (angle datum): follow the step-up transformer(s) to the # highest-voltage node, take the node with the most generator active power, then # the generator injecting the most into it. + vls = net.get_voltage_levels() nomv = net.get_buses()["voltage_level_id"].map(vls["nominal_v"]).to_dict() tw = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "connected1", "connected2"]) tc = tw[tw["connected1"] & tw["connected2"]] @@ -253,16 +339,38 @@ def follow_gsu(bus, max_hops=4): q.append((nb, d + 1)) return best + # candidate pool for the *reference* (angle datum) only: restricted to the main + # country when available. This is a lightsim2grid-only heuristic proxy for OLF's + # own `slackBusSelectionMode=MOST_MESHED` reference pick (unrelated to country), + # added because a cross-border tie's equivalent generator (*eg* a single "whole + # neighbouring country" aggregate with a very large `max_p`) otherwise dominates + # the "most generation at the highest voltage reached" heuristic below and gets + # picked as reference, which is a poor MOST_MESHED proxy and was empirically much + # worse than restricting to the domestic candidates. Unlike this reference pick, + # the *participant* set (`mask` above) is intentionally left unrestricted, since + # OLF's actual active-power distribution (`ActivePowerDistribution. + # filterParticipatingBuses`) has no country logic at all. + ref_mask = mask + subs = net.get_substations() + if "country" in subs.columns and subs["country"].notna().any(): + vl2sub = vls["substation_id"] + bus_country = df_bus["voltage_level_id"].map(vl2sub).map(subs["country"]) + main_country = bus_country.value_counts().idxmax() + gen_country = df_gen["voltage_level_id"].map(vl2sub).map(subs["country"]).to_numpy() + if (mask & (gen_country == main_country)).any(): + ref_mask = mask & (gen_country == main_country) + cand = np.where(mask)[0] - conn = {i: follow_gsu(gen_bus[i]) for i in cand} - cvolt = {i: nomv.get(conn[i], 0.) for i in cand} + ref_cand = np.where(ref_mask)[0] + conn = {i: follow_gsu(gen_bus[i]) for i in ref_cand} + cvolt = {i: nomv.get(conn[i], 0.) for i in ref_cand} vmax = max(cvolt.values()) node_p = {} - for i in cand: + for i in ref_cand: if cvolt[i] == vmax: node_p[conn[i]] = node_p.get(conn[i], 0.) + target_p[i] ref_node = max(node_p, key=node_p.get) - ref_i = max((i for i in cand if cvolt[i] == vmax and conn[i] == ref_node), + ref_i = max((i for i in ref_cand if cvolt[i] == vmax and conn[i] == ref_node), key=lambda i: target_p[i]) order = [int(ref_i)] + [int(i) for i in cand if i != ref_i] @@ -282,6 +390,7 @@ def init(net : pypo.network.Network, buses_for_sub:Optional[bool]=None, # new in 0.9.1 init_vm_pu:float=1.06, keep_half_open_lines: bool=False, + convert_dangling_lines: bool=False, ) -> LSGrid: """ This function is available under the `init_from_pypowsybl` in lightsim2grid @@ -373,6 +482,22 @@ def init(net : pypo.network.Network, deactivated. Default False (whole-branch deactivation, as before). :type keep_half_open_lines: bool + :param convert_dangling_lines: If True, every IIDM ``DanglingLine`` (*eg* + produced by ``network.reduce_by_ids_and_depths(..., + with_boundary_lines=True)`` when zooming into a + sub-area) is converted to its equivalent branch + + constant-power load: a fictitious 1-bus "substation" at + the boundary end, a line carrying the dangling line's own + r/x/g/b (shunt entirely on the local side, matching + transformers' single ``h``), and a load consuming its + p0/q0. Without this (the default), dangling lines are + silently ignored -- fine for real full grids, which + never have any, but it drops a real boundary injection + (can be hundreds of MW) whenever they do appear. Off by + default to keep existing behaviour unchanged; the + reduce/validate debug scripts turn it on. + :type convert_dangling_lines: bool + :return: The properly initialized network. :rtype: :class:`LSGrid` """ @@ -404,7 +529,35 @@ def init(net : pypo.network.Network, voltage_levels = net.get_voltage_levels().sort_index() else: voltage_levels = net.get_voltage_levels() - + + # dangling lines (only present when the grid was cut with + # `reduce_by_ids_and_depths(..., with_boundary_lines=True)`, see + # `_aux_dangling_lines_fictitious`) each get their own fictitious 1-bus + # "substation" at their boundary end, appended *after* the real buses so + # `orig_id` / row-position based mappings (eg `_ls_to_orig`, used by + # `lightsim_bus_to_iidm`) keep pointing at real pypowsybl buses only. + if convert_dangling_lines: + bus_extra, vl_extra, df_dl = _aux_dangling_lines_fictitious(net, sort_index) + else: + raw_dl = net.get_dangling_lines() + bus_extra, vl_extra, df_dl = None, None, raw_dl.iloc[0:0] + if raw_dl.shape[0] > 0: + warnings.warn(f"{raw_dl.shape[0]} dangling line(s) found in the grid (eg from " + "`network.reduce_by_ids_and_depths(..., with_boundary_lines=True)`) " + "but `convert_dangling_lines=False` (the default): their boundary " + "injection is ignored. Pass `convert_dangling_lines=True` to model " + "them as an equivalent branch + constant-power load instead.") + if df_dl.shape[0] > 0: + if buses_for_sub is not None and buses_for_sub: + raise RuntimeError("Dangling lines (eg from `network.reduce_by_ids_and_depths(" + "..., with_boundary_lines=True)`) are not supported " + "together with buses_for_sub=True (legacy mode). Use " + "buses_for_sub=False (or None).") + bus_extra = bus_extra.copy() + bus_extra["orig_id"] = np.arange(bus_df.shape[0], bus_df.shape[0] + bus_extra.shape[0]) + bus_df = pd.concat([bus_df, bus_extra]) + voltage_levels = pd.concat([voltage_levels, vl_extra]) + all_buses_vn_kv = voltage_levels.loc[bus_df["voltage_level_id"].values]["nominal_v"].values nb_bus_per_vl = bus_df[["voltage_level_id", "name"]].groupby("voltage_level_id").count() @@ -540,7 +693,12 @@ def init(net : pypo.network.Network, # for oldest pypowsybl version, we could have "" there bus_reg = np.where(bus_reg == "", df_gen.index, bus_reg) vl_reg = copy.deepcopy(df_gen["voltage_level_id"].values) - mask_remote_gen = bus_reg != df_gen.index.values + # a disconnected generator is deactivated below regardless of its (possibly + # itself disconnected / unresolvable) regulated element, so exclude it here: + # otherwise a disconnected generator remotely "regulating" a disconnected + # busbar section (empty bus-view id) crashes the (moot, since deactivated) + # bus resolution below. + mask_remote_gen = (bus_reg != df_gen.index.values) & ~gen_disco gen_reg_bus_view = None if mask_remote_gen.any(): gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) @@ -575,6 +733,18 @@ def init(net : pypo.network.Network, df_load = net.get_loads().sort_index() else: df_load = net.get_loads() + if df_dl.shape[0] > 0: + # equivalent constant-power load at each dangling line's boundary bus + # (see `_aux_dangling_lines_fictitious`); p0/q0 are already in MW/MVAr, + # same raw convention as `net.get_loads()`. + df_load_extra = pd.DataFrame({ + "p0": df_dl["p0"].values, + "q0": df_dl["q0"].values, + "bus_id": df_dl["boundary_bus_id"].values, + "connected": True, + "voltage_level_id": df_dl["boundary_vl_id"].values, + }, index=[f"{dl_id}@boundary_load" for dl_id in df_dl.index]) + df_load = pd.concat([df_load, df_load_extra]) load_bus, load_disco, load_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "load", df_load) model.init_loads(df_load["p0"].values, df_load["q0"].values, @@ -603,6 +773,32 @@ def init(net : pypo.network.Network, warnings.warn("The `PerUnitView` (python side) is less efficient and less " "tested that the equivalent java class. Please upgrade pypowsybl version") df_line_pu = net_pu.get_lines().loc[df_line.index] + if df_dl.shape[0] > 0: + # equivalent branch (local bus -> fictitious boundary bus) for each + # dangling line, see `_aux_dangling_lines_fictitious`. Shunt admittance + # (g, b) sits entirely on the local (network) side, none on the + # boundary side -- same convention already used for the single `h` of + # a transformer (see `trafo_h` below). + df_dl_pu = net_pu.get_dangling_lines().loc[df_dl.index] + line_ids_extra = [f"{dl_id}{_DANGLING_BOUNDARY_LINE_SUFFIX}" for dl_id in df_dl.index] + df_line_extra = pd.DataFrame({ + "bus1_id": df_dl["bus_id"].values, + "bus2_id": df_dl["boundary_bus_id"].values, + "connected1": df_dl["connected"].values, + "connected2": True, + "voltage_level1_id": df_dl["voltage_level_id"].values, + "voltage_level2_id": df_dl["boundary_vl_id"].values, + }, index=line_ids_extra) + df_line_extra_pu = pd.DataFrame({ + "r": df_dl_pu["r"].values, + "x": df_dl_pu["x"].values, + "g1": df_dl_pu["g"].values, + "b1": df_dl_pu["b"].values, + "g2": 0., + "b2": 0., + }, index=line_ids_extra) + df_line = pd.concat([df_line, df_line_extra]) + df_line_pu = pd.concat([df_line_pu, df_line_extra_pu]) line_r = df_line_pu["r"].values line_x = df_line_pu["x"].values line_h_or = (df_line_pu["g1"].values + 1j * df_line_pu["b1"].values) From 96f976af6e9358f27ef40fdf804ff32260c06d93 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 2 Jul 2026 10:46:03 +0200 Subject: [PATCH 016/166] add a new serialization method, fix some HVDC import issues Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 12 + benchmarks/benchmark_binary_serialization.py | 286 ++++++++++++++++++ docs/binary_serialization.rst | 159 ++++++++++ docs/conf.py | 4 +- docs/index.rst | 1 + lightsim2grid/__init__.py | 2 +- lightsim2grid/network/compare_lsgrid.py | 25 ++ .../network/from_pypowsybl/_from_pypowsybl.py | 13 +- .../tests/test_binary_serialization.py | 246 +++++++++++++++ lightsim2grid/tests/test_pickleable.py | 43 ++- src/bindings/python/binary_helpers.hpp | 42 +++ src/bindings/python/binding_containers.cpp | 11 + src/bindings/python/binding_lsgrid.cpp | 4 + src/core/BinaryArchive.cpp | 156 ++++++++++ src/core/BinaryArchive.hpp | 274 +++++++++++++++++ src/core/CMakeLists.txt | 1 + src/core/LSGrid.cpp | 9 + src/core/LSGrid.hpp | 14 + src/core/SubstationContainer.cpp | 9 + src/core/SubstationContainer.hpp | 4 + .../element_container/GeneratorContainer.cpp | 9 + .../element_container/GeneratorContainer.hpp | 4 + .../element_container/HvdcLineContainer.cpp | 9 + .../element_container/HvdcLineContainer.hpp | 12 +- src/core/element_container/LineContainer.cpp | 9 + src/core/element_container/LineContainer.hpp | 6 +- src/core/element_container/LoadContainer.cpp | 9 + src/core/element_container/LoadContainer.hpp | 4 + src/core/element_container/SGenContainer.cpp | 9 + src/core/element_container/SGenContainer.hpp | 4 + src/core/element_container/ShuntContainer.cpp | 9 + src/core/element_container/ShuntContainer.hpp | 4 + .../element_container/StorageContainer.cpp | 9 + .../element_container/StorageContainer.hpp | 4 + src/core/element_container/SvcContainer.cpp | 9 + src/core/element_container/SvcContainer.hpp | 4 + src/core/element_container/TrafoContainer.cpp | 9 + src/core/element_container/TrafoContainer.hpp | 4 + 38 files changed, 1423 insertions(+), 19 deletions(-) create mode 100644 benchmarks/benchmark_binary_serialization.py create mode 100644 docs/binary_serialization.rst create mode 100644 lightsim2grid/tests/test_binary_serialization.py create mode 100644 src/bindings/python/binary_helpers.hpp create mode 100644 src/core/BinaryArchive.cpp create mode 100644 src/core/BinaryArchive.hpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b695fb3c..9d38f786 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -233,6 +233,18 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. enables the `get_theta_to_J_col()` / `get_vm_to_J_col()` / `get_q_to_J_col()` mappings above. This central registry is also what makes the HVDC / voltage-control Jacobian extensions possible. +- [ADDED] a fast, additive binary serialization path: `LSGrid.save_binary(path)` / + `LSGrid.load_binary(path)`, and the same two methods on every individual element + container that has its own state (*eg* `gridmodel.get_loads().save_binary(...)`), + mirroring what is already picklable. This is **not** a replacement for pickle: it + trades portability for speed, meant for repeatedly re-loading the *same* grid on the + *same* machine / lightsim2grid build. **NB** as with pickle, a file can only be + reloaded with the exact same lightsim2grid version it was saved with; a version + mismatch or a corrupted / truncated file raises a `RuntimeError` (no cross-version + migration is attempted). See the new "Fast binary serialization" documentation page + and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against + pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster + to read than pickle on grids with ~9000 buses). [0.13.1] 2026-04-21 -------------------- diff --git a/benchmarks/benchmark_binary_serialization.py b/benchmarks/benchmark_binary_serialization.py new file mode 100644 index 00000000..87ab0fae --- /dev/null +++ b/benchmarks/benchmark_binary_serialization.py @@ -0,0 +1,286 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Benchmark comparing LSGrid.save_binary / LSGrid.load_binary (the fast, additive +binary serialization path) against pickle.dump / pickle.load on the same grid. + +Also reports, as a reference point, the "reading time" of building an LSGrid +from scratch from a pandapower network (init_from_pandapower) and from a +pypowsybl network (init_from_pypowsybl): these are NOT round trips of an +already-converted grid, they show how much is saved by re-loading a +lightsim2grid-native snapshot instead of re-running the full conversion. + +In addition to the single l2rpn_idf_2023 fixture, this also scans a range of +grid sizes (the same pandapower cases used by benchmark_grid_size.py, from +14 to ~9241 buses) to see how the save_binary/load_binary speedup over pickle +evolves with grid size. + +Usage: python benchmark_binary_serialization.py [--n_repeat N] [--n_repeat_convert N] [--cases case14.json,case118.json,...] +""" + +import argparse +import os +import pickle +import tempfile +import time +import warnings + +import grid2op +from lightsim2grid.lightSimBackend import LightSimBackend + +TABULATE_AVAIL = False +try: + from tabulate import tabulate + TABULATE_AVAIL = True +except ImportError: + print("The tabulate package is not installed. Output will use a plain format.") + +ENV_NAME = "l2rpn_idf_2023" + +# same graduated set of pandapower cases used by benchmark_grid_size.py, all +# already cached as json files in this directory +CASE_NAMES = [ + "case14.json", + "case118.json", + "case_illinois200.json", + "case300.json", + "case1354pegase.json", + "case1888rte.json", + "case2848rte.json", + "case2869pegase.json", + "case3120sp.json", + "case6495rte.json", + "case6515rte.json", + "case9241pegase.json", +] + + +def _time_repeat(fun, n_repeat): + """Run `fun()` `n_repeat` times, return (total_seconds, per_call_seconds).""" + start = time.perf_counter() + for _ in range(n_repeat): + fun() + total = time.perf_counter() - start + return total, total / n_repeat + + +def benchmark_save_load(grid, n_repeat, tmpdir): + binary_path = os.path.join(tmpdir, "benchmark.lsb") + pickle_path = os.path.join(tmpdir, "benchmark.pickle") + + _, save_binary_s = _time_repeat(lambda: grid.save_binary(binary_path), n_repeat) + _, load_binary_s = _time_repeat(lambda: type(grid).load_binary(binary_path), n_repeat) + binary_size = os.path.getsize(binary_path) + + def _pickle_dump(): + with open(pickle_path, "wb") as f: + pickle.dump(grid, f) + + def _pickle_load(): + with open(pickle_path, "rb") as f: + pickle.load(f) + + _, pickle_dump_s = _time_repeat(_pickle_dump, n_repeat) + _, pickle_load_s = _time_repeat(_pickle_load, n_repeat) + pickle_size = os.path.getsize(pickle_path) + + return { + "save_binary_s": save_binary_s, + "load_binary_s": load_binary_s, + "binary_size_bytes": binary_size, + "pickle_dump_s": pickle_dump_s, + "pickle_load_s": pickle_load_s, + "pickle_size_bytes": pickle_size, + } + + +def benchmark_init_from_pandapower(n_repeat): + try: + import pandapower.networks as pn + from lightsim2grid.network import init_from_pandapower + except ImportError: + return None + net = pn.case118() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + _, read_s = _time_repeat(lambda: init_from_pandapower(net), n_repeat) + return read_s + + +def benchmark_init_from_pypowsybl(n_repeat): + try: + import pypowsybl.network as pp_network + from lightsim2grid.network import init_from_pypowsybl + except ImportError: + return None + net = pp_network.create_ieee118() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + _, read_s = _time_repeat(lambda: init_from_pypowsybl(net), n_repeat) + return read_s + + +def run_fixed_grid_benchmark(n_repeat): + """Original single-grid comparison: save_binary/load_binary vs pickle on + the l2rpn_idf_2023 fixture (same grid used by test_binary_serialization.py + and test_pickleable.py), plus pandapower/pypowsybl reference reading times. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make(ENV_NAME, test=True, backend=LightSimBackend()) + grid = env.backend._grid + + with tempfile.TemporaryDirectory() as tmpdir: + res = benchmark_save_load(grid, n_repeat, tmpdir) + + pandapower_read_s = benchmark_init_from_pandapower(n_repeat) + pypowsybl_read_s = benchmark_init_from_pypowsybl(n_repeat) + + rows = [ + ["save_binary (write)", f"{1000. * res['save_binary_s']:.3f}", f"{res['binary_size_bytes']} bytes"], + ["load_binary (read)", f"{1000. * res['load_binary_s']:.3f}", "-"], + ["pickle.dump (write)", f"{1000. * res['pickle_dump_s']:.3f}", f"{res['pickle_size_bytes']} bytes"], + ["pickle.load (read)", f"{1000. * res['pickle_load_s']:.3f}", "-"], + ] + if pandapower_read_s is not None: + rows.append(["init_from_pandapower (case118, reference)", f"{1000. * pandapower_read_s:.3f}", "-"]) + else: + rows.append(["init_from_pandapower", "pandapower not installed, skipped", "-"]) + if pypowsybl_read_s is not None: + rows.append(["init_from_pypowsybl (ieee118, reference)", f"{1000. * pypowsybl_read_s:.3f}", "-"]) + else: + rows.append(["init_from_pypowsybl", "pypowsybl not installed, skipped", "-"]) + + headers = [f"{ENV_NAME} ({n_repeat} repeats)", "time (ms/call)", "file size"] + if TABULATE_AVAIL: + print(tabulate(rows, headers=headers, tablefmt="rst")) + else: + print("\t".join(headers)) + for row in rows: + print("\t".join(str(x) for x in row)) + + speedup_write = res["pickle_dump_s"] / res["save_binary_s"] + speedup_read = res["pickle_load_s"] / res["load_binary_s"] + print(f"\nsave_binary is {speedup_write:.1f}x faster than pickle.dump") + print(f"load_binary is {speedup_read:.1f}x faster than pickle.load") + + +def _load_pandapower_case(case_name): + """Same load-or-fetch-and-cache pattern as benchmark_grid_size.py: the + case*.json files are already committed in this directory, this is only a + fallback.""" + import pandapower as pp + if not os.path.exists(case_name): + import pandapower.networks as pn + case = getattr(pn, os.path.splitext(case_name)[0])() + pp.to_json(case, case_name) + return pp.from_json(case_name) + + +def _time_read_pandapower_file(case_name, n_repeat): + """Time the full 'read a pandapower json FILE from disk and build an + LSGrid from it' pipeline (pp.from_json + init_from_pandapower). This is + what's directly comparable to load_binary/pickle.load: both of those also + start from a file on disk, not an already-loaded in-memory object, so the + pandapower reference needs to include the file read to be apples-to-apples. + """ + import pandapower as pp + from lightsim2grid.network import init_from_pandapower + + def _read_and_convert(): + net = pp.from_json(case_name) + init_from_pandapower(net) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + _, read_s = _time_repeat(_read_and_convert, n_repeat) + return read_s + + +def run_grid_size_scan(n_repeat, n_repeat_convert, case_names): + """save_binary/load_binary vs pickle across a range of grid sizes, built + directly via init_from_pandapower (no grid2op env needed, just an LSGrid) + -- same pandapower cases benchmark_grid_size.py uses to scan solver speed. + + Also reports, as a reference, the time (and file size) to read the + original pandapower json FILE and build an LSGrid from it from scratch -- + this is the "no snapshot at all" baseline that save_binary/load_binary and + pickle are meant to be faster than for repeated re-loads of the same grid. + """ + try: + from lightsim2grid.network import init_from_pandapower + except ImportError: + print("\npandapower not installed, skipping the grid-size scan.") + return + + rows = [] + for case_name in case_names: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + net = _load_pandapower_case(case_name) + grid = init_from_pandapower(net) + + pp_read_s = _time_read_pandapower_file(case_name, n_repeat_convert) + + with tempfile.TemporaryDirectory() as tmpdir: + res = benchmark_save_load(grid, n_repeat, tmpdir) + + speedup_write = res["pickle_dump_s"] / res["save_binary_s"] + speedup_read = res["pickle_load_s"] / res["load_binary_s"] + speedup_read_vs_pp_file = pp_read_s / res["load_binary_s"] + + rows.append([ + os.path.splitext(case_name)[0], + grid.total_bus(), + f"{1000. * res['save_binary_s']:.3f}", + f"{1000. * res['pickle_dump_s']:.3f}", + f"{speedup_write:.1f}x", + f"{1000. * res['load_binary_s']:.3f}", + f"{1000. * res['pickle_load_s']:.3f}", + f"{speedup_read:.1f}x", + res["binary_size_bytes"], + res["pickle_size_bytes"], + os.path.getsize(case_name), + f"{1000. * pp_read_s:.2f}", + f"{speedup_read_vs_pp_file:.1f}x", + ]) + + headers = [ + "grid", "nb bus", + "save_binary (ms)", "pickle.dump (ms)", "write speedup", + "load_binary (ms)", "pickle.load (ms)", "read speedup", + "binary size (B)", "pickle size (B)", "pandapower json size (B)", + "read pandapower file (ms, reference)", "load_binary speedup vs pandapower file", + ] + print(f"\nGrid size scan ({n_repeat} repeats for save/load, {n_repeat_convert} for the " + f"'read pandapower file' reference):") + if TABULATE_AVAIL: + print(tabulate(rows, headers=headers, tablefmt="rst")) + else: + print("\t".join(headers)) + for row in rows: + print("\t".join(str(x) for x in row)) + + +def main(n_repeat=20, n_repeat_convert=5, case_names=CASE_NAMES): + run_fixed_grid_benchmark(n_repeat) + run_grid_size_scan(n_repeat, n_repeat_convert, case_names) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--n_repeat", type=int, default=20, + help="number of times save_binary/load_binary/pickle are repeated") + parser.add_argument("--n_repeat_convert", type=int, default=5, + help="number of times the pandapower/pypowsybl conversion (reference row) is repeated") + parser.add_argument("--cases", type=str, default=None, + help="comma separated list of case*.json files to scan (default: the full CASE_NAMES list)") + args = parser.parse_args() + case_names = CASE_NAMES if args.cases is None else args.cases.split(",") + main(n_repeat=args.n_repeat, n_repeat_convert=args.n_repeat_convert, case_names=case_names) diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst new file mode 100644 index 00000000..0b91d6a2 --- /dev/null +++ b/docs/binary_serialization.rst @@ -0,0 +1,159 @@ +.. _binary-serialization: + +Fast binary serialization (``save_binary`` / ``load_binary``) +================================================================ + +Goal +----------------- + +In addition to the standard `pickle `_ support already +available on :class:`lightsim2grid.network.LSGrid` (see :ref:`elements-modeled`), lightsim2grid offers a +second, **additive** serialization path: ``save_binary`` / ``load_binary``. + +This is **not a replacement for pickle**. It is a much faster, custom binary format meant for the case +where you repeatedly re-load the *same* grid on the *same* machine / lightsim2grid build and care more +about speed than about portability. Pickle remains the way to go if you need to move a grid across +python versions, machines, or lightsim2grid versions, or if you need it to work with multiprocessing / +``ray`` / etc. + +It is available on :class:`lightsim2grid.network.LSGrid` itself, as well as on every individual element +container that has its own state (:class:`~lightsim2grid.elements.LoadContainer`, +:class:`~lightsim2grid.elements.LineContainer`, :class:`~lightsim2grid.elements.GeneratorContainer`, ...), +exactly mirroring what is already picklable: + +.. code-block:: python + + import grid2op + from lightsim2grid import LightSimBackend + + env = grid2op.make(..., backend=LightSimBackend()) + grid = env.backend._grid + + # save the whole grid to a fast binary file + grid.save_binary("my_grid.lsb") + + # ... later, possibly in another python process on the same machine ... + from lightsim2grid.network import LSGrid + grid_reloaded = LSGrid.load_binary("my_grid.lsb") + +Individual element containers work the same way: + +.. code-block:: python + + loads = grid.get_loads() + loads.save_binary("my_loads.lsb") + + from lightsim2grid.elements import LoadContainer + loads_reloaded = LoadContainer.load_binary("my_loads.lsb") + +.. warning:: + ``load_binary`` performs a **hard version check**: the file can only be reloaded with the exact same + lightsim2grid version (major.medium.minor) that was used to write it. As opposed to pickle, there is + no attempt at cross-version migration: a mismatch always raises a ``RuntimeError``. The same is true + of a corrupted or truncated file: ``load_binary`` raises a ``RuntimeError`` rather than crashing or + silently returning garbage. + +.. note:: + The binary format walks the exact same internal state (``get_state`` / ``set_state``) that pickle + uses, so the two stay structurally in sync: anything that can be pickled can be saved with + ``save_binary``. See ``lightsim2grid/tests/test_binary_serialization.py`` for the full test suite + (round trip, AC/DC powerflow round trip, version mismatch, corrupted file). + +.. _binary_serialization_benchmarks: + +Benchmarks +----------------- + +Here are some benchmarks made with: + +- date: 2026-07-02 10:21 CEST +- system: Linux 6.8.0-60-generic +- OS: Ubuntu 22.04.5 LTS +- processor: x86_64 +- python version: 3.12.8 +- numpy version: 2.0.2 +- pandas version: 2.3.3 +- pandapower version: 3.4.0 +- pypowsybl version: 1.15.0 +- grid2op version: 1.12.5.dev0 +- lightsim2grid version: 1.0.0.rc1 +- lightsim2grid extra information: + + - klu_solver_available: True + - nicslu_solver_available: False + - cktso_solver_available: False + - compiled_march_native: False + - compiled_o3_optim: False + +This benchmark is available by running, from the root of the lightsim2grid repository: + +.. code-block:: bash + + cd benchmarks + python3 benchmark_binary_serialization.py + +Single grid (``l2rpn_idf_2023``) +++++++++++++++++++++++++++++++++++ + +A first, direct comparison on a single mid-sized grid (the same fixture used by the test suite): + +==================================== ================ =========== +l2rpn_idf_2023 (15 repeats) time (ms/call) file size +==================================== ================ =========== +save_binary (write) 0.284 31606 B +load_binary (read) 0.168 - +pickle.dump (write) 0.418 32681 B +pickle.load (read) 0.293 - +==================================== ================ =========== + +``save_binary`` is **1.5x** faster than ``pickle.dump`` and ``load_binary`` is **1.7x** faster than +``pickle.load`` on this grid, while also producing a slightly smaller file. + +Grid size scan +++++++++++++++++++++++++++++++++++ + +The benchmark script also scans a range of grid sizes (the same pandapower cases used in +:ref:`benchmark-grid-size`, from 14 to ~9241 buses), each ``LSGrid`` being built directly with +:func:`lightsim2grid.network.init_from_pandapower`. In addition to ``save_binary`` / ``load_binary`` +versus pickle, it also reports, as a reference, the time to read the *original* pandapower json **file** +from disk and build an ``LSGrid`` from it from scratch (``pp.from_json`` + ``init_from_pandapower``) -- +this is the "no snapshot at all" baseline: + +.. code-block:: text + + ================ ======== ================== ================== =============== ================== ================== ============== ================= ================= ========================== ================================ ================================ + grid nb bus save_binary (ms) pickle.dump (ms) write speedup load_binary (ms) pickle.load (ms) read speedup binary size (B) pickle size (B) pandapower json size (B) read pandapower file (ms, ref) load_binary speedup vs pp file + ================ ======== ================== ================== =============== ================== ================== ============== ================= ================= ========================== ================================ ================================ + case14 14 0.315 0.3 1.0x 0.206 0.273 1.3x 3368 3054 69741 312.12 1515.3x + case118 118 0.381 0.35 0.9x 0.271 0.231 0.9x 19502 21654 108962 288.89 1065.5x + case_illinois200 200 0.356 0.625 1.8x 0.243 0.279 1.1x 25936 28600 127041 311.49 1280.0x + case300 300 0.41 0.649 1.6x 0.212 0.415 2.0x 43648 48446 161723 312.43 1475.3x + case1354pegase 1354 0.566 1.812 3.2x 0.324 1.025 3.2x 196544 229176 514421 320.61 989.2x + case1888rte 1888 0.559 2.286 4.1x 0.436 1.393 3.2x 235928 274971 656133 326.26 748.4x + case2848rte 2848 0.698 3.265 4.7x 0.355 2.119 6.0x 355939 415935 951381 339.28 955.4x + case2869pegase 2869 0.812 4.005 4.9x 0.413 2.45 5.9x 436746 513498 1061979 346.86 839.9x + case3120sp 3120 0.598 3.311 5.5x 0.427 1.863 4.4x 334367 398073 961372 346.68 812.3x + case6495rte 6495 1.271 11.11 8.7x 0.643 5.317 8.3x 833860 981887 2140446 365.18 568.1x + case6515rte 6515 1.09 11.233 10.3x 0.66 4.668 7.1x 836436 984720 2170208 385.7 584.7x + case9241pegase 9241 1.06 18.023 17.0x 1.061 8.923 8.4x 1497483 1763983 3458084 438.61 413.6x + ================ ======== ================== ================== =============== ================== ================== ============== ================= ================= ========================== ================================ ================================ + +Two things stand out: + +- The speedup of ``save_binary`` / ``load_binary`` over pickle **grows with grid size**: at small grids + (``case14``) the two formats are roughly on par (pickle's fixed per-call overhead dominates), but at + ``case9241pegase`` (~9200 buses) ``save_binary`` is **17x** faster to write and ``load_binary`` is + **8.4x** faster to read than pickle, and the binary file is consistently 10-15% smaller. +- Reading the original pandapower json **file** (``pp.from_json`` + conversion) has a large, roughly + constant overhead (~300-440 ms) essentially independent of grid size, dominated by pandapower's own + json (de)serialization rather than by the (fast) lightsim2grid conversion itself. This makes + ``load_binary`` **several hundred to over a thousand times faster** than re-reading a pandapower file + from scratch -- the intended use case for this feature: keep a fast, lightsim2grid-native snapshot of + an already-converted grid around, instead of re-running the full pandapower/pypowsybl conversion every + time. + +.. note:: + ``load_binary`` speedups reported here are versus reading the *original source format* (pandapower + json), not versus ``init_from_pandapower`` alone: the pandapower json reader itself is the dominant + cost, not the lightsim2grid conversion. See :ref:`benchmark-grid-size` for a size scan of the raw + powerflow solver speed (a different topic than serialization). diff --git a/docs/conf.py b/docs/conf.py index 8c87c4fa..df96192a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,8 +22,8 @@ author = 'Benjamin DONNOT' # The full version, including alpha/beta/rc tags -release = "0.13.2.dev0" -version = '0.13' +release = "1.0.0.rc1" +version = '1.0' # -- General configuration --------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index c2c90917..85fa031a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,6 +62,7 @@ This is a work in progress at the moment time_series security_analysis solver_plugin + binary_serialization Indices and tables diff --git a/lightsim2grid/__init__.py b/lightsim2grid/__init__.py index 396f2691..f3049a73 100644 --- a/lightsim2grid/__init__.py +++ b/lightsim2grid/__init__.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__version__ = "0.13.2.dev0" +__version__ = "1.0.0.rc1" __all__ = [ "newtonpf", diff --git a/lightsim2grid/network/compare_lsgrid.py b/lightsim2grid/network/compare_lsgrid.py index 200edf5e..dc3a9a05 100644 --- a/lightsim2grid/network/compare_lsgrid.py +++ b/lightsim2grid/network/compare_lsgrid.py @@ -134,6 +134,17 @@ ) +ATTR_SVC_INPUT = [ + "regulation_mode", + "target_vm_pu", + "target_q_mvar", + "slope_pu", + "b_min", + "b_max", + "regulated_bus_id", +] + + ATTR_STATION_INPUT = [ "converter_type", "loss_factor", @@ -271,6 +282,17 @@ def _compare_static_generators(network1: LSGrid, network2: LSGrid, tol=1e-8): return res_sgens +def _compare_svcs(network1: LSGrid, network2: LSGrid, tol=1e-8): + res_svcs = _aux_compare( + network1, + network2, + "get_svcs", + ATTR_SVC_INPUT, + tol, + ) + return res_svcs + + def _compare_loads(network1: LSGrid, network2: LSGrid, tol=1e-8): res_loads = _aux_compare( network1, @@ -345,4 +367,7 @@ def compare_network_input(network1: LSGrid, network2: LSGrid, tol=1e-8): shunts = _compare_shunts(network1, network2, tol) if len(shunts) > 0: res["shunts"] = shunts + svcs = _compare_svcs(network1, network2, tol) + if len(svcs) > 0: + res["svcs"] = svcs return res diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index daa921b9..d676301f 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -1105,8 +1105,19 @@ def _aux_hvdc_station_data(df_side): ) for hvdc_id, (is_or_disc, is_ex_disc, line_conn1, line_conn2) in enumerate( zip(hvdc_from_disco, hvdc_to_disco, df_dc["connected1"].values, df_dc["connected2"].values)): - if is_or_disc or is_ex_disc or (not line_conn1) or (not line_conn2): + # a converter station with its own terminal open (eg its DC partner is + # switched off, or its whole substation is dead) is NOT a dead branch: real + # VSC stations (and OpenLoadFlow) keep the still-connected converter + # injecting its scheduled P / regulating Q-V as a local device. Only fully + # deactivate when BOTH stations are disconnected. + or_disc = is_or_disc or (not line_conn1) + ex_disc = is_ex_disc or (not line_conn2) + if or_disc and ex_disc: model.deactivate_dcline(hvdc_id) + elif or_disc: + model.deactivate_dcline_side1(hvdc_id) + elif ex_disc: + model.deactivate_dcline_side2(hvdc_id) model.set_dcline_names(df_dc.index) # storage units (IIDM batteries). IIDM gives the battery setpoints in the diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py new file mode 100644 index 00000000..150c1cee --- /dev/null +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -0,0 +1,246 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import struct +import tempfile +import os +import unittest +import warnings + +import numpy as np + +import grid2op +from lightsim2grid.lightSimBackend import LightSimBackend +from lightsim2grid.network.compare_lsgrid import ( + compare_network_input, + _compare_loads, + _compare_lines, + _compare_generators, + _compare_shunts, + _compare_storages, + _compare_substations, + _compare_trafos, + _compare_static_generators, + _compare_dclines, + _compare_svcs) + + +class TestBinarySerialization(unittest.TestCase): + """Round-trip tests for LSGrid.save_binary / LSGrid.load_binary, the fast + additive alternative to pickle. Mirrors the structure of test_pickleable.py + (same fixture, same comparison helpers), so pickle and binary stay in sync + behaviourally even though they are separate, independent code paths. + """ + + def _aux_test_2sides(self, grid1, grid2, method_name, test_results=False): + assert len(getattr(grid1, method_name)()) == len(getattr(grid2, method_name)()) + for line1, line2 in zip(getattr(grid1, method_name)(), getattr(grid2, method_name)()): + for attr_nm in ["id", "name", "sub1_id", "sub2_id", + "pos1_topo_vect", "pos2_topo_vect", + "connected_global", "connected1", "connected2", + "bus1_id", "bus2_id", "has_res", + ]: + assert getattr(line1, attr_nm) == getattr(line2, attr_nm), f"{method_name} error for {attr_nm}: {getattr(line1, attr_nm)} vs {getattr(line2, attr_nm)}" + + for attr_nm in ["r_pu", "x_pu", "h1_pu", "h2_pu", + "yac_11", "yac_12", "yac_21", "yac_22", + "ydc_11", "ydc_12", "ydc_21", "ydc_22", + ]: + assert np.allclose(getattr(line1, attr_nm), getattr(line2, attr_nm)), f"{method_name} error for {attr_nm}: {getattr(line1, attr_nm)} vs {getattr(line2, attr_nm)}" + + if test_results: + for attr_nm in ["res_p1_mw", "res_q1_mvar", "res_theta1_deg", "res_v1_kv", "res_a1_ka", + "res_p2_mw", "res_q2_mvar", "res_theta2_deg", "res_v2_kv", "res_a2_ka"]: + assert np.allclose(getattr(line1, attr_nm), getattr(line2, attr_nm)), f"{method_name} error for {attr_nm}: {getattr(line1, attr_nm)} vs {getattr(line2, attr_nm)}" + + def _aux_test_1side(self, grid1, grid2, method_name, test_results=False, + add_attr_int=None, + add_attr_float=None): + li_attr_to_test_int = [ + "id", "name", "sub_id", + "pos_topo_vect", + "connected", + "bus_id", + ] + if add_attr_int is not None: + li_attr_to_test_int += add_attr_int + + li_attr_to_test_float = [] + if add_attr_float is not None: + li_attr_to_test_float += add_attr_float + assert len(getattr(grid1, method_name)()) == len(getattr(grid2, method_name)()) + for line1, line2 in zip(getattr(grid1, method_name)(), getattr(grid2, method_name)()): + for attr_nm in li_attr_to_test_int: + assert getattr(line1, attr_nm) == getattr(line2, attr_nm), f"{method_name} error for {attr_nm}: {getattr(line1, attr_nm)} vs {getattr(line2, attr_nm)}" + + for attr_nm in li_attr_to_test_float: + assert np.allclose(getattr(line1, attr_nm), getattr(line2, attr_nm)), f"{method_name} error for {attr_nm}: {getattr(line1, attr_nm)} vs {getattr(line2, attr_nm)}" + + if not test_results: + continue + li_attr_to_test = ["res_p_mw", "res_q_mvar", "res_theta_deg", "res_v_kv"] + for attr_nm in li_attr_to_test: + if not np.allclose(getattr(line1, attr_nm), getattr(line2, attr_nm)): + diff_ = np.abs(getattr(line1, attr_nm) - getattr(line2, attr_nm)) + raise AssertionError(f"{method_name} error for {attr_nm} for gen id {line1.id} ({line1.name}): {getattr(line1, attr_nm)} " + f"vs {getattr(line2, attr_nm)} -> {diff_}") + + def aux_test_2sides(self, grid1, grid2, test_results=False): + self._aux_test_2sides(grid1, grid2, "get_lines", test_results) + self._aux_test_2sides(grid1, grid2, "get_trafos", test_results) + + def aux_test_1side(self, grid1, grid2, test_results=False): + self._aux_test_1side(grid1, grid2, "get_loads", test_results) + self._aux_test_1side(grid1, grid2, "get_generators", test_results, + add_attr_int=["is_slack", "voltage_regulator_on"], + add_attr_float=["slack_weight", "target_vm_pu", "min_q_mvar", "max_q_mvar"]) + self._aux_test_1side(grid1, grid2, "get_storages", test_results) + self._aux_test_1side(grid1, grid2, "get_shunts", test_results) + + def test_save_load(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_binary.lsb") + grid = self.env.backend._grid + grid.save_binary(path) + grid_1 = type(grid).load_binary(path) + + assert grid_1.get_algo_type() == grid.get_algo_type() + assert grid_1.get_dc_solver_type() == grid.get_dc_solver_type() + + self.aux_test_2sides(grid, grid_1) + self.aux_test_1side(grid, grid_1) + tmp = compare_network_input(grid, grid_1) + assert len(tmp) == 0 + + nb_bus_total = self.env.n_sub * 2 + max_it = 10 + tol = 1e-8 + + # test dc_pf + V_0 = np.ones(nb_bus_total, dtype=complex) + V_1 = V_0.copy() + V_0 = grid.dc_pf(V_0, max_it, tol) + V_1 = grid_1.dc_pf(V_1, max_it, tol) + + assert np.all(np.abs(V_0 - V_1) <= 1e-7), "dc pf does not lead to same results" + self.aux_test_2sides(grid, grid_1, True) + self.aux_test_1side(grid, grid_1, True) + + # test ac_pf + V_0 = grid.ac_pf(V_0, max_it, tol) + V_1 = grid_1.ac_pf(V_1, max_it, tol) + assert np.all(np.abs(V_0 - V_1) <= 1e-7), "ac pf does not lead to same results" + self.aux_test_2sides(grid, grid_1, True) + self.aux_test_1side(grid, grid_1, True) + + def _aux_test_binary(self, fun_name, fun_comp): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + els = getattr(self.env.backend._grid, fun_name)() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, f"test_binary_{fun_name}.lsb") + els.save_binary(path) + els_reloaded = type(els).load_binary(path) + + class Struct: + pass + setattr(Struct, fun_name, lambda self: els_reloaded) + diff_ = fun_comp(Struct(), self.env.backend._grid) + assert len(diff_) == 0 + + def test_binary_loads(self): + self._aux_test_binary("get_loads", _compare_loads) + + def test_binary_lines(self): + self._aux_test_binary("get_lines", _compare_lines) + + def test_binary_trafos(self): + self._aux_test_binary("get_trafos", _compare_trafos) + + def test_binary_storages(self): + self._aux_test_binary("get_storages", _compare_storages) + + def test_binary_generators(self): + self._aux_test_binary("get_generators", _compare_generators) + + def test_binary_shunts(self): + self._aux_test_binary("get_shunts", _compare_shunts) + + def test_binary_substations(self): + self._aux_test_binary("get_substations", _compare_substations) + + def test_binary_sgens(self): + self._aux_test_binary("get_static_generators", _compare_static_generators) + + def test_binary_hvdc(self): + self._aux_test_binary("get_dclines", _compare_dclines) + + def test_binary_svcs(self): + self._aux_test_binary("get_svcs", _compare_svcs) + + def test_cannot_load_wrong_version(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_binary.lsb") + grid.save_binary(path) + with open(path, "rb") as f: + data = bytearray(f.read()) + + # file layout: 4-byte magic "LSB1", then 3 length-prefixed strings + # (uint32 little-endian length + raw bytes): major, medium, minor + # version. Doctor the major-version bytes so they cannot match the + # currently installed lightsim2grid version. + maj_len = struct.unpack(" +#include "BinaryArchive.hpp" + +namespace py = pybind11; + +// Helper: attach save_binary()/load_binary() python methods to any container +// that exposes get_state()/set_state() and a nested StateRes type (the same +// contract used by add_pickle in pickle_helpers.hpp). This is an additive, +// faster alternative to pickle -- NOT a replacement, and NOT cross-version +// compatible (a version mismatch is a hard failure, see BinaryArchive.hpp). +// +// These lambdas call ls2g::save_binary_generic/load_binary_generic directly +// (rather than T::save_binary/T::load_binary): VERSION_MAJOR/MEDIUM/MINOR are +// only defined via target_compile_definitions on the python bindings target, +// not on lightsim2grid_core, so this is the single, consistent translation +// unit where those macros carry the real version (mirrors pickle_helpers.hpp). +template +void add_binary_serialization(py::class_& cls) { + cls.def("save_binary", [](const T& obj, const std::string& path) { + ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); + }, py::arg("path"), + "Save this object's state to a fast custom binary file (additive alternative " + "to pickle: faster, but NOT portable across lightsim2grid versions)."); + cls.def_static("load_binary", [](const std::string& path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); + }, py::arg("path"), + "Load an object previously saved with save_binary(). Raises RuntimeError on " + "a version mismatch or a corrupted / truncated file."); +} + +#endif // BINARY_HELPERS_HPP diff --git a/src/bindings/python/binding_containers.cpp b/src/bindings/python/binding_containers.cpp index 29eb41e7..16ddd212 100644 --- a/src/bindings/python/binding_containers.cpp +++ b/src/bindings/python/binding_containers.cpp @@ -8,6 +8,7 @@ #include "binding_declarations.hpp" #include "pickle_helpers.hpp" +#include "binary_helpers.hpp" #include "element_container/GeneratorContainer.hpp" #include "element_container/SGenContainer.hpp" #include "element_container/SvcContainer.hpp" @@ -31,6 +32,7 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()) .def("get_bus_id", &GeneratorContainer::get_bus_id_numpy, "TODO doc", py::keep_alive<0, 1>()); add_pickle(gen_cls, "GeneratorContainer"); + add_binary_serialization(gen_cls); py::class_(m, "GenInfo", DocIterator::GenInfo.c_str()) .def_readonly("id", &GenInfo::id, DocIterator::id.c_str()) @@ -62,6 +64,7 @@ void bind_containers(py::module_& m) { return py::make_iterator(data.begin(), data.end()); }, py::keep_alive<0, 1>()); add_pickle(svc_cls, "SvcContainer"); + add_binary_serialization(svc_cls); py::class_(m, "SvcInfo", "Information about one Static Var Compensator (SVC).") .def_readonly("id", &SvcInfo::id, DocIterator::id.c_str()) @@ -92,6 +95,7 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()) .def("get_bus_id", &SGenContainer::get_bus_id_numpy, "TODO doc", py::keep_alive<0, 1>()); add_pickle(sgen_cls, "SGenContainer"); + add_binary_serialization(sgen_cls); py::class_(m, "SGenInfo", DocIterator::SGenInfo.c_str()) .def_readonly("id", &SGenInfo::id, DocIterator::id.c_str()) @@ -121,6 +125,7 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()) .def("get_bus_id", &LoadContainer::get_bus_id_numpy, "TODO doc", py::keep_alive<0, 1>()); add_pickle(load_cls, "LoadContainer"); + add_binary_serialization(load_cls); py::class_(m, "LoadInfo", DocIterator::LoadInfo.c_str()) .def_readonly("id", &LoadInfo::id, DocIterator::id.c_str()) @@ -148,6 +153,7 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()) .def("get_bus_id", &StorageContainer::get_bus_id_numpy, "TODO doc", py::keep_alive<0, 1>()); add_pickle(storage_cls, "StorageContainer"); + add_binary_serialization(storage_cls); py::class_(m, "StorageInfo", DocIterator::LoadInfo.c_str()) .def_readonly("id", &StorageInfo::id, DocIterator::id.c_str()) @@ -173,6 +179,7 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()) .def("get_bus_id", &ShuntContainer::get_bus_id_numpy, "TODO doc", py::keep_alive<0, 1>()); add_pickle(shunt_cls, "ShuntContainer"); + add_binary_serialization(shunt_cls); py::class_(m, "ShuntInfo", DocIterator::ShuntInfo.c_str()) .def_readonly("id", &ShuntInfo::id, DocIterator::id.c_str()) @@ -208,6 +215,7 @@ void bind_containers(py::module_& m) { .def("get_yac_eff_21", [](const TrafoContainer & t) -> Eigen::Ref { return t.yac_eff_21(); }, "TODO doc", py::keep_alive<0, 1>()) .def("get_yac_eff_22", [](const TrafoContainer & t) -> Eigen::Ref { return t.yac_eff_22(); }, "TODO doc", py::keep_alive<0, 1>()); add_pickle(trafo_cls, "TrafoContainer"); + add_binary_serialization(trafo_cls); py::class_(m, "TrafoInfo", DocIterator::TrafoInfo.c_str()) .def_readonly("id", &TrafoInfo::id, DocIterator::id.c_str()) @@ -267,6 +275,7 @@ void bind_containers(py::module_& m) { .def("get_yac_eff_21", [](const LineContainer & l) -> Eigen::Ref { return l.yac_eff_21(); }, "TODO doc", py::keep_alive<0, 1>()) .def("get_yac_eff_22", [](const LineContainer & l) -> Eigen::Ref { return l.yac_eff_22(); }, "TODO doc", py::keep_alive<0, 1>()); add_pickle(line_cls, "LineContainer"); + add_binary_serialization(line_cls); py::class_(m, "LineInfo", DocIterator::LineInfo.c_str()) .def_readonly("id", &LineInfo::id, DocIterator::id.c_str()) @@ -342,6 +351,7 @@ void bind_containers(py::module_& m) { .def("get_bus_id_side_1", &HvdcLineContainer::get_bus_id_side_1_numpy) .def("get_bus_id_side_2", &HvdcLineContainer::get_bus_id_side_2_numpy); add_pickle(dcline_cls, "HvdcLineContainer"); + add_binary_serialization(dcline_cls); py::class_(m, "HvdcLineInfo", DocIterator::DCLineInfo.c_str()) .def_readonly("id", &HvdcLineInfo::id, DocIterator::id.c_str()) @@ -392,6 +402,7 @@ void bind_containers(py::module_& m) { return py::make_iterator(data.begin(), data.end()); }, py::keep_alive<0, 1>()); add_pickle(sub_cls, "SubstationContainer"); + add_binary_serialization(sub_cls); py::class_(m, "SubstationInfo", "TODO") .def_readonly("id", &SubstationInfo::id, DocIterator::id.c_str()) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 176d271b..e1e0a9bb 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -8,6 +8,7 @@ #include "binding_declarations.hpp" #include "pickle_helpers.hpp" +#include "binary_helpers.hpp" #include "LSGrid.hpp" #include "help_fun_msg.hpp" @@ -46,6 +47,7 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def_property_readonly("timer_last_ac_pf", &LSGrid::timer_last_ac_pf, "TODO") .def_property_readonly("timer_last_dc_pf", &LSGrid::timer_last_dc_pf, "TODO"); add_pickle(lsgrid_cls, "LSGrid"); + add_binary_serialization(lsgrid_cls); lsgrid_cls // algo config (scaling/refactor policy params) .def("get_ac_algo_config", &LSGrid::get_ac_algo_config, @@ -248,6 +250,8 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("change_q_storage", &LSGrid::change_q_storage, DocLSGrid::_internal_do_not_use.c_str()) .def("deactivate_dcline", &LSGrid::deactivate_dcline, DocLSGrid::_internal_do_not_use.c_str()) + .def("deactivate_dcline_side1", &LSGrid::deactivate_dcline_side1, "Disconnect only converter station 1 of an HVDC line; station 2 stays active (injecting / regulating).") + .def("deactivate_dcline_side2", &LSGrid::deactivate_dcline_side2, "Disconnect only converter station 2 of an HVDC line; station 1 stays active (injecting / regulating).") .def("reactivate_dcline", &LSGrid::reactivate_dcline, DocLSGrid::_internal_do_not_use.c_str()) .def("change_p_dcline", &LSGrid::change_p_dcline, DocLSGrid::_internal_do_not_use.c_str()) .def("change_v1_dcline", &LSGrid::change_v1_dcline, DocLSGrid::_internal_do_not_use.c_str()) diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp new file mode 100644 index 00000000..c9cc2efd --- /dev/null +++ b/src/core/BinaryArchive.cpp @@ -0,0 +1,156 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#include "BinaryArchive.hpp" + +#include +#include +#include + +namespace ls2g { + +namespace { +const char MAGIC[4] = {'L', 'S', 'B', '1'}; +} // anonymous namespace + +BinaryArchive::BinaryArchive(const std::string & path, Mode mode): + mode_(mode), + path_(path) +{ + if (mode_ == Mode::Write) { + ofs_.open(path_, std::ios::binary | std::ios::out | std::ios::trunc); + if (!ofs_.is_open()) { + throw std::runtime_error("BinaryArchive: cannot open file for writing: '" + path_ + "'"); + } + } else { + ifs_.open(path_, std::ios::binary | std::ios::in); + if (!ifs_.is_open()) { + throw std::runtime_error("BinaryArchive: cannot open file for reading: '" + path_ + "'"); + } + } +} + +BinaryArchive::~BinaryArchive() = default; + +void BinaryArchive::write_raw(const void * data, std::size_t nbytes) +{ + if (nbytes == 0) return; + ofs_.write(static_cast(data), static_cast(nbytes)); + if (!ofs_) { + throw std::runtime_error("BinaryArchive: failed to write to file '" + path_ + "'"); + } +} + +void BinaryArchive::read_raw(void * data, std::size_t nbytes) +{ + if (nbytes == 0) return; + ifs_.read(static_cast(data), static_cast(nbytes)); + if (!ifs_) { + throw std::runtime_error( + "BinaryArchive: unexpected end of file (or read error) while reading '" + path_ + + "'. The file is likely truncated or corrupted."); + } +} + +void BinaryArchive::write_magic() +{ + write_raw(MAGIC, sizeof(MAGIC)); +} + +void BinaryArchive::check_magic() +{ + char buf[sizeof(MAGIC)]; + read_raw(buf, sizeof(buf)); + if (std::memcmp(buf, MAGIC, sizeof(MAGIC)) != 0) { + throw std::runtime_error( + "BinaryArchive: invalid file '" + path_ + + "': magic number mismatch. This is not a lightsim2grid binary file, or it is corrupted."); + } +} + +void BinaryArchive::write_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor) +{ + write_magic(); + write_string(v_major); + write_string(v_medium); + write_string(v_minor); +} + +void BinaryArchive::check_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor) +{ + check_magic(); + std::string file_major, file_medium, file_minor; + read_string(file_major); + read_string(file_medium); + read_string(file_minor); + if (file_major != v_major || file_medium != v_medium || file_minor != v_minor) { + std::ostringstream oss; + oss << "BinaryArchive: version mismatch when loading '" << path_ << "'. " + << "This file was saved with lightsim2grid version " << file_major << "." << file_medium << "." << file_minor + << ", but the currently installed version is " << v_major << "." << v_medium << "." << v_minor + << ". Binary files can only be reloaded with the exact same lightsim2grid version they were saved with " + << "(unlike pickle, this format does not attempt any cross-version migration)."; + throw std::runtime_error(oss.str()); + } +} + +void BinaryArchive::write_string(const std::string & s) +{ + std::uint32_t len = static_cast(s.size()); + write_scalar(len); + if (len) write_raw(s.data(), len); +} + +void BinaryArchive::read_string(std::string & s) +{ + std::uint32_t len = 0; + read_scalar(len); + s.resize(len); + if (len) read_raw(&s[0], len); +} + +void BinaryArchive::write_bool_vector(const std::vector & v) +{ + std::uint64_t n = static_cast(v.size()); + write_scalar(n); + std::vector buf(v.size()); + for (std::size_t i = 0; i < v.size(); ++i) buf[i] = v[i] ? 1 : 0; + if (!buf.empty()) write_raw(buf.data(), buf.size()); +} + +void BinaryArchive::read_bool_vector(std::vector & v) +{ + std::uint64_t n = 0; + read_scalar(n); + std::vector buf(static_cast(n)); + if (!buf.empty()) read_raw(buf.data(), buf.size()); + v.resize(static_cast(n)); + for (std::size_t i = 0; i < buf.size(); ++i) v[i] = (buf[i] != 0); +} + +void BinaryArchive::write_string_vector(const std::vector & v) +{ + std::uint64_t n = static_cast(v.size()); + write_scalar(n); + for (const auto & s : v) write_string(s); +} + +void BinaryArchive::read_string_vector(std::vector & v) +{ + std::uint64_t n = 0; + read_scalar(n); + v.clear(); + v.reserve(static_cast(n)); + for (std::uint64_t i = 0; i < n; ++i) { + std::string s; + read_string(s); + v.push_back(std::move(s)); + } +} + +} // namespace ls2g diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp new file mode 100644 index 00000000..c376ea3f --- /dev/null +++ b/src/core/BinaryArchive.hpp @@ -0,0 +1,274 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef BINARYARCHIVE_H +#define BINARYARCHIVE_H + +// Fast, additive binary serialization for any class exposing a `StateRes` +// tuple + get_state()/set_state() (the same contract already used for +// python pickle support, see pickle_helpers.hpp). This is NOT meant to +// replace pickle: it trades portability for speed (no python-level +// marshalling, raw contiguous writes for vector data). +// +// C++14 only (see project policy, eg LSGrid.hpp): no `if constexpr`, no +// `std::apply`, no fold expressions. The recursive walk over an arbitrary +// StateRes tuple is done via partial-specialization of the `ValueArchiver` +// class template (not free-function SFINAE overloads): class template +// partial specialization resolution does not depend on declaration order +// or two-phase-lookup/ADL subtleties the way a set of mutually-recursive +// free function template overloads would, which matters here since a +// StateRes tuple can nest other StateRes tuples several levels deep. + +#include +#include +#include +#include +#include +#include +#include + +#include "ls2g_api.hpp" +#include "Utils.hpp" // real_type, cplx_type + +namespace ls2g { + +class LS2G_API BinaryArchive +{ + public: + enum class Mode { Write, Read }; + + BinaryArchive(const std::string & path, Mode mode); + ~BinaryArchive(); + BinaryArchive(const BinaryArchive &) = delete; + BinaryArchive & operator=(const BinaryArchive &) = delete; + + // low level: the only methods that touch the underlying stream. + // Both throw std::runtime_error (including the file path) on any + // stream failure, which uniformly covers "file does not exist", + // "corrupted file" and "truncated file" (unexpected EOF) cases. + void write_raw(const void * data, std::size_t nbytes); + void read_raw(void * data, std::size_t nbytes); + + // magic number ("LSB1"), to catch garbage files early + void write_magic(); + void check_magic(); // throws std::runtime_error on mismatch + + // magic + 3 length-prefixed version strings. Version strings are + // passed in explicitly by the caller (never read from the + // VERSION_MAJOR/MEDIUM/MINOR macros in this shared header): those + // macros are only defined via target_compile_definitions on the + // python bindings target, not on lightsim2grid_core, so referencing + // them here would silently embed different values (even risk an ODR + // violation) depending on which translation unit instantiates this + // header first. Callers (each container's own .cpp, or + // binary_helpers.hpp on the bindings side) supply the macros + // themselves, each from a single, consistent translation unit. + void write_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor); + void check_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor); // throws std::runtime_error on any mismatch + + void write_string(const std::string & s); + void read_string(std::string & s); + + // std::vector is bit-packed (not contiguous): converts to/from + // std::vector (1 byte per bool) rather than touching raw + // storage. + void write_bool_vector(const std::vector & v); + void read_bool_vector(std::vector & v); + + // count + length-prefixed strings + void write_string_vector(const std::vector & v); + void read_string_vector(std::vector & v); + + // any trivially-copyable scalar (arithmetic incl. bool, or cplx_type) + template + void write_scalar(const T & v) { write_raw(&v, sizeof(T)); } + template + void read_scalar(T & v) { read_raw(&v, sizeof(T)); } + + // std::vector of a trivially-copyable, contiguous element type + // (real_type, int, cplx_type, ...): count + raw bytes straight from + // / into .data(), no per-element conversion. + template + void write_vector_raw(const std::vector & v) { + std::uint64_t n = static_cast(v.size()); + write_scalar(n); + if (n) write_raw(v.data(), static_cast(n) * sizeof(T)); + } + template + void read_vector_raw(std::vector & v) { + std::uint64_t n = 0; + read_scalar(n); + v.resize(static_cast(n)); + if (n) read_raw(v.data(), static_cast(n) * sizeof(T)); + } + + private: + std::ofstream ofs_; + std::ifstream ifs_; + Mode mode_; + std::string path_; +}; + +namespace archive_detail { + +// C++14 substitute for std::void_t (C++17) +template struct make_void { using type = void; }; +template using void_t = typename make_void::type; + +// detects any std::tuple<...> (or more generally any type with a +// std::tuple_size specialization) via the standard pre-C++17 +// detection-idiom pattern. +template +struct is_tuple_like : std::false_type {}; +template +struct is_tuple_like::value)> > : std::true_type {}; + +// element types safe to write/read as raw contiguous bytes inside a +// std::vector (excludes bool: std::vector is bit-packed and has +// no .data(), and is handled by BinaryArchive::write_bool_vector instead). +template +struct is_raw_vector_elem : std::integral_constant::value && !std::is_same::value) || std::is_same::value> {}; + +// ---- ValueArchiver: recursive dispatcher, one partial specialization per +// StateRes field shape actually found in the codebase. Adding a field type +// not covered here leaves the primary (undefined) template selected, which +// fails to compile loudly rather than silently mis-serializing. +template +struct ValueArchiver; // intentionally incomplete: every real field type below has a specialization + +// arithmetic scalars (real_type, int, bool, ...) +template +struct ValueArchiver::value>::type> { + static void write(BinaryArchive & ar, const T & v) { ar.write_scalar(v); } + static void read(BinaryArchive & ar, T & v) { ar.read_scalar(v); } +}; + +// cplx_type (std::complex), standard-guaranteed layout +// compatible with real_type[2] -- safe as a raw scalar for a same-build +// round trip (no cross-platform guarantee needed, consistent with the +// hard version-mismatch policy below). +template<> +struct ValueArchiver { + static void write(BinaryArchive & ar, const cplx_type & v) { ar.write_scalar(v); } + static void read(BinaryArchive & ar, cplx_type & v) { ar.read_scalar(v); } +}; + +// enum types (eg AlgorithmType): stored as their underlying integer type +template +struct ValueArchiver::value>::type> { + using Under = typename std::underlying_type::type; + static void write(BinaryArchive & ar, const T & v) { ar.write_scalar(static_cast(v)); } + static void read(BinaryArchive & ar, T & v) { Under u{}; ar.read_scalar(u); v = static_cast(u); } +}; + +template<> +struct ValueArchiver { + static void write(BinaryArchive & ar, const std::string & v) { ar.write_string(v); } + static void read(BinaryArchive & ar, std::string & v) { ar.read_string(v); } +}; + +template<> +struct ValueArchiver > { + static void write(BinaryArchive & ar, const std::vector & v) { ar.write_bool_vector(v); } + static void read(BinaryArchive & ar, std::vector & v) { ar.read_bool_vector(v); } +}; + +template<> +struct ValueArchiver > { + static void write(BinaryArchive & ar, const std::vector & v) { ar.write_string_vector(v); } + static void read(BinaryArchive & ar, std::vector & v) { ar.read_string_vector(v); } +}; + +// std::vector> (only TrafoContainer's rx_corr_alpha/pct): +// outer count, then each inner vector via the raw-vector path. +template +struct ValueArchiver > > { + static void write(BinaryArchive & ar, const std::vector > & v) { + std::uint64_t n = static_cast(v.size()); + ar.write_scalar(n); + for (const auto & inner : v) ValueArchiver >::write(ar, inner); + } + static void read(BinaryArchive & ar, std::vector > & v) { + std::uint64_t n = 0; + ar.read_scalar(n); + v.resize(static_cast(n)); + for (auto & inner : v) ValueArchiver >::read(ar, inner); + } +}; + +// std::vector of raw-copyable T (real_type, int, cplx_type, ...) +template +struct ValueArchiver, typename std::enable_if::value>::type> { + static void write(BinaryArchive & ar, const std::vector & v) { ar.write_vector_raw(v); } + static void read(BinaryArchive & ar, std::vector & v) { ar.read_vector_raw(v); } +}; + +// any std::tuple<...> (a StateRes, possibly nesting other StateRes tuples): +// recurse field-by-field via the index_sequence/dummy-array fold idiom +// already used in NRSystem.hpp (C++14-safe, no std::apply). +template +struct ValueArchiver::value>::type> { + template + static void write_impl(BinaryArchive & ar, const Tuple & t, std::index_sequence) { + int dummy[] = { 0, (ValueArchiver::type>::write(ar, std::get(t)), 0)... }; + (void)dummy; + } + template + static void read_impl(BinaryArchive & ar, Tuple & t, std::index_sequence) { + int dummy[] = { 0, (ValueArchiver::type>::read(ar, std::get(t)), 0)... }; + (void)dummy; + } + static void write(BinaryArchive & ar, const Tuple & t) { + write_impl(ar, t, std::make_index_sequence::value>{}); + } + static void read(BinaryArchive & ar, Tuple & t) { + read_impl(ar, t, std::make_index_sequence::value>{}); + } +}; + +} // namespace archive_detail + +// Public entry points into the dispatcher above. +template +void archive_write_value(BinaryArchive & ar, const T & v) { + archive_detail::ValueArchiver::type>::write(ar, v); +} +template +void archive_read_value(BinaryArchive & ar, T & v) { + archive_detail::ValueArchiver::type>::read(ar, v); +} + +// Top-level helpers reused by every serializable class's save_binary()/ +// load_binary() (LSGrid + every element container with its own StateRes) -- +// the single shared implementation the per-class methods delegate to, so +// none of them duplicate any serialization logic. +template +void save_binary_generic(const T & obj, const std::string & path, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { + BinaryArchive ar(path, BinaryArchive::Mode::Write); + ar.write_header(v_major, v_medium, v_minor); + typename T::StateRes state = obj.get_state(); + archive_write_value(ar, state); +} + +template +T load_binary_generic(const std::string & path, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { + BinaryArchive ar(path, BinaryArchive::Mode::Read); + ar.check_header(v_major, v_medium, v_minor); + typename T::StateRes state{}; + archive_read_value(ar, state); + T res{}; + res.set_state(state); + return res; +} + +} // namespace ls2g + +#endif // BINARYARCHIVE_H diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 2d2fd902..708eeb74 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -276,6 +276,7 @@ add_library(lightsim2grid_core SHARED "${CMAKE_CURRENT_SOURCE_DIR}/Utils.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/DataConverter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/SubstationContainer.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/BinaryArchive.cpp" ${SOURCES} ) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 6b8f0232..aef13984 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -8,6 +8,7 @@ #include "LSGrid.hpp" #include "AlgorithmSelector.hpp" // to avoid circular references +#include "BinaryArchive.hpp" #include @@ -193,6 +194,14 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) _dc_algo.change_algorithm(std::get<17>(my_state)); }; +void LSGrid::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +LSGrid LSGrid::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + void LSGrid::set_ls_to_orig(const IntVect & ls_to_orig){ if(ls_to_orig.size() == 0){ _ls_to_orig = IntVect(); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 852ad9cc..85391779 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -460,6 +460,12 @@ class LS2G_API LSGrid final LSGrid::StateRes get_state() const ; void set_state(LSGrid::StateRes & my_state) ; + // fast binary serialization (additive alternative to pickle, see + // BinaryArchive.hpp -- NOT cross-version compatible, same lightsim2grid + // version required to reload) + void save_binary(const std::string & path) const; + static LSGrid load_binary(const std::string & path); + // algo config (scaling/refactor policy params) — not part of StateRes pickle AlgoConfig get_ac_algo_config() const { return _algo.get_config(); } void set_ac_algo_config(const AlgoConfig& cfg) { _algo.set_config(cfg); } @@ -883,6 +889,14 @@ class LS2G_API LSGrid final //deactivate a powerline (disconnect it) void deactivate_dcline(int dcline_id) {hvdc_lines_.deactivate(dcline_id, algo_controler_); } void reactivate_dcline(int dcline_id) {hvdc_lines_.reactivate(dcline_id, algo_controler_); } + // Disconnect only one converter station of an HVDC line ("half-open"): the + // other station keeps injecting its scheduled P / regulating Q-V, matching + // OpenLoadFlow which treats a VSC station with its DC partner switched off as + // a still-active local reactive/voltage-support device (not a dead branch). + // Unlike powerlines/trafos this is unconditional (HvdcLineContainer's + // synch_status_both_side_ defaults to false), no `keep_half_open_lines` needed. + void deactivate_dcline_side1(int dcline_id) {hvdc_lines_.deactivate_side_1(dcline_id, algo_controler_); } + void deactivate_dcline_side2(int dcline_id) {hvdc_lines_.deactivate_side_2(dcline_id, algo_controler_); } void change_p_dcline(int dcline_id, real_type new_p) {hvdc_lines_.change_p(dcline_id, new_p, algo_controler_); } void change_v1_dcline(int dcline_id, real_type new_v_pu) {hvdc_lines_.change_v_side_1(dcline_id, new_v_pu, algo_controler_); } void change_v2_dcline(int dcline_id, real_type new_v_pu) {hvdc_lines_.change_v_side_2(dcline_id, new_v_pu, algo_controler_); } diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 61a298f0..864013f7 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "SubstationContainer.hpp" +#include "BinaryArchive.hpp" #include #include @@ -48,4 +49,12 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) sub_names_ = std::get<5>(my_state); } +void SubstationContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +SubstationContainer SubstationContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 678ac785..677439fe 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -67,6 +67,10 @@ class LS2G_API SubstationContainer final : public IteratorAdder #include @@ -595,4 +596,12 @@ void GeneratorContainer::update_slack_weights_by_id( } } +void GeneratorContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +GeneratorContainer GeneratorContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 85aa63cc..400b0afe 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -91,6 +91,10 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter // pickle GeneratorContainer::StateRes get_state() const; void set_state(GeneratorContainer::StateRes & my_state ); + + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) + void save_binary(const std::string & path) const; + static GeneratorContainer load_binary(const std::string & path); // slack handling /** diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index 3e2abe9a..9d652f20 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "HvdcLineContainer.hpp" +#include "BinaryArchive.hpp" #include #include @@ -445,4 +446,12 @@ void HvdcLineContainer::compute_results(const Eigen::Ref & Va, } } +void HvdcLineContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +HvdcLineContainer HvdcLineContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index d6c94dff..1f19b595 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -99,7 +99,13 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer @@ -72,4 +73,12 @@ void LineContainer::init(const RealVect & branch_r, reset_results(); } +void LineContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +LineContainer LineContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/LineContainer.hpp b/src/core/element_container/LineContainer.hpp index 779d9098..14fdd686 100644 --- a/src/core/element_container/LineContainer.hpp +++ b/src/core/element_container/LineContainer.hpp @@ -75,7 +75,11 @@ class LS2G_API LineContainer final: public TwoSidesContainer_rxh_A & Va, const Eigen::Ref & Vm, const Eigen::Ref & V, diff --git a/src/core/element_container/LoadContainer.cpp b/src/core/element_container/LoadContainer.cpp index e9ef8041..980c9ef0 100644 --- a/src/core/element_container/LoadContainer.cpp +++ b/src/core/element_container/LoadContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "LoadContainer.hpp" +#include "BinaryArchive.hpp" #include #include @@ -59,4 +60,12 @@ void LoadContainer::fillSbus(CplxVect & Sbus, } } +void LoadContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +LoadContainer LoadContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 39f119b8..1b80ca8f 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -61,6 +61,10 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA // pickle (python) LoadContainer::StateRes get_state() const; void set_state(LoadContainer::StateRes & my_state); + + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) + void save_binary(const std::string & path) const; + static LoadContainer load_binary(const std::string & path); void init(const RealVect & load_p_mw, const RealVect & load_q_mvar, diff --git a/src/core/element_container/SGenContainer.cpp b/src/core/element_container/SGenContainer.cpp index 3cbe0b23..34623304 100644 --- a/src/core/element_container/SGenContainer.cpp +++ b/src/core/element_container/SGenContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "SGenContainer.hpp" +#include "BinaryArchive.hpp" #include @@ -97,4 +98,12 @@ void SGenContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to } } +void SGenContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +SGenContainer SGenContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index cea17e90..d74f5eea 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -69,6 +69,10 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA // pickle (python) SGenContainer::StateRes get_state() const; void set_state(SGenContainer::StateRes & my_state ); + + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) + void save_binary(const std::string & path) const; + static SGenContainer load_binary(const std::string & path); void init(const RealVect & sgen_p, diff --git a/src/core/element_container/ShuntContainer.cpp b/src/core/element_container/ShuntContainer.cpp index a219ea6f..96f6cc3d 100644 --- a/src/core/element_container/ShuntContainer.cpp +++ b/src/core/element_container/ShuntContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "ShuntContainer.hpp" +#include "BinaryArchive.hpp" #include @@ -167,4 +168,12 @@ void ShuntContainer::_compute_results(const Eigen::Ref & Va, } } +void ShuntContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +ShuntContainer ShuntContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index ef0364f9..cb4d0e34 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -64,6 +64,10 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator // pickle (python) ShuntContainer::StateRes get_state() const; void set_state(ShuntContainer::StateRes & my_state ); + + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) + void save_binary(const std::string & path) const; + static ShuntContainer load_binary(const std::string & path); virtual void fillYbus(std::vector > & res, bool ac, diff --git a/src/core/element_container/StorageContainer.cpp b/src/core/element_container/StorageContainer.cpp index 9b0fd87e..feeb61c9 100644 --- a/src/core/element_container/StorageContainer.cpp +++ b/src/core/element_container/StorageContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "StorageContainer.hpp" +#include "BinaryArchive.hpp" #include #include @@ -60,4 +61,12 @@ void StorageContainer::fillSbus(CplxVect & Sbus, } } +void StorageContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +StorageContainer StorageContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index 67af9d8d..9e99196d 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -64,6 +64,10 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat StorageContainer::StateRes get_state() const; void set_state(StorageContainer::StateRes & my_state); + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) + void save_binary(const std::string & path) const; + static StorageContainer load_binary(const std::string & path); + void init(const RealVect & storage_p_mw, const RealVect & storage_q_mvar, const Eigen::VectorXi & storage_bus_id diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index 5e23c28d..f62570cb 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -7,6 +7,7 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "SvcContainer.hpp" +#include "BinaryArchive.hpp" #include #include @@ -235,4 +236,12 @@ bool SvcContainer::_change_bus(int svc_id, GridModelBusId new_bus_id, DualAlgoCo return true; } +void SvcContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +SvcContainer SvcContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index a08b570a..2adcba24 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -103,6 +103,10 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder #include @@ -335,4 +336,12 @@ TrafoContainer::FDPFCoeffs TrafoContainer::get_fdpf_coeffs(int tr_id, FDPFMethod return res; } +void TrafoContainer::save_binary(const std::string & path) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + +TrafoContainer TrafoContainer::load_binary(const std::string & path) { + return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +} + } // namespace ls2g diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 8d15d3d1..8e58a9d1 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -110,6 +110,10 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A Date: Thu, 2 Jul 2026 11:45:45 +0200 Subject: [PATCH 017/166] fixing some issues with HVDC still Signed-off-by: DONNOT Benjamin --- src/core/LSGrid.cpp | 29 ++++++++++++------- src/core/LSGrid.hpp | 16 ++++++++-- .../ConverterStationContainer.cpp | 13 --------- .../ConverterStationContainer.hpp | 11 ------- 4 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index aef13984..87547da7 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -739,14 +739,15 @@ CplxVect LSGrid::check_solution(const CplxVect & V_proposed, bool check_q_limits bool is_ac = true; AlgoControl reset_solver; reset_solver.tell_none_changed(); // TODO reset solver - CplxVect V = pre_process_solver(V_proposed, + CplxVect V = pre_process_solver(V_proposed, acSbus_, - Ybus_ac_, + Ybus_ac_, id_me_to_ac_solver_, id_ac_solver_to_me_, slack_bus_id_ac_me_, slack_bus_id_ac_solver_, - is_ac, reset_solver); + is_ac, reset_solver, + false); // do NOT snap regulated buses to their target: we are testing V_proposed as-is // compute the mismatch CplxVect tmp = Ybus_ac_ * V; // this is a vector @@ -844,7 +845,8 @@ CplxVect LSGrid::_pre_process_solver_impl( GlobalBusIdVect & id_solver_to_me, GlobalBusIdVect & slack_bus_id_me, SolverBusIdVect & slack_bus_id_solver, - const AlgoControl & solver_control) + const AlgoControl & solver_control, + bool init_pv_vm_targets) { // cplx_type matrix => AC solver family, real_type matrix => DC solver family const bool is_ac = std::is_same::value; @@ -921,9 +923,15 @@ CplxVect LSGrid::_pre_process_solver_impl( } V(bus_solver_id) = Vinit(bus_me_id.cast_int()); } - generators_.set_vm(V, id_me_to_solver); - hvdc_lines_.set_vm(V, id_me_to_solver); - svcs_.set_vm(V, id_me_to_solver); // VOLTAGE-mode SVCs (init quality at the regulated bus) + if(init_pv_vm_targets){ + // NR-initialization heuristic only: snaps regulated buses with no droop/slope + // to their own target voltage magnitude. Skipped by check_solution, which must + // evaluate the caller-supplied voltage as given (see the `init_pv_vm_targets` + // doc on `pre_process_solver`). + generators_.set_vm(V, id_me_to_solver); + hvdc_lines_.set_vm(V, id_me_to_solver); + svcs_.set_vm(V, id_me_to_solver); // VOLTAGE-mode SVCs (init quality at the regulated bus) + } if(solver_control.need_reset_solver() || solver_control.has_dimension_changed() || @@ -958,11 +966,12 @@ CplxVect LSGrid::pre_process_solver( GlobalBusIdVect & slack_bus_id_me, SolverBusIdVect & slack_bus_id_solver, bool is_ac, // kept for API compatibility; DC now goes through pre_process_dc_solver - const AlgoControl & solver_control) + const AlgoControl & solver_control, + bool init_pv_vm_targets) { return _pre_process_solver_impl( Vinit, Sbus, Ybus, id_me_to_solver, id_solver_to_me, - slack_bus_id_me, slack_bus_id_solver, solver_control); + slack_bus_id_me, slack_bus_id_solver, solver_control, init_pv_vm_targets); } CplxVect LSGrid::pre_process_dc_solver( @@ -977,7 +986,7 @@ CplxVect LSGrid::pre_process_dc_solver( { return _pre_process_solver_impl( Vinit, Pbus, Bbus, id_me_to_solver, id_solver_to_me, - slack_bus_id_me, slack_bus_id_solver, solver_control); + slack_bus_id_me, slack_bus_id_solver, solver_control, true); } CplxVect LSGrid::_get_results_back_to_orig_nodes(const CplxVect & res_tmp, diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 85391779..8ac77747 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1540,6 +1540,16 @@ class LS2G_API LSGrid final This is use internally by ac_pf or dc_pf but also when doing batched solvers (*eg* TimeSeries or Contingency analysis) **/ + // `init_pv_vm_targets`: when true (the default, used by the real ac_pf / dc_pf + // solves), voltage-controlled elements with no droop/slope (generators, HVDC + // converters, zero-slope SVCs -- see SvcContainer::set_vm) have the proposed + // voltage MAGNITUDE at their regulated bus snapped to their own target, a + // reasonable NR-initialization heuristic. `check_solution` passes `false`: it + // is testing a caller-supplied (eg OLF's) voltage as-is, and silently + // overwriting it there defeats the whole point of the check -- even a tiny, + // physically-correct gap between that voltage and the local target (the + // regulator doing its job) can look like a large spurious power mismatch at a + // strongly-meshed bus. CplxVect pre_process_solver(const CplxVect & Vinit, CplxVect & Sbus, Eigen::SparseMatrix & Ybus, @@ -1548,7 +1558,8 @@ class LS2G_API LSGrid final GlobalBusIdVect & slack_bus_id_me, SolverBusIdVect & slack_bus_id_solver, bool is_ac, - const AlgoControl & solver_control); + const AlgoControl & solver_control, + bool init_pv_vm_targets = true); // DC-specific pre processing: builds the real Bbus (admittance) matrix and the // real Pbus (active power) vector, reusing the shared bus-mapping helpers. Mirrors @@ -1728,7 +1739,8 @@ class LS2G_API LSGrid final GlobalBusIdVect & id_solver_to_me, GlobalBusIdVect & slack_bus_id_me, SolverBusIdVect & slack_bus_id_solver, - const AlgoControl & solver_control); + const AlgoControl & solver_control, + bool init_pv_vm_targets); // matrix (re)initialization, overloaded per family (no `if constexpr`, C++14) void init_solver_matrix(Eigen::SparseMatrix & mat, int nb_bus_solver){ init_Ybus(mat, nb_bus_solver); } void init_solver_matrix(Eigen::SparseMatrix & mat, int nb_bus_solver){ init_Bbus(mat, nb_bus_solver); } diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index 37e146b9..8a941cae 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -170,9 +170,6 @@ void ConverterStationContainer::fillSbus_station(CplxVect & Sbus, // i don't do anything if the station is disconnected if(!status_[station_id]) continue; - // a regulating station that is "pseudo off" is considered turned off (legacy dcline behaviour) - if (is_pseudo_off(station_id) && is_regulating(station_id)) continue; - bus_id_me = bus_id_(station_id); if(bus_id_me.cast_int() == _deactivated_bus_id){ // TODO DEBUG MODE: only check in debug mode @@ -211,7 +208,6 @@ void ConverterStationContainer::fillpv(std::vector & bus_pv, for(int station_id = 0; station_id < nb_station; ++station_id){ if(!status_[station_id]) continue; if (!voltage_regulator_on_[station_id]) continue; // station is purposedly not pv - if (is_pseudo_off(station_id)) continue; // turned off stations are not pv (legacy dcline behaviour) bus_id_me = bus_id_(station_id); if(bus_id_me.cast_int() == _deactivated_bus_id){ @@ -249,7 +245,6 @@ void ConverterStationContainer::init_q_vector(int nb_bus, { if(!status_[station_id]) continue; if (!voltage_regulator_on_[station_id]) continue; // station is purposedly not pv - if (is_pseudo_off(station_id)) continue; // turned off stations are not pv (legacy dcline behaviour) const GlobalBusId bus_id = bus_id_(station_id); total_q_min_per_bus(bus_id.cast_int()) += min_q_(station_id); @@ -285,12 +280,6 @@ void ConverterStationContainer::set_q(const RealVect & reactive_mismatch, res_q_(station_id) = target_q_mvar_(station_id); continue; } - if (is_pseudo_off(station_id)) { - // it's as if the station were turned off (legacy dcline behaviour) - res_q_(station_id) = 0.; - continue; - } - const GlobalBusId bus_id = bus_id_(station_id); const SolverBusId bus_solver = id_grid_to_solver[bus_id.cast_int()]; // TODO DEBUG MODE: check that the bus is correct! @@ -318,7 +307,6 @@ void ConverterStationContainer::get_vm_for_dc(RealVect & Vm) for(int station_id = 0; station_id < nb_station; ++station_id){ if(!status_[station_id]) continue; if (!voltage_regulator_on_[station_id]) continue; // station is purposedly not pv - if (is_pseudo_off(station_id)) continue; // turned off stations are not pv (legacy dcline behaviour) bus_id_me = bus_id_(station_id); real_type tmp = target_vm_pu_(station_id); @@ -334,7 +322,6 @@ void ConverterStationContainer::set_vm(CplxVect & V, const SolverBusIdVect & id_ for(int station_id = 0; station_id < nb_station; ++station_id){ if(!status_[station_id]) continue; if (!voltage_regulator_on_[station_id]) continue; // station is purposedly not pv - if (is_pseudo_off(station_id)) continue; // turned off stations are not pv (legacy dcline behaviour) bus_id_me = bus_id_(station_id); if(bus_id_me.cast_int() == _deactivated_bus_id){ diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index b4ba9778..c7d85383 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -174,17 +174,6 @@ class LS2G_API ConverterStationContainer : public OneSideContainer_PQ, public It bool _reactivate(int station_id, DualAlgoControl & solver_control) final; bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) final; - /** - * Station with a derived active power of 0: not PV, no Sbus contribution - * when regulating (legacy dcline behaviour, cf `turnedoff_no_pv`). - */ - bool is_pseudo_off(int station_id) const { - return (abs(target_p_mw_(station_id)) < _tol_equal_float); - } - bool is_regulating(int station_id) const { - return voltage_regulator_on_[station_id]; - } - private: // input data IntVect type_; // ConverterType, per station From 94e3efc94a2f96dbf735dac8d9268fcf08fe102d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 2 Jul 2026 16:23:55 +0200 Subject: [PATCH 018/166] still improving the baking script Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 17 ++ .../network/from_pypowsybl/_from_pypowsybl.py | 100 +++++++++- .../network/from_pypowsybl/_olf_bake.py | 56 ++++-- .../tests/test_init_from_pypowsybl.py | 41 +++- lightsim2grid/tests/test_limits_pypowsybl.py | 185 ++++++++++++++++++ src/bindings/python/binding_containers.cpp | 4 + src/bindings/python/binding_lsgrid.cpp | 12 ++ src/core/LSGrid.cpp | 40 +++- src/core/LSGrid.hpp | 29 ++- src/core/SubstationContainer.cpp | 11 +- src/core/SubstationContainer.hpp | 21 +- .../Container_IteratorUtils.hpp | 12 +- .../element_container/HvdcLineContainer.hpp | 9 + .../TwoSidesContainer_rxh_A.hpp | 54 ++++- 14 files changed, 555 insertions(+), 36 deletions(-) create mode 100644 lightsim2grid/tests/test_limits_pypowsybl.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d38f786..959d01b5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -98,6 +98,13 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. built its working matrix from the grid model's own (never populated, hence empty) Ybus, causing out-of-bounds writes. It now uses the correctly indexed internal `Ybus_` and builds the required inputs on demand, so it works both before and after `compute()`. +- [FIXED] pickling (or `save_binary`/`load_binary`) an `LSGrid` whose `_ls_to_orig` + bus-mapping was set (which `init_from_pypowsybl` and `init_from_pandapower` both do) + always raised ``"Impossible to set the converter ls_to_orig: the provided vector has + not the same size as the number of bus on the grid."`` on restore. `LSGrid::set_state` + validated `_ls_to_orig` against `substations_.nb_bus()` before `substations_` itself + had been restored on the fresh instance pickle/binary loading constructs, so that size + was always 0. Fixed by restoring `substations_` first. - [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so @@ -245,6 +252,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). +- [ADDED] `init_from_pypowsybl` now reads and exposes operating limits: per-bus min/max + voltage (`LSGrid.get_bus_vmin_kv()` / `get_bus_vmax_kv()`, from the source voltage + levels' `low_voltage_limit` / `high_voltage_limit`) and per-side branch thermal + (current) limits (`limit_a1_ka` / `limit_a2_ka` on `LineInfo` / `TrafoInfo`, from + `network.get_operational_limits()`, honoring each side's *selected* limit group and + taking the minimum finite value across all durations). NaN where a limit is not + configured; both fields round-trip through pickle and `save_binary`/`load_binary`. + Grids built from pandapower never populate these (getters return an empty array / + NaN per element). Not wired into `LightSimBackend.thermal_limit_a`, which keeps its + existing behaviour. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index d676301f..5e41e0af 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -166,6 +166,46 @@ def _aux_phase_shift_rx_tables(trafo_index, net): return alpha, corr +def _aux_current_limits(element_ids, group_1, group_2, operational_limits): + """Per-side thermal (current) limit, in **kA**, for a set of branch elements + (lines or transformers), aligned to ``element_ids``. + + ``group_1``/``group_2`` are the elements' ``selected_limits_group_1``/``_2`` + (pandas Series, from ``net.get_lines()``/``net.get_2_windings_transformers()`` + called with ``all_attributes=True``) -- a branch can carry several unused limit + groups (eg summer/winter), only the *selected* one per side is relevant. + ``operational_limits`` is ``net.get_operational_limits()`` already filtered to + ``type == "CURRENT"``. + + For each (element, side), the minimum finite value across all durations + (permanent + temporary) within the side's selected limit group is kept -- + pypowsybl's ``~1.8e308`` "no limit for this duration" placeholder is ignored. + NaN for a given (element, side) if it carries no current limit (or its selected + group has none).""" + n = len(element_ids) + limit_a1_ka = np.full(n, np.nan) + limit_a2_ka = np.full(n, np.nan) + if operational_limits is None or operational_limits.shape[0] == 0 or n == 0: + return limit_a1_ka, limit_a2_ka + # ignore the "no limit for this duration" placeholder + operational_limits = operational_limits[operational_limits["value"] < 1e30] + pos = pd.Series(np.arange(n), index=element_ids) + for out, side_str, group in ((limit_a1_ka, "ONE", group_1), (limit_a2_ka, "TWO", group_2)): + rows = operational_limits[operational_limits.index.get_level_values("side") == side_str] + if rows.shape[0] == 0: + continue + rows = rows.reset_index()[["element_id", "group_name", "value"]] + sel_group = group.reindex(element_ids).rename("sel_group").rename_axis("element_id").reset_index() + rows = rows.merge(sel_group, on="element_id", how="inner") + rows = rows[rows["group_name"] == rows["sel_group"]] + if rows.shape[0] == 0: + continue + min_per_el = rows.groupby("element_id")["value"].min() / 1000. # A -> kA + idx = pos.reindex(min_per_el.index).dropna().astype(int) + out[idx.values] = min_per_el.loc[idx.index].values + return limit_a1_ka, limit_a2_ka + + def _aux_regulated_bus_view_ids(net, regulated_ids): """Resolve voltage-controller regulated elements to their terminal bus. @@ -573,8 +613,12 @@ def init(net : pypo.network.Network, n_busbar_per_sub = 1 all_buses_vn_kv = voltage_levels.loc[bus_df["voltage_level_id"], "nominal_v"].values + all_buses_vmin_kv = voltage_levels.loc[bus_df["voltage_level_id"], "low_voltage_limit"].values + all_buses_vmax_kv = voltage_levels.loc[bus_df["voltage_level_id"], "high_voltage_limit"].values if n_busbar_per_sub > 1: all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmin_kv = np.concatenate([all_buses_vmin_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmax_kv = np.concatenate([all_buses_vmax_kv for _ in range(n_busbar_per_sub)]) n_sub_ls = bus_df.shape[0] ls_to_orig = np.zeros(all_buses_vn_kv.shape[0], dtype=int) - 1 ls_to_orig[:n_sub_ls] = np.arange(n_sub_ls) @@ -612,8 +656,12 @@ def init(net : pypo.network.Network, f"which is not compatible with the n_busbar_per_sub={n_busbar_per_sub} " "given as input.") all_buses_vn_kv = voltage_levels["nominal_v"].values + all_buses_vmin_kv = voltage_levels["low_voltage_limit"].values + all_buses_vmax_kv = voltage_levels["high_voltage_limit"].values if n_busbar_per_sub > 1: all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmin_kv = np.concatenate([all_buses_vmin_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmax_kv = np.concatenate([all_buses_vmax_kv for _ in range(n_busbar_per_sub)]) n_sub_ls = voltage_levels.shape[0] voltage_levels["glop_sub_id"] = np.arange(voltage_levels.shape[0]) n_busbar_per_sub_ls = n_busbar_per_sub @@ -661,6 +709,7 @@ def init(net : pypo.network.Network, model._ls_to_orig = ls_to_orig model._max_nb_bus_per_sub = n_busbar_per_sub_ls model.set_substation_names(sub_names) + model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) # do the generators if sort_index: @@ -754,13 +803,27 @@ def init(net : pypo.network.Network, if is_disco: model.deactivate_load(load_id) model.set_load_names(df_load.index) - + + # thermal (current) limits for lines / trafos, see `_aux_current_limits` + try: + ol_current = net.get_operational_limits() + ol_current = ol_current[ol_current.index.get_level_values("type") == "CURRENT"] + except Exception: # noqa: BLE001 - not available on legacy pypowsybl + ol_current = None + # for lines if sort_index: df_line = net.get_lines().sort_index() else: df_line = net.get_lines() - + try: + line_limit_groups = net.get_lines(all_attributes=True)[["selected_limits_group_1", "selected_limits_group_2"]] + except (TypeError, KeyError): + # not available on legacy pypowsybl / grid with no limit group at all + line_limit_groups = pd.DataFrame( + {"selected_limits_group_1": np.nan, "selected_limits_group_2": np.nan}, index=df_line.index + ) + # per unit if net_pu is None: if hasattr(net, "per_unit"): @@ -820,7 +883,14 @@ def init(net : pypo.network.Network, elif is_ex_disc: model.deactivate_powerline_side2(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) model.set_line_names(df_line.index) - + line_limit_a1_ka, line_limit_a2_ka = _aux_current_limits( + df_line.index, + line_limit_groups["selected_limits_group_1"], + line_limit_groups["selected_limits_group_2"], + ol_current, + ) + model.set_line_thermal_limit(line_limit_a1_ka, line_limit_a2_ka) + # for trafo # I extract trafo with `all_attributes=True` so that I have access to the `rho` try: @@ -896,6 +966,17 @@ def init(net : pypo.network.Network, elif is_ex_disc: model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) model.set_trafo_names(df_trafo.index) + if "selected_limits_group_1" in df_trafo.columns: + trafo_group_1 = df_trafo["selected_limits_group_1"] + trafo_group_2 = df_trafo["selected_limits_group_2"] + else: + # not available on legacy pypowsybl + trafo_group_1 = pd.Series(np.nan, index=df_trafo.index) + trafo_group_2 = pd.Series(np.nan, index=df_trafo.index) + trafo_limit_a1_ka, trafo_limit_a2_ka = _aux_current_limits( + df_trafo.index, trafo_group_1, trafo_group_2, ol_current + ) + model.set_trafo_thermal_limit(trafo_limit_a1_ka, trafo_limit_a2_ka) # phase-shifting transformers: declare the (alpha -> r/x correction) dependency so # lightsim2grid keeps the series impedance right when the shift changes, without any # "tap" concept (the per-step r/x deltas pypowsybl carries only in its tap steps). @@ -989,8 +1070,17 @@ def init(net : pypo.network.Network, target_v = df_svc["target_v"].values.astype(float) # target_v (kV) -> pu at the regulated bus; NaN (REACTIVE_POWER / OFF) -> 1 pu target_vm_pu = np.where(np.isfinite(target_v), target_v, svc_reg_vn) / svc_reg_vn - b_min = df_svc["b_min"].values.astype(float) - b_max = df_svc["b_max"].values.astype(float) + # pypowsybl/IIDM gives b_min/b_max in SIEMENS (physical susceptance), while + # lightsim2grid's SvcContainer expects them in per unit (base sn_mva, at the + # regulated bus's nominal voltage -- same base as target_vm_pu/slope above). + # Without this conversion the SVC's modeled Q range is smaller than its real one + # by a factor of (nominal_v_kv)^2 / sn_mva (eg ~500x on a 225kV/100MVA bus), + # making a perfectly healthy SVC collapse to a near-zero Q range: it saturates + # (or, since check_solution never enforces SVC Q limits, silently hits a "hard" + # voltage pin at its own target_vm_pu regardless of what Q that would truly take) + # long before the real device would. + b_min = df_svc["b_min"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used + b_max = df_svc["b_max"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used else: target_vm_pu = np.zeros(0) b_min = np.zeros(0) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 18bd02b6..3393f53b 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -50,15 +50,13 @@ * Active-power redistribution from distributed slack / area interchange: the realized P is written back into the target P of generators and batteries (and optionally loads, if the slack was distributed on load). - -What is intentionally *not* baked ---------------------------------- -* Static var compensators are left regulating. An SVC's reactive range is - voltage-dependent (it is a susceptance band b_min..b_max, not a fixed Q box), - so the generator-style "Q at a fixed limit" test does not apply. If an SVC is - still regulating voltage at convergence, leaving it regulating reproduces the - OLF result. Freezing a saturated SVC would require its regulated-bus voltage - and the OLF susceptance sign convention; that is left as a hook. +* Static var compensators whose realized reactive output sits at (or beyond) + their voltage-dependent susceptance envelope (``Q(V) = b * V^2``, ``b`` in + ``b_min``..``b_max``, recomputed in MVAr at the SVC's own solved terminal + voltage) are frozen to fixed-Q (``REACTIVE_POWER`` mode), mirroring the + generator/VSC reactive-limit switch above. An SVC still comfortably inside + its envelope is left regulating (its target reproduces the OLF result + exactly, since it isn't saturated). Verified against pypowsybl 1.15.0. The OLF-internal round trip (solve with outer loops -> bake -> solve loop-free) reproduces bus voltages to ~2e-4 kV / @@ -269,8 +267,44 @@ def _bake_reactive_limit_switches( upd["voltage_regulator_on"] = False network.update_vsc_converter_stations(upd) - # Static var compensators are deliberately left regulating; see module - # docstring. Hook for susceptance-envelope saturation would go here. + _bake_svc_saturation(network, keep_only_main_comp) + + +def _bake_svc_saturation(network, keep_only_main_comp=True): + """Freeze a VOLTAGE-mode SVC whose realized reactive output sits at (or beyond) + its voltage-dependent susceptance envelope to fixed-Q (REACTIVE_POWER mode), + mirroring ``_bake_reactive_limit_switches`` for generators/VSC stations above. + + Unlike a generator's fixed Q box, an SVC's reactive range is + ``Q(V) = b * V^2`` (``b`` in ``b_min``..``b_max``, in Siemens): recompute + ``qmin``/``qmax`` in MVAr at the SVC's own solved terminal voltage before + comparing against the realized ``q``. + """ + df_bus = network.get_buses(attributes=["v_mag", "synchronous_component"]) + svc = network.get_static_var_compensators( + attributes=["regulating", "regulation_mode", "q", "b_min", "b_max", "connected", "bus_id"] + ) + if keep_only_main_comp: + svc = _keep_only_main_comp(svc, df_bus) + is_voltage = svc["regulating"] & (svc["regulation_mode"] == "VOLTAGE") + svc = svc[is_voltage] + if not len(svc): + return + v_kv = df_bus.loc[svc["bus_id"].values, "v_mag"].to_numpy() + # SVC "q" (like generators') is the terminal/receptor-convention result: flip to + # generator/injection convention to compare against the susceptance envelope. + q_gen = -svc["q"].to_numpy() + qmax = svc["b_max"].to_numpy() * v_kv ** 2 # Q[MVAr] = B[S] * V[kV]^2 + qmin = svc["b_min"].to_numpy() * v_kv ** 2 + mask = (q_gen >= qmax - _Q_LIMIT_TOL) | (q_gen <= qmin + _Q_LIMIT_TOL) + if mask.any(): + upd = pd.DataFrame(index=svc.index[mask]) + # unlike Generator.target_q (already generator convention), StaticVarCompensator + # .target_q is receptor convention like its "q" result column -- write the raw + # (unflipped) realized value. + upd["target_q"] = svc["q"].to_numpy()[mask] + upd["regulation_mode"] = "REACTIVE_POWER" + network.update_static_var_compensators(upd) def _bake_remote_voltage_control(network, keep_only_main_comp=True): diff --git a/lightsim2grid/tests/test_init_from_pypowsybl.py b/lightsim2grid/tests/test_init_from_pypowsybl.py index 56d423af..843f1abd 100644 --- a/lightsim2grid/tests/test_init_from_pypowsybl.py +++ b/lightsim2grid/tests/test_init_from_pypowsybl.py @@ -518,13 +518,46 @@ def get_sub_names(self): def get_sub_names_b4s(self): return np.asarray( - ['VL1_0', 'VL2_0', 'VL3_0', 'VL4_0', 'VL5_0', 'VL6_0', - 'VL7_0', 'VL8_0', + ['VL1_0', 'VL2_0', 'VL3_0', 'VL4_0', 'VL5_0', 'VL6_0', + 'VL7_0', 'VL8_0', 'VL9_0', 'VL10_0', 'VL11_0', 'VL12_0', 'VL13_0', 'VL14_0'], dtype=str ) - - + + +class TestPickleFromPypo(unittest.TestCase): + """A pypowsybl-sourced LSGrid sets a non-empty `_ls_to_orig` (unlike a grid built + directly through `init_bus`/etc, whose `_ls_to_orig` stays empty). `LSGrid.set_state` + used to call `set_ls_to_orig` (which validates the vector size against + `substations_.nb_bus()`) *before* `substations_.set_state(...)` had restored the + real bus count on the freshly default-constructed instance pickle/binary restore + into -- so `substations_.nb_bus()` was still 0 and any non-empty `_ls_to_orig` was + wrongly rejected as a size mismatch. Fixed by restoring `substations_` first.""" + + def test_pickle_roundtrip(self): + net = pp.network.create_ieee14() + model = init_from_pypowsybl(net) + model2 = pickle.loads(pickle.dumps(model)) + np.testing.assert_array_equal(np.asarray(model._ls_to_orig), np.asarray(model2._ls_to_orig)) + np.testing.assert_allclose(np.asarray(model.get_bus_vn_kv()), np.asarray(model2.get_bus_vn_kv())) + nb_bus = len(model.get_bus_status()) + v_init = np.ones(nb_bus, dtype=np.complex128) + v1 = model.ac_pf(v_init, 30, 1e-10) + v2 = model2.ac_pf(v_init, 30, 1e-10) + np.testing.assert_allclose(v1, v2) + + def test_binary_roundtrip(self): + import os + import tempfile + net = pp.network.create_ieee14() + model = init_from_pypowsybl(net) + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "model.bin") + model.save_binary(path) + model2 = LSGrid.load_binary(path) + np.testing.assert_array_equal(np.asarray(model._ls_to_orig), np.asarray(model2._ls_to_orig)) + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/lightsim2grid/tests/test_limits_pypowsybl.py b/lightsim2grid/tests/test_limits_pypowsybl.py new file mode 100644 index 00000000..edce22c2 --- /dev/null +++ b/lightsim2grid/tests/test_limits_pypowsybl.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Per-bus voltage limits and per-side branch thermal (current) limits, read from +pypowsybl's `low_voltage_limit`/`high_voltage_limit` (voltage levels) and +`get_operational_limits()` (lines / transformers) when a grid is built with +`init_from_pypowsybl`. Skipped when pypowsybl is unavailable.""" + +import os +import pickle +import tempfile +import unittest +import numpy as np + +from lightsim2grid.network import LSGrid + +try: + import pypowsybl as pp + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + +try: + import pandapower.networks as pn + from lightsim2grid.network import init_from_pandapower + HAS_PANDAPOWER = True +except ImportError: + HAS_PANDAPOWER = False + + +def _build_net(): + """3-bus network: slack gen (B1) -- line L12 -- B2 -- trafo T23 -- B3 (load). + + B1's voltage level carries no voltage limit (pypowsybl default: NaN). + L12 carries an asymmetric per-side current limit (side 1: permanent + a higher + temporary duration, to check the min-over-durations reduction; side 2: a single + permanent limit). T23 carries a current limit on side 1 only (side 2 stays unset). + """ + n = pp.network.create_empty() + # T23 needs both its voltage levels on the same substation (IIDM constraint) + n.create_substations(id=["S1", "S2"]) + n.create_voltage_levels(id=["VL1", "VL2", "VL3"], substation_id=["S1", "S2", "S2"], + topology_kind=["BUS_BREAKER"] * 3, nominal_v=[400.0, 400.0, 90.0], + low_voltage_limit=[float("nan"), 380.0, 80.0], + high_voltage_limit=[float("nan"), 420.0, 100.0]) + n.create_buses(id=["B1", "B2", "B3"], voltage_level_id=["VL1", "VL2", "VL3"]) + n.create_lines(id="L12", voltage_level1_id="VL1", voltage_level2_id="VL2", + bus1_id="B1", bus2_id="B2", r=1.0, x=20.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + n.create_2_windings_transformers(id="T23", voltage_level1_id="VL2", voltage_level2_id="VL3", + bus1_id="B2", bus2_id="B3", rated_u1=400.0, rated_u2=90.0, + r=1.0, x=10.0, g=0.0, b=0.0, rated_s=100.0) + n.create_generators(id="G", voltage_level_id="VL1", bus_id="B1", + target_p=100.0, target_q=0.0, target_v=400.0, + voltage_regulator_on=True, max_p=1000.0, min_p=0.0) + n.create_loads(id="LD", voltage_level_id="VL3", bus_id="B3", p0=80.0, q0=20.0) + + # side 1: permanent=500A + a higher 10min=600A temporary limit -> min=500A=0.5kA + n.create_operational_limits(element_id=["L12", "L12"], + side=["ONE", "ONE"], + name=["permanent", "10min"], + type=["CURRENT"] * 2, + value=[500.0, 600.0], + acceptable_duration=[-1, 600]) + # side 2: single permanent limit -> 450A = 0.45kA + n.create_operational_limits(element_id=["L12"], side=["TWO"], name=["permanent"], + type=["CURRENT"], value=[450.0], acceptable_duration=[-1]) + # trafo side 1 only (side 2 stays unset -> NaN) + n.create_operational_limits(element_id=["T23"], side=["ONE"], name=["permanent"], + type=["CURRENT"], value=[123.4], acceptable_duration=[-1]) + return n + + +@unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") +class TestLimitsPypowsybl(unittest.TestCase): + def setUp(self): + self.model = init_from_pypowsybl(_build_net(), gen_slack_id="G", sort_index=True) + + def test_voltage_limits(self): + vmin = np.asarray(self.model.get_bus_vmin_kv()) + vmax = np.asarray(self.model.get_bus_vmax_kv()) + self.assertEqual(vmin.shape[0], 3) + sub_names = list(self.model.get_substation_names()) + by_name = {nm: i for i, nm in enumerate(sub_names)} + self.assertTrue(np.isnan(vmin[by_name["VL1"]])) + self.assertTrue(np.isnan(vmax[by_name["VL1"]])) + self.assertAlmostEqual(vmin[by_name["VL2"]], 380.0) + self.assertAlmostEqual(vmax[by_name["VL2"]], 420.0) + self.assertAlmostEqual(vmin[by_name["VL3"]], 80.0) + self.assertAlmostEqual(vmax[by_name["VL3"]], 100.0) + + def test_line_thermal_limit(self): + line = [el for el in self.model.get_lines() if el.name == "L12"][0] + self.assertAlmostEqual(line.limit_a1_ka, 0.5, places=6) # min(500, 600) A + self.assertAlmostEqual(line.limit_a2_ka, 0.45, places=6) + + def test_trafo_thermal_limit(self): + trafo = [el for el in self.model.get_trafos() if el.name == "T23"][0] + self.assertAlmostEqual(trafo.limit_a1_ka, 0.1234, places=6) + self.assertTrue(np.isnan(trafo.limit_a2_ka)) + + def test_no_limits_grid_gives_nan_not_crash(self): + n = pp.network.create_ieee14() + model = init_from_pypowsybl(n) + vmin = np.asarray(model.get_bus_vmin_kv()) + self.assertEqual(vmin.shape[0], model.get_bus_status().__len__()) + for el in model.get_lines(): + self.assertTrue(np.isnan(el.limit_a1_ka)) + self.assertTrue(np.isnan(el.limit_a2_ka)) + + def test_pickle_roundtrip_pypowsybl_grid(self): + # see also TestPickleFromPypo in test_init_from_pypowsybl.py for the general + # (limits-independent) pickle/binary regression test + model2 = pickle.loads(pickle.dumps(self.model)) + np.testing.assert_allclose(np.asarray(model2.get_bus_vmin_kv()), np.asarray(self.model.get_bus_vmin_kv()), equal_nan=True) + np.testing.assert_allclose(np.asarray(model2.get_bus_vmax_kv()), np.asarray(self.model.get_bus_vmax_kv()), equal_nan=True) + line = [el for el in model2.get_lines() if el.name == "L12"][0] + self.assertAlmostEqual(line.limit_a1_ka, 0.5, places=6) + self.assertAlmostEqual(line.limit_a2_ka, 0.45, places=6) + + +def _build_ls_grid_with_limits(): + """Plain LSGrid (no pypowsybl involved), exercising the new setters directly -- + a minimal/fast complement to `test_pickle_roundtrip_pypowsybl_grid` above.""" + g = LSGrid() + g.init_bus(2, 1, np.array([20.0, 20.0]), 0, 0) + g.set_bus_voltage_limits(np.array([18.0, np.nan]), np.array([22.0, np.nan])) + g.init_powerlines(np.array([0.01]), np.array([0.05]), np.array([0.0 + 0.0j]), + np.array([0], dtype=np.int32), np.array([1], dtype=np.int32)) + g.set_line_thermal_limit(np.array([1.234]), np.array([np.nan])) + return g + + +class TestLimitsSerialization(unittest.TestCase): + def test_pickle_roundtrip(self): + model = _build_ls_grid_with_limits() + model2 = pickle.loads(pickle.dumps(model)) + np.testing.assert_allclose(np.asarray(model2.get_bus_vmin_kv()), np.asarray(model.get_bus_vmin_kv()), equal_nan=True) + np.testing.assert_allclose(np.asarray(model2.get_bus_vmax_kv()), np.asarray(model.get_bus_vmax_kv()), equal_nan=True) + a1_before = [el.limit_a1_ka for el in model.get_lines()] + a1_after = [el.limit_a1_ka for el in model2.get_lines()] + np.testing.assert_allclose(a1_after, a1_before, equal_nan=True) + + def test_binary_roundtrip(self): + model = _build_ls_grid_with_limits() + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "model.bin") + model.save_binary(path) + model2 = LSGrid.load_binary(path) + np.testing.assert_allclose(np.asarray(model2.get_bus_vmin_kv()), np.asarray(model.get_bus_vmin_kv()), equal_nan=True) + a1_before = [el.limit_a1_ka for el in model.get_lines()] + a1_after = [el.limit_a1_ka for el in model2.get_lines()] + np.testing.assert_allclose(a1_after, a1_before, equal_nan=True) + + def test_unset_fields_roundtrip_empty(self): + g = LSGrid() + g.init_bus(1, 1, np.array([20.0]), 0, 0) + g.init_powerlines(np.array([0.01]), np.array([0.05]), np.array([0.0 + 0.0j]), + np.array([0], dtype=np.int32), np.array([0], dtype=np.int32)) + g2 = pickle.loads(pickle.dumps(g)) + self.assertEqual(np.asarray(g2.get_bus_vmin_kv()).shape[0], 0) + self.assertTrue(np.isnan([el.limit_a1_ka for el in g2.get_lines()][0])) + + +@unittest.skipUnless(HAS_PANDAPOWER, "pandapower is not installed") +class TestLimitsPandapowerUnset(unittest.TestCase): + """pandapower-origin grids never call the new setters: getters must return + empty arrays / per-element NaN, not crash.""" + + def test_unset_limits(self): + model = init_from_pandapower(pn.case14()) + self.assertEqual(np.asarray(model.get_bus_vmin_kv()).shape[0], 0) + self.assertEqual(np.asarray(model.get_bus_vmax_kv()).shape[0], 0) + for el in model.get_lines(): + self.assertTrue(np.isnan(el.limit_a1_ka)) + self.assertTrue(np.isnan(el.limit_a2_ka)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/bindings/python/binding_containers.cpp b/src/bindings/python/binding_containers.cpp index 16ddd212..733f7960 100644 --- a/src/bindings/python/binding_containers.cpp +++ b/src/bindings/python/binding_containers.cpp @@ -245,6 +245,8 @@ void bind_containers(py::module_& m) { .def_readonly("res_q2_mvar", &TrafoInfo::res_q2_mvar, DocIterator::res_q_lv_mvar.c_str()) .def_readonly("res_v2_kv", &TrafoInfo::res_v2_kv, DocIterator::res_v_lv_kv.c_str()) .def_readonly("res_a2_ka", &TrafoInfo::res_a2_ka, DocIterator::res_a_lv_ka.c_str()) + .def_readonly("limit_a1_ka", &TrafoInfo::limit_a1_ka, "Thermal (current) limit, hv side, in kA (NaN if not set, see `LSGrid.set_trafo_thermal_limit`).") + .def_readonly("limit_a2_ka", &TrafoInfo::limit_a2_ka, "Thermal (current) limit, lv side, in kA (NaN if not set, see `LSGrid.set_trafo_thermal_limit`).") .def_readonly("res_theta1_deg", &TrafoInfo::res_theta1_deg, DocIterator::res_theta_hv_deg.c_str()) .def_readonly("res_theta2_deg", &TrafoInfo::res_theta2_deg, DocIterator::res_theta_lv_deg.c_str()) .def_readonly("yac_11", &TrafoInfo::yac_11, "TODO doc") @@ -302,6 +304,8 @@ void bind_containers(py::module_& m) { .def_readonly("res_q2_mvar", &LineInfo::res_q2_mvar, DocIterator::res_q_ex_mvar.c_str()) .def_readonly("res_v2_kv", &LineInfo::res_v2_kv, DocIterator::res_v_ex_kv.c_str()) .def_readonly("res_a2_ka", &LineInfo::res_a2_ka, DocIterator::res_a_ex_ka.c_str()) + .def_readonly("limit_a1_ka", &LineInfo::limit_a1_ka, "Thermal (current) limit, origin side, in kA (NaN if not set, see `LSGrid.set_line_thermal_limit`).") + .def_readonly("limit_a2_ka", &LineInfo::limit_a2_ka, "Thermal (current) limit, extremity side, in kA (NaN if not set, see `LSGrid.set_line_thermal_limit`).") .def_readonly("res_theta1_deg", &LineInfo::res_theta1_deg, DocIterator::res_theta_or_deg.c_str()) .def_readonly("res_theta2_deg", &LineInfo::res_theta2_deg, DocIterator::res_theta_ex_deg.c_str()) .def_readonly("yac_11", &LineInfo::yac_11, "TODO doc") diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index e1e0a9bb..4f6e9e58 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -106,6 +106,14 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("remove_gen_slackbus", &LSGrid::remove_gen_slackbus, DocLSGrid::_internal_do_not_use.c_str()) .def("get_bus_vn_kv", &LSGrid::get_bus_vn_kv, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_bus_status", &LSGrid::get_bus_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) + .def("set_bus_voltage_limits", &LSGrid::set_bus_voltage_limits, + "Set the per-bus min/max operating voltage (in kV), one value per bus (see `get_bus_vn_kv`).") + .def("get_bus_vmin_kv", &LSGrid::get_bus_vmin_kv, + "Per-bus min operating voltage, in kV (NaN if not provided for a given bus, empty array if never set).", + py::return_value_policy::reference) + .def("get_bus_vmax_kv", &LSGrid::get_bus_vmax_kv, + "Per-bus max operating voltage, in kV (NaN if not provided for a given bus, empty array if never set).", + py::return_value_policy::reference) // inspect the grid .def("get_substations", &LSGrid::get_substations, "TODO", py::return_value_policy::reference) @@ -146,6 +154,10 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("set_line_names", &LSGrid::set_line_names, "TODO") .def("set_dcline_names", &LSGrid::set_dcline_names, "TODO") .def("set_trafo_names", &LSGrid::set_trafo_names, "TODO") + .def("set_line_thermal_limit", &LSGrid::set_line_thermal_limit, + "Set the per-side thermal (current) limit of each powerline, in kA (see `limit_a1_ka`/`limit_a2_ka` on `LineInfo`).") + .def("set_trafo_thermal_limit", &LSGrid::set_trafo_thermal_limit, + "Set the per-side thermal (current) limit of each transformer, in kA (see `limit_a1_ka`/`limit_a2_ka` on `TrafoInfo`).") .def("set_gen_names", &LSGrid::set_gen_names, "TODO") .def("set_load_names", &LSGrid::set_load_names, "TODO") .def("set_storage_names", &LSGrid::set_storage_names, "TODO") diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 87547da7..dbc5bc1c 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -158,15 +158,19 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // static var compensators (index 16/17 are the ac/dc algo types) SvcContainer::StateRes & state_svcs = std::get<18>(my_state); - // assign it to this instance - set_ls_to_orig(IntVect::Map(ls_to_pp.data(), ls_to_pp.size())); // set also _orig_to_ls - // substations last_bus_status_saved_ = last_bus_status_saved; substations_.set_state(state_substations); max_nb_bus_per_sub_ = substations_.nmax_busbar_per_sub(); n_sub_ = substations_.nb_sub(); + // assign it to this instance -- must run *after* substations_ is restored above, + // since set_ls_to_orig() validates the vector size against substations_.nb_bus() + // (on a freshly default-constructed LSGrid, as pickle/binary restore does, that + // would otherwise still be 0 and any non-empty ls_to_orig would wrongly be + // rejected as a size mismatch). + set_ls_to_orig(IntVect::Map(ls_to_pp.data(), ls_to_pp.size())); // set also _orig_to_ls + // elements // 1. powerlines powerlines_.set_state(state_lines); @@ -691,7 +695,15 @@ void LSGrid::check_solution_q_values(CplxVect & res, bool check_q_limits) const{ // the generator is disconnected, I do nothing continue; } - check_solution_q_values_onegen(res, gen.bus_id, gen.min_q_mvar, gen.max_q_mvar, check_q_limits); + // Only a voltage-regulating generator has a genuinely FREE Q at its own bus in + // the real NR system (GeneratorContainer::fillSbus never stamps Q there, local + // or remote control alike -- see fillpv / the VoltageControl extension). A + // non-regulating (fixed PQ) generator's Q is deterministic and already part of + // Sbus, so masking it here would hide a real mismatch instead of correctly + // reporting "this bus's Q is free, don't judge it". + if(gen.voltage_regulator_on){ + check_solution_q_values_onegen(res, gen.bus_id, gen.min_q_mvar, gen.max_q_mvar, check_q_limits); + } // if(gen.id == gen_slackbus_) if(gen.is_slack) @@ -714,10 +726,13 @@ void LSGrid::check_solution_q_values(CplxVect & res, bool check_q_limits) const{ const auto & station_2 = hvdc.station_side_2; // a side may be open while the line is still connected_global (a line whose // remote converter is in another synchronous component, see - // HvdcLineContainer::disconnect_if_not_in_main_component): skip the open side - if(station_1.connected) + // HvdcLineContainer::disconnect_if_not_in_main_component): skip the open side. + // Same "only mask a genuinely free Q" reasoning as for generators above: a + // fixed-Q / power-factor (LCC) station's Q is real Sbus data, not a free + // variable -- see ConverterStationContainer::fillSbus_station. + if(station_1.connected && station_1.voltage_regulator_on) check_solution_q_values_onegen(res, station_1.bus_id, station_1.min_q_mvar, station_1.max_q_mvar, check_q_limits); - if(station_2.connected) + if(station_2.connected && station_2.voltage_regulator_on) check_solution_q_values_onegen(res, station_2.bus_id, station_2.min_q_mvar, station_2.max_q_mvar, check_q_limits); } @@ -738,7 +753,16 @@ CplxVect LSGrid::check_solution(const CplxVect & V_proposed, bool check_q_limits const int nb_bus = static_cast(V_proposed.size()); bool is_ac = true; AlgoControl reset_solver; - reset_solver.tell_none_changed(); // TODO reset solver + // `AlgoControl`'s own default constructor already asks for a full rebuild + // (`need_reset_solver_=true`) -- only downgrade that to "nothing changed" once the + // AC solver bus mapping has actually been built at least once (by a prior + // `ac_pf`/`dc_pf`/`check_solution` call). Calling `check_solution` as the very + // first operation on a freshly-built model with `tell_none_changed()` + // unconditionally used to leave `id_me_to_ac_solver_`/`Ybus_ac_` at their + // default-constructed (empty) size, and `fill_hvdc_droop_solver_data`'s + // `id_me_to_solver[bus_id]` lookup (bus ids in the hundreds/thousands) then read + // out of bounds -- a silent, hard-to-reproduce segfault instead of a clean rebuild. + if(id_me_to_ac_solver_.size() > 0) reset_solver.tell_none_changed(); CplxVect V = pre_process_solver(V_proposed, acSbus_, Ybus_ac_, diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 8ac77747..c9615cf1 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -183,6 +183,14 @@ class LS2G_API LSGrid final const TrafoContainer & get_trafos_as_data() const {return trafos_;} const HvdcLineContainer & get_dclines_as_data() const {return hvdc_lines_;} Eigen::Ref get_bus_vn_kv() const {return substations_.get_bus_vn_kv();} + + // per-bus min/max operating voltage (kV), optional: empty if never set + void set_bus_voltage_limits(const RealVect & bus_vmin_kv, const RealVect & bus_vmax_kv){ + substations_.init_bus_voltage_limits(bus_vmin_kv, bus_vmax_kv); + } + Eigen::Ref get_bus_vmin_kv() const {return substations_.get_bus_vmin_kv();} + Eigen::Ref get_bus_vmax_kv() const {return substations_.get_bus_vmax_kv();} + std::tuple assign_slack_to_most_connected(); void consider_only_main_component(); /** @@ -635,6 +643,17 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, trafos_.nb(), "set_trafo_names"); trafos_.set_names(names); } + // per-side thermal (current) limit, in kA, optional: empty if never set + void set_line_thermal_limit(const RealVect & limit_a1_ka, const RealVect & limit_a2_ka){ + GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_thermal_limit (side1)"); + GenericContainer::check_size(limit_a2_ka, powerlines_.nb(), "set_line_thermal_limit (side2)"); + powerlines_.set_thermal_limit(limit_a1_ka, limit_a2_ka); + } + void set_trafo_thermal_limit(const RealVect & limit_a1_ka, const RealVect & limit_a2_ka){ + GenericContainer::check_size(limit_a1_ka, trafos_.nb(), "set_trafo_thermal_limit (side1)"); + GenericContainer::check_size(limit_a2_ka, trafos_.nb(), "set_trafo_thermal_limit (side2)"); + trafos_.set_thermal_limit(limit_a1_ka, limit_a2_ka); + } void set_gen_names(const std::vector & names){ GenericContainer::check_size(names, generators_.nb(), "set_gen_names"); generators_.set_names(names); @@ -895,8 +914,14 @@ class LS2G_API LSGrid final // a still-active local reactive/voltage-support device (not a dead branch). // Unlike powerlines/trafos this is unconditional (HvdcLineContainer's // synch_status_both_side_ defaults to false), no `keep_half_open_lines` needed. - void deactivate_dcline_side1(int dcline_id) {hvdc_lines_.deactivate_side_1(dcline_id, algo_controler_); } - void deactivate_dcline_side2(int dcline_id) {hvdc_lines_.deactivate_side_2(dcline_id, algo_controler_); } + void deactivate_dcline_side1(int dcline_id) { + hvdc_lines_.deactivate_side_1(dcline_id, algo_controler_); + hvdc_lines_.disable_droop(dcline_id); // remote angle is gone, see disable_droop's doc + } + void deactivate_dcline_side2(int dcline_id) { + hvdc_lines_.deactivate_side_2(dcline_id, algo_controler_); + hvdc_lines_.disable_droop(dcline_id); + } void change_p_dcline(int dcline_id, real_type new_p) {hvdc_lines_.change_p(dcline_id, new_p, algo_controler_); } void change_v1_dcline(int dcline_id, real_type new_v_pu) {hvdc_lines_.change_v_side_1(dcline_id, new_v_pu, algo_controler_); } void change_v2_dcline(int dcline_id, real_type new_v_pu) {hvdc_lines_.change_v_side_2(dcline_id, new_v_pu, algo_controler_); } diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 864013f7..b7d7966a 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -18,13 +18,17 @@ SubstationContainer::StateRes SubstationContainer::get_state() const { std::vector sub_vn_kv(sub_vn_kv_.begin(), sub_vn_kv_.end()); std::vector bus_vn_kv(bus_vn_kv_.begin(), bus_vn_kv_.end()); + std::vector bus_vmin_kv(bus_vmin_kv_.begin(), bus_vmin_kv_.end()); + std::vector bus_vmax_kv(bus_vmax_kv_.begin(), bus_vmax_kv_.end()); SubstationContainer::StateRes res( n_sub_, nmax_busbar_per_sub_, sub_vn_kv, bus_status_, bus_vn_kv, - sub_names_); + sub_names_, + bus_vmin_kv, + bus_vmax_kv); return res; } @@ -47,6 +51,11 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) bus_status_ = bus_status; bus_vn_kv_ = RealVect::Map(&bus_vn_kv[0], bus_vn_kv.size()); sub_names_ = std::get<5>(my_state); + + std::vector & bus_vmin_kv = std::get<6>(my_state); + std::vector & bus_vmax_kv = std::get<7>(my_state); + bus_vmin_kv_ = bus_vmin_kv.empty() ? RealVect() : RealVect::Map(&bus_vmin_kv[0], bus_vmin_kv.size()); + bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(&bus_vmax_kv[0], bus_vmax_kv.size()); } void SubstationContainer::save_binary(const std::string & path) const { diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 677439fe..165a04c9 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -59,7 +59,9 @@ class LS2G_API SubstationContainer final : public IteratorAdder, // sub_vn_kv_; std::vector, // bus_status_; std::vector, // bus_vn_kv_; - std::vector // sub_names_ + std::vector, // sub_names_ + std::vector, // bus_vmin_kv_ (optional, empty if unset) + std::vector // bus_vmax_kv_ (optional, empty if unset) >; int nb() const {return n_sub_;} @@ -138,6 +140,21 @@ class LS2G_API SubstationContainer final : public IteratorAdder get_bus_vn_kv() const {return bus_vn_kv_;} + + // per-bus min/max operating voltage (kV), optional: empty if never set (e.g. pandapower-origin grids) + void init_bus_voltage_limits(const RealVect & bus_vmin_kv, const RealVect & bus_vmax_kv){ + if(static_cast(bus_vmin_kv.size()) != nb_bus()){ + throw std::runtime_error("SubstationContainer::init_bus_voltage_limits: bus_vmin_kv does not have the proper size."); + } + if(static_cast(bus_vmax_kv.size()) != nb_bus()){ + throw std::runtime_error("SubstationContainer::init_bus_voltage_limits: bus_vmax_kv does not have the proper size."); + } + bus_vmin_kv_ = bus_vmin_kv; + bus_vmax_kv_ = bus_vmax_kv; + } + Eigen::Ref get_bus_vmin_kv() const {return bus_vmin_kv_;} + Eigen::Ref get_bus_vmax_kv() const {return bus_vmax_kv_;} + bool is_bus_connected(const GridModelBusId & global_bus_id) const {return bus_status_[global_bus_id.cast_int()];} bool is_bus_connected(int sub_id, const LocalBusId & local_bus_id) const { return bus_status_[local_to_gridmodel(sub_id, local_bus_id).cast_int()]; @@ -231,6 +248,8 @@ class LS2G_API SubstationContainer final : public IteratorAdder bus_status_; RealVect bus_vn_kv_; std::vector sub_names_; + RealVect bus_vmin_kv_; // optional, empty if unset + RealVect bus_vmax_kv_; // optional, empty if unset }; diff --git a/src/core/element_container/Container_IteratorUtils.hpp b/src/core/element_container/Container_IteratorUtils.hpp index 997b084e..f311f27c 100644 --- a/src/core/element_container/Container_IteratorUtils.hpp +++ b/src/core/element_container/Container_IteratorUtils.hpp @@ -43,7 +43,17 @@ class GenericContainerConstIterator my_info(*data_, id) {}; - const DataInfo& operator*() const { return my_info; } + // Returns a COPY, not a reference: `my_info` is a single member reused/overwritten + // in place by every `operator++`/`operator--` (see below), so a caller holding onto + // a `const DataInfo&` across more than one increment would silently see it mutate + // out from under them. This bit pybind11's `__iter__` binding hard: `py::make_iterator` + // dereferences+advances the SAME C++ iterator for every `__next__()`, so + // `list(container)` (which exhausts the iterator before Python is done with any of + // the yielded objects) ended up handing back N aliases of the SAME final `my_info` + // state (mostly the past-the-end default-constructed one, id=-1) instead of N + // independent per-element snapshots. Returning by value forces pybind11 to copy at + // each step; `model.get_X()[i]` (`operator[]`, unaffected) was always correct. + DataInfo operator*() const { return my_info; } bool operator==(const GenericContainerConstIterator & other) const { return (my_id == other.my_id) && (_p_data_ == other._p_data_); } bool operator!=(const GenericContainerConstIterator & other) const { return !(*this == other); } GenericContainerConstIterator & operator++() diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index 1f19b595..b7486b81 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -282,6 +282,15 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer(nb()); for(int i = 0; i < nb_hvdc; ++i) if(is_droop_active(i)) return true; diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 122268b1..e4c1a461 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -9,6 +9,8 @@ #ifndef TWO_SIDES_CONTAINER_RXH_A_H #define TWO_SIDES_CONTAINER_RXH_A_H +#include + #include "TwoSidesContainer.hpp" namespace ls2g { @@ -73,6 +75,11 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer real_type res_a1_ka; real_type res_a2_ka; + // thermal (current) limit, in kA -- input, not a powerflow result; + // NaN if not provided when the grid was built + real_type limit_a1_ka; + real_type limit_a2_ka; + cplx_type yac_11; cplx_type yac_12; cplx_type yac_21; @@ -95,6 +102,8 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer has_res(false), res_a1_ka(0.), res_a2_ka(0.), + limit_a1_ka(std::numeric_limits::quiet_NaN()), + limit_a2_ka(std::numeric_limits::quiet_NaN()), yac_11(0., 0.), yac_12(0., 0.), yac_21(0., 0.), @@ -122,6 +131,9 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer res_a2_ka = r_data.res_a_side_2_.coeff(my_id); } + if(r_data.limit_a1_ka_.size() > 0) limit_a1_ka = r_data.limit_a1_ka_.coeff(my_id); + if(r_data.limit_a2_ka_.size() > 0) limit_a2_ka = r_data.limit_a2_ka_.coeff(my_id); + // coeffs yac_11 = r_data.yac_11_.coeff(my_id); yac_12 = r_data.yac_12_.coeff(my_id); @@ -153,9 +165,22 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer std::vector, // branch_r std::vector, // branch_x std::vector, // branch_h1 - std::vector // branch_h2 + std::vector, // branch_h2 + std::vector, // limit_a1_ka (optional, empty if unset) + std::vector // limit_a2_ka (optional, empty if unset) >; + // thermal (current) limit, in kA, per side -- input, not a powerflow result. + // Optional: empty (size 0) if never set (e.g. pandapower-origin grids). + void set_thermal_limit(const RealVect & limit_a1_ka, const RealVect & limit_a2_ka){ + check_size(limit_a1_ka, nb(), "TwoSidesContainer_rxh_A::set_thermal_limit (side 1)"); + check_size(limit_a2_ka, nb(), "TwoSidesContainer_rxh_A::set_thermal_limit (side 2)"); + limit_a1_ka_ = limit_a1_ka; + limit_a2_ka_ = limit_a2_ka; + } + Eigen::Ref get_limit_a1_ka() const {return limit_a1_ka_;} + Eigen::Ref get_limit_a2_ka() const {return limit_a2_ka_;} + // getter (results) tuple4d get_res_side_1() const { const tuple3d & side_1_res = TwoSidesContainer::get_res_side_1(); @@ -792,12 +817,16 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer std::vector branch_x(x_.begin(), x_.end()); std::vector branch_h1(h_side_1_.begin(), h_side_1_.end()); std::vector branch_h2(h_side_2_.begin(), h_side_2_.end()); + std::vector limit_a1_ka(limit_a1_ka_.begin(), limit_a1_ka_.end()); + std::vector limit_a2_ka(limit_a2_ka_.begin(), limit_a2_ka_.end()); StateRes res( get_tsc_state(), branch_r, branch_x, branch_h1, - branch_h2 + branch_h2, + limit_a1_ka, + limit_a2_ka ); return res; } @@ -820,6 +849,21 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer x_ = RealVect::Map(&branch_x[0], size); h_side_1_ = CplxVect::Map(&branch_h1[0], size); h_side_2_ = CplxVect::Map(&branch_h2[0], size); + + const std::vector & limit_a1_ka = std::get<5>(my_state); + const std::vector & limit_a2_ka = std::get<6>(my_state); + if(limit_a1_ka.size() > 0){ + check_size(limit_a1_ka, size, "limit_a1_ka"); + limit_a1_ka_ = RealVect::Map(&limit_a1_ka[0], size); + } else { + limit_a1_ka_ = RealVect(); + } + if(limit_a2_ka.size() > 0){ + check_size(limit_a2_ka, size, "limit_a2_ka"); + limit_a2_ka_ = RealVect::Map(&limit_a2_ka[0], size); + } else { + limit_a2_ka_ = RealVect(); + } } void reset_results_tsc_rxha(){ @@ -982,13 +1026,17 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer ydc_12_(el_id) = -tmp; } - protected: + protected: // physical properties RealVect r_; // in pu RealVect x_; // in pu CplxVect h_side_1_; // in pu CplxVect h_side_2_; // in pu + // input data (optional, empty if unset) + RealVect limit_a1_ka_; // thermal (current) limit, side 1, in kA + RealVect limit_a2_ka_; // thermal (current) limit, side 2, in kA + //output data RealVect res_a_side_1_; // in kA RealVect res_a_side_2_; // in kA From 83887cd5f989f6dd7da742b078d141ccfd81088f Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 2 Jul 2026 17:31:16 +0200 Subject: [PATCH 019/166] fix another issue in the grid baking Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_from_pypowsybl.py | 28 +++-- .../network/from_pypowsybl/_olf_bake.py | 104 +++++++++++++++++- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 5e41e0af..ee997f96 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -712,20 +712,34 @@ def init(net : pypo.network.Network, model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) # do the generators + gen_attrs = [ + "connected", "max_p", "target_p", "target_v", "target_q", "p", + "voltage_regulator_on", "regulated_element_id", "voltage_level_id", "bus_id", + "min_q", "max_q", "min_q_at_target_p", "max_q_at_target_p", + ] if sort_index: - df_gen = net.get_generators().sort_index() + df_gen = net.get_generators(attributes=gen_attrs).sort_index() else: - df_gen = net.get_generators() - - # to handle encoding in 32 bits and overflow when "splitting" the Q values among + df_gen = net.get_generators(attributes=gen_attrs) + + # to handle encoding in 32 bits and overflow when "splitting" the Q values among min_float_value = np.finfo(np.float32).min * 1e-4 + 1. max_float_value = np.finfo(np.float32).max * 1e-4 + 1. - min_q_aux = 1. * df_gen["min_q"].values + # "min_q"/"max_q" (the flat MIN_MAX box) are NaN for a generator whose + # reactive_limits_kind is CURVE -- pypowsybl only populates those through + # "min_q_at_target_p"/"max_q_at_target_p" (the capability curve evaluated at the + # generator's own target P, available even before any loadflow has been run, + # unlike "min_q_at_p" which depends on a solved "p"). Without this, every + # CURVE-kind generator silently got the "no limit" float32 sentinel below + # regardless of its real reactive range. + min_q_src = df_gen["min_q_at_target_p"].where(df_gen["min_q_at_target_p"].notna(), df_gen["min_q"]) + max_q_src = df_gen["max_q_at_target_p"].where(df_gen["max_q_at_target_p"].notna(), df_gen["max_q"]) + min_q_aux = 1. * min_q_src.values too_small = min_q_aux < min_float_value min_q_aux[too_small] = min_float_value min_q = min_q_aux.astype(np.float32) - - max_q_aux = 1. * df_gen["max_q"].values + + max_q_aux = 1. * max_q_src.values too_big = np.abs(max_q_aux) > max_float_value max_q_aux[too_big] = np.sign(max_q_aux[too_big]) * max_float_value max_q = max_q_aux.astype(np.float32) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 3393f53b..74b080b3 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -57,6 +57,17 @@ generator/VSC reactive-limit switch above. An SVC still comfortably inside its envelope is left regulating (its target reproduces the OLF result exactly, since it isn't saturated). +* Static var compensators carrying a "standby automaton" (``standby=True``): + OLF's own outer loop starts these as a fixed ``b0`` shunt (no voltage + control) and only switches them to active voltage control once their bus + voltage crosses a low/high threshold. The static ``regulating`` / + ``target_v`` attributes do *not* reflect this -- they read as if the SVC + were always actively regulating. Resolved before the reactive-limit + switch above: an SVC that stayed within its deadband is frozen to fixed-Q + (0 when, as commonly configured, ``b0 == 0``); one that crossed a + threshold is switched to ``VOLTAGE`` mode targeting the corresponding + ``low_voltage_setpoint`` / ``high_voltage_setpoint`` (then still eligible + for the ordinary saturation freeze). Verified against pypowsybl 1.15.0. The OLF-internal round trip (solve with outer loops -> bake -> solve loop-free) reproduces bus voltages to ~2e-4 kV / @@ -65,11 +76,31 @@ regulating voltage, a shunt, and ratio + phase tap changers. """ +import numpy as np import pandas as pd -# Tolerance (MVAr) for deciding a reactive injection sits "at" its Q limit. -_Q_LIMIT_TOL = 1e-3 +# Tolerance (MVAr) for deciding a reactive injection sits "at" its Q limit: the +# larger of a small absolute floor (catches exact/near-exact hits, dominated by +# float rounding) and a fraction of the unit's own Q range (catches OLF's discrete +# reactive-limit outer loop settling a hair inside the limit -- e.g. because P kept +# shifting slightly in later outer-loop iterations after the PV->PQ switch already +# happened). A fixed absolute tolerance alone is either too tight for large-range +# units (missing genuine saturation) or too loose for small-range ones (freezing +# units that still have real headroom). +_Q_LIMIT_TOL_ABS = 1e-3 +_Q_LIMIT_TOL_REL = 0.005 # 0.5% of (qmax - qmin) + + +def _q_limit_tol(qmin, qmax): + rng = np.asarray(qmax) - np.asarray(qmin) + # An unset reactive-limit box defaults to +/-Double.MAX in pypowsybl (an + # "unbounded" sentinel, not a real 3.6e308 MVAr range): a relative tolerance on + # that would swallow every generator. Treat absurdly large / non-finite ranges + # as "no limit" -- no relative bonus, just the absolute floor -- rather than + # let them dominate the max(). + rng = np.where(np.isfinite(rng) & (rng < 1e6), rng, 0.0) + return np.maximum(_Q_LIMIT_TOL_ABS, _Q_LIMIT_TOL_REL * rng) def _keep_only_main_comp(df_el, df_bus): @@ -109,8 +140,9 @@ def _bound_at_qlimit(df: pd.DataFrame, regulating: pd.Series): """ q_gen = -df["q"] # result column is load convention; flip to generator qmin, qmax = _reactive_limits(df) - at_max = regulating & (q_gen >= qmax - _Q_LIMIT_TOL) - at_min = regulating & (q_gen <= qmin + _Q_LIMIT_TOL) + tol = _q_limit_tol(qmin, qmax) + at_max = regulating & (q_gen >= qmax - tol) + at_min = regulating & (q_gen <= qmin + tol) return (at_max | at_min), q_gen @@ -267,9 +299,70 @@ def _bake_reactive_limit_switches( upd["voltage_regulator_on"] = False network.update_vsc_converter_stations(upd) + _bake_svc_standby(network, keep_only_main_comp) _bake_svc_saturation(network, keep_only_main_comp) +def _bake_svc_standby(network, keep_only_main_comp=True): + """Resolve an SVC's "standby automaton" (PowSyBl OLF's ``MonitoringVoltageOuterLoop``) + to the state the outer loop actually settled on. + + A standby SVC (``standbyAutomaton`` extension, ``standby=True``) starts the outer loop + as a fixed-susceptance shunt (``b0``, no voltage control) rather than a normal + voltage-regulating device -- regardless of what its static ``regulating`` / + ``regulation_mode`` / ``target_v`` attributes say. It only switches to active PV control + -- targeting ``low_voltage_setpoint`` / ``high_voltage_setpoint`` -- once its own bus + voltage crosses ``low_voltage_threshold`` / ``high_voltage_threshold``. Inside the + deadband it stays a plain ``b0`` shunt: the realized ``q`` there already equals + ``b0 * v_mag_kv ** 2`` (0 when, as commonly configured, ``b0 == 0``), so freezing it to + that realized value -- like a saturated SVC -- reproduces the deadband state exactly. + + Runs before ``_bake_svc_saturation``: an SVC resolved to VOLTAGE mode here (crossed a + threshold) is still eligible for the ordinary saturation freeze; an SVC resolved to + REACTIVE_POWER here is already frozen and the saturation check leaves it alone (it only + looks at ``regulation_mode == "VOLTAGE"``). + """ + try: + automaton = network.get_extensions("standbyAutomaton") + except Exception: # noqa: BLE001 - extension unsupported / absent on old pypowsybl + return + automaton = automaton[automaton["standby"]] + if not len(automaton): + return + df_bus = network.get_buses(attributes=["v_mag", "synchronous_component"]) + svc = network.get_static_var_compensators(attributes=["connected", "bus_id"]) + svc = svc.loc[svc.index.intersection(automaton.index)] + if keep_only_main_comp: + svc = _keep_only_main_comp(svc, df_bus) + automaton = automaton.loc[svc.index] + if not len(automaton): + return + + v_kv = df_bus.loc[svc["bus_id"].values, "v_mag"].to_numpy() + above_high = v_kv > automaton["high_voltage_threshold"].to_numpy() + below_low = v_kv < automaton["low_voltage_threshold"].to_numpy() + + active = above_high | below_low + if active.any(): + upd = pd.DataFrame(index=automaton.index[active]) + upd["target_v"] = np.where( + above_high[active], + automaton["high_voltage_setpoint"].to_numpy()[active], + automaton["low_voltage_setpoint"].to_numpy()[active], + ) + upd["regulation_mode"] = "VOLTAGE" + network.update_static_var_compensators(upd) + + idle_ids = automaton.index[~active] + if len(idle_ids): + # realized "q" (receptor convention, matches target_q) already equals b0 * v^2 + svc_q = network.get_static_var_compensators(attributes=["q"]).loc[idle_ids, "q"] + upd = pd.DataFrame(index=idle_ids) + upd["target_q"] = svc_q + upd["regulation_mode"] = "REACTIVE_POWER" + network.update_static_var_compensators(upd) + + def _bake_svc_saturation(network, keep_only_main_comp=True): """Freeze a VOLTAGE-mode SVC whose realized reactive output sits at (or beyond) its voltage-dependent susceptance envelope to fixed-Q (REACTIVE_POWER mode), @@ -296,7 +389,8 @@ def _bake_svc_saturation(network, keep_only_main_comp=True): q_gen = -svc["q"].to_numpy() qmax = svc["b_max"].to_numpy() * v_kv ** 2 # Q[MVAr] = B[S] * V[kV]^2 qmin = svc["b_min"].to_numpy() * v_kv ** 2 - mask = (q_gen >= qmax - _Q_LIMIT_TOL) | (q_gen <= qmin + _Q_LIMIT_TOL) + tol = _q_limit_tol(qmin, qmax) + mask = (q_gen >= qmax - tol) | (q_gen <= qmin + tol) if mask.any(): upd = pd.DataFrame(index=svc.index[mask]) # unlike Generator.target_q (already generator convention), StaticVarCompensator From 00d98c076d7d93d23546d73a280847fa660dfc92 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 2 Jul 2026 17:59:04 +0200 Subject: [PATCH 020/166] baking the 'pseudo off' generators -> remove them from PV in olf_bake Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 17 ++++++ .../network/from_pypowsybl/_olf_bake.py | 58 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 959d01b5..9aa8fe21 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,23 @@ Change Log - code `helm` powerflow method - interface with gridpack (to enforce q limits for example) - maybe have a look at suitesparse "sliplu" tools ? +- `GeneratorContainer::is_pseudo_off` (used when ``turnedoff_no_pv`` / + ``LightSimBackend(turned_off_pv=False)`` is active) is not implemented as + originally intended: it currently only checks ``target_p == 0`` to decide a + generator is "pseudo off" (and so should not pin its bus voltage). It was meant + to emulate PowSyBl OpenLoadFlow's own rule + (``generatorsWithZeroMwTargetAreNotStarted``, + ``AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl``), which + additionally requires ``min_p > 0`` (a generator with ``min_p <= 0`` is allowed + to legitimately sit at 0 MW and stays a normal voltage-controlling PV bus even + at ``p == 0``). ``GeneratorContainer`` does not currently store ``min_p`` at + all. There should be a convention (and a stored ``min_p`` per generator) so + ``is_pseudo_off`` can check ``target_p == 0 AND min_p > 0``, matching OLF. For + now this OLF-specific behavior is instead reproduced Python-side, on the + pypowsybl-loading path only, by ``lightsim2grid.network.from_pypowsybl. + bake_outer_loops`` (see ``_bake_generator_not_started`` in ``_olf_bake.py``), + which rewrites ``voltage_regulator_on=False`` in the IIDM network for such + generators before conversion, rather than relying on this C++ mechanism. TODO: speed directly update the pv, pq, Sbus and Ybus part when updating the elements (less error prone and faster to recompute). Then what is passed to the solver diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 74b080b3..41c21e74 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -41,6 +41,13 @@ guarded: it only happens where the changer was regulating AND a solved value exists, because ``solved_tap_position`` is NaN for changers that did not take part in an outer loop -- copying NaN would destroy the input tap. +* Generators dispatched at (approximately) 0 MW with a strictly positive + ``min_p``: OLF never sets these up as voltage-controlling PV buses in the + first place (``generatorsWithZeroMwTargetAreNotStarted``, "not started"), + regardless of their ``voltage_regulator_on``/``target_v`` attributes. Frozen + to fixed-Q (their realized reactive output) before the reactive-limit switch + below runs. A generator with ``min_p <= 0`` (legitimately allowed to sit at + 0 MW) is left alone. * PV -> PQ reactive-limit switches (generators, VSC converter stations): only the units whose realized reactive power actually sits at a Q limit are frozen to fixed-Q at that value, with voltage regulation switched off. Units still @@ -262,9 +269,60 @@ def _bake_taps_and_sections(network, keep_only_main_comp=True): network.update_shunt_compensators(upd) +# Tolerance (MW) for deciding a generator's target_p is "zero": mirrors OLF's own +# POWER_EPSILON_SI = 1e-4 MW (AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl). +_ZERO_P_TOL = 1e-4 + + +def _bake_generator_not_started(network, keep_only_main_comp=True): + """Freeze a voltage-regulating generator dispatched at (approximately) 0 MW, + with a strictly positive minimum active power, to fixed-Q (PQ) -- mirroring + PowSyBl OpenLoadFlow's own generator setup rule (default-on parameter + ``generatorsWithZeroMwTargetAreNotStarted``, + ``AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl``): such a + generator is declared unable to legitimately run at 0 MW (``min_p > 0``), so + OLF treats it as "not started" and never sets it up as a voltage-controlling + PV bus in the first place -- regardless of its ``voltage_regulator_on`` / + ``target_v`` attributes, which read as if it were actively regulating. A + generator with ``min_p <= 0`` (legitimately allowed to sit at 0 MW, e.g. a + curtailed renewable) is NOT affected -- OLF keeps it on voltage control. + + ``lightsim2grid``'s own C++ analogue (``GeneratorContainer::is_pseudo_off``, + gated by ``turnedoff_no_pv`` / ``LightSimBackend(turned_off_pv=False)``) does + not check ``min_p`` yet (see the CHANGELOG.rst TODO) and is off by default + anyway (Grid2Op semantics: an agent redispatching a generator to ~0 MW + mid-episode should not silently also drop its voltage support) -- so this + OLF-specific case is instead resolved here, on the pypowsybl-loading path + only, the same way ``_bake_svc_standby`` resolves the SVC standby automaton. + + Runs before the reactive-limit-switch check below: a generator resolved to PQ + here is already frozen and skipped there (it only looks at + ``voltage_regulator_on == True``). + """ + df_bus = network.get_buses(attributes=["synchronous_component"]) + gen = network.get_generators( + attributes=["voltage_regulator_on", "target_p", "min_p", "q", "connected", "bus_id"] + ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) + not_started = ( + gen["voltage_regulator_on"] + & (gen["target_p"].abs() < _ZERO_P_TOL) + & (gen["min_p"] > _ZERO_P_TOL) + ) + if not not_started.any(): + return + upd = pd.DataFrame(index=gen.index[not_started]) + # result "q" is load/receptor convention; Generator.target_q is generator convention + upd["target_q"] = -gen["q"].to_numpy()[not_started] + upd["voltage_regulator_on"] = False + network.update_generators(upd) + + def _bake_reactive_limit_switches( network, keep_only_main_comp=True): + _bake_generator_not_started(network, keep_only_main_comp) df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ From 72a598f2e7a797f12945e1dbfb4631452623aab5 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 3 Jul 2026 10:20:24 +0200 Subject: [PATCH 021/166] add handling of limit violation in contingency analysis, fix warnings cpp side Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 12 + benchmarks/benchmark_ca.py | 88 +++- docs/security_analysis.rst | 83 ++++ lightsim2grid/contingencyAnalysis.py | 175 +++++++- .../network/from_pypowsybl/_from_pypowsybl.py | 6 +- ...st_ContingencyAnalysis_limit_violations.py | 383 ++++++++++++++++++ lightsim2grid/tests/test_limits_pypowsybl.py | 7 +- src/bindings/python/binding_batch.cpp | 52 ++- src/bindings/python/binding_containers.cpp | 8 +- src/bindings/python/binding_lsgrid.cpp | 12 +- src/core/DataConverter.cpp | 4 +- src/core/LSGrid.cpp | 20 +- src/core/LSGrid.hpp | 26 +- src/core/SubstationContainer.hpp | 4 +- .../batch_algorithm/BaseBatchSolverSynch.hpp | 4 +- .../batch_algorithm/ContingencyAnalysis.cpp | 256 +++++++++++- .../batch_algorithm/ContingencyAnalysis.hpp | 85 +++- src/core/batch_algorithm/LimitViolation.hpp | 42 ++ src/core/batch_algorithm/TimeSeries.hpp | 4 +- .../Container_IteratorUtils.hpp | 2 +- .../ConverterStationContainer.cpp | 4 +- .../element_container/GeneratorContainer.cpp | 8 +- .../element_container/GeneratorContainer.hpp | 12 +- .../element_container/GenericContainer.cpp | 8 +- .../element_container/GenericContainer.hpp | 60 +-- .../element_container/HvdcLineContainer.hpp | 12 +- src/core/element_container/LoadContainer.cpp | 2 +- src/core/element_container/LoadContainer.hpp | 12 +- .../element_container/OneSideContainer.hpp | 24 +- .../element_container/OneSideContainer_PQ.hpp | 20 +- .../OneSideContainer_forBranch.hpp | 24 +- src/core/element_container/SGenContainer.cpp | 2 +- src/core/element_container/SGenContainer.hpp | 12 +- src/core/element_container/ShuntContainer.cpp | 10 +- src/core/element_container/ShuntContainer.hpp | 6 +- .../element_container/StorageContainer.cpp | 2 +- .../element_container/StorageContainer.hpp | 12 +- src/core/element_container/SvcContainer.cpp | 4 +- src/core/element_container/TrafoContainer.cpp | 2 +- src/core/element_container/TrafoContainer.hpp | 16 +- .../element_container/TwoSidesContainer.hpp | 40 +- .../TwoSidesContainer_rxh_A.hpp | 24 +- src/core/powerflow_algorithm/BaseAlgo.cpp | 4 +- src/core/powerflow_algorithm/BaseAlgo.hpp | 38 +- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 8 +- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 2 +- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 2 +- src/core/powerflow_algorithm/NRSystem.tpp | 2 +- .../powerflow_algorithm/ScalingPolicies.hpp | 2 +- 49 files changed, 1399 insertions(+), 248 deletions(-) create mode 100644 lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py create mode 100644 src/core/batch_algorithm/LimitViolation.hpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9aa8fe21..e0a72390 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -122,6 +122,18 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. validated `_ls_to_orig` against `substations_.nb_bus()` before `substations_` itself had been restored on the fresh instance pickle/binary loading constructs, so that size was always 0. Fixed by restoring `substations_` first. +- [FIXED] a member-initialization-order bug in `ContingencyAnalysis`'s constructor + (compiler ``-Wreorder`` warning): ``_compute_limit_violations_`` was listed before + ``_li_defaults`` in the initializer list although declared after it in the class. + C++ always initializes members in declaration order regardless of initializer-list + order, so the list was misleading (harmless today since there is no data dependency + between the two, but a latent hazard for future refactors). The initializer list now + matches declaration order. +- [IMPROVED] (cpp) the codebase now compiles warning-free under ``-Wall -Wextra + -Werror``: fixed 29 signed/unsigned comparison warnings (``int`` / ``Eigen::Index`` + vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / + default-virtual-method signatures (kept as ``/*name*/`` comments for documentation, + matching the convention already used elsewhere in the codebase). - [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so diff --git a/benchmarks/benchmark_ca.py b/benchmarks/benchmark_ca.py index 7cbadc6b..8002f22a 100644 --- a/benchmarks/benchmark_ca.py +++ b/benchmarks/benchmark_ca.py @@ -59,10 +59,68 @@ "case9241pegase.json" ] +MAX_CONT = 1000 # cap the number of n-1 contingencies simulated, for speed + + +def _set_benchmark_limits(grid): + """Set voltage / current limits on the grid: loose enough that they are not expected + to be violated (so this does not change convergence behaviour), but present, so that + `compute_limit_violations=True` actually exercises its per-element checks instead of + early-returning on empty limit vectors.""" + vn_kv = grid.get_bus_vn_kv() + grid.set_bus_voltage_limits(0.5 * vn_kv, 1.5 * vn_kv) + n_line = len(grid.get_lines()) + n_trafo = len(grid.get_trafos()) + grid.set_line_current_limit_side1(np.full(n_line, 1e4)) + grid.set_line_current_limit_side2(np.full(n_line, 1e4)) + grid.set_trafo_current_limit_side1(np.full(n_trafo, 1e4)) + grid.set_trafo_current_limit_side2(np.full(n_trafo, 1e4)) + + +def _run_one_config(env_lightsim, *, init_from_n_powerflow=False, + handle_disconnected_grid=False, compute_limit_violations=False, + nb_thread=1): + """Run all n-1 contingencies (capped at MAX_CONT) for one (env, configuration) pair and + return (pf / s, nb_solved) so the caller can report timings. + + .. note:: + `sa.close()` also resets the underlying `computer`'s counters, so the values needed + by the caller are extracted *before* closing, not returned as the (now-reset) computer + object itself. + """ + env_lightsim.reset() + if compute_limit_violations: + _set_benchmark_limits(env_lightsim.backend._grid) + sa = ContingencyAnalysis(env_lightsim, compute_limit_violations=compute_limit_violations) + for i in range(env_lightsim.n_line): + sa.add_single_contingency(i) + if i >= MAX_CONT: + break + sa.init_from_n_powerflow = init_from_n_powerflow + sa.handle_disconnected_grid = handle_disconnected_grid + sa.nb_thread = nb_thread + sa.get_flows() + computer_sa = sa.computer + total_time = computer_sa.total_time() + computer_sa.amps_computation_time() + nb_solved = computer_sa.nb_solved() + pf_per_s = nb_solved / total_time if total_time > 0. else float("nan") + sa.close() + return pf_per_s, nb_solved + + +# the 4 configurations compared below, each toggling exactly one flag away from the +# defaults (`ContingencyAnalysis.get_flows()` with none of these options set) +FEATURE_CONFIGS = [ + ("default", dict()), + ("init_from_n_powerflow", dict(init_from_n_powerflow=True)), + ("handle_disconnected_grid", dict(handle_disconnected_grid=True)), + ("compute_limit_violations", dict(compute_limit_violations=True)), +] if __name__ == "__main__": - + tab_features = [] # one row per grid size, one column per entry in FEATURE_CONFIGS + for case_name in tqdm(case_names): if not os.path.exists(case_name): @@ -138,5 +196,31 @@ linear_solver_used_str = solver_names[env_lightsim.backend._grid.get_solver_type()] for k, v in cases_by_threads.items(): print(f"{case_name}, nb_threads={k}: {cases_by_threads[1] / v:.1f}x speed-up vs 1 thread") + + # compare the "default", "init_from_n_powerflow", "handle_disconnected_grid" and + # "compute_limit_violations" options against one another (single thread, so the + # comparison isolates the cost of each feature from thread-scaling effects above) + row = [get_env_name_displayed(case_name)] + pf_per_s_default = None + for config_name, kwargs in FEATURE_CONFIGS: + pf_per_s, nb_solved = _run_one_config(env_lightsim, **kwargs) + if config_name == "default": + pf_per_s_default = pf_per_s + if VERBOSE: + print(f"[{case_name}] {config_name}: {nb_solved} pf solved, " + f"{pf_per_s:.0f} pf / s ({1000. / pf_per_s:.3f} ms / pf)") + speed_ratio = pf_per_s / pf_per_s_default if pf_per_s_default else float("nan") + row.append(f"{pf_per_s:.0f} pf/s ({speed_ratio:.2f}x default)") + tab_features.append(row) + env_lightsim.close() - break \ No newline at end of file + + print("\nComparison of `default` / `init_from_n_powerflow` / `handle_disconnected_grid` / " + "`compute_limit_violations` (single thread, up to " + f"{MAX_CONT + 1} n-1 contingencies per grid)") + if TABULATE_AVAIL: + print(tabulate(tab_features, + headers=["grid"] + [name for name, _ in FEATURE_CONFIGS], + tablefmt="rst")) + else: + print(tab_features) \ No newline at end of file diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index 438367cf..3cbf72d2 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -147,6 +147,89 @@ This feature only relies on the C++ standard library (``std::thread``): no addit ``nb_thread`` is also available on the lower-level ``ContingencyAnalysisCPP`` class (same semantics). +Reporting limit violations +--------------------------- + +Starting from lightsim2grid 0.14.0, if you have set some operating limits on the grid model +(per-bus voltage bounds with :func:`lightsim2grid.network.LSGrid.set_bus_voltage_limits`, +per-side current limits on lines / trafos with +:func:`lightsim2grid.network.LSGrid.set_line_current_limit_side1` / +:func:`lightsim2grid.network.LSGrid.set_line_current_limit_side2` / +:func:`lightsim2grid.network.LSGrid.set_trafo_current_limit_side1` / +:func:`lightsim2grid.network.LSGrid.set_trafo_current_limit_side2`), ``ContingencyAnalysis`` can +report, for the pre-contingency ("n") case and for each simulated contingency, which of these +limits are violated. The API is modeled after pypowsybl's security analysis +(``res.pre_contingency_result.limit_violations`` / ``res.post_contingency_results``), with the +notable difference that ``post_contingency_results`` is a **list** (ordered like the +contingencies were added), not a dictionary: + +.. code-block:: python + + import numpy as np + import grid2op + from lightsim2grid import ContingencyAnalysis + from lightsim2grid import LightSimBackend + env = grid2op.make(..., backend=LightSimBackend()) + + # set some limits on the grid model (kV for buses, kA for lines / trafos) + grid = env.backend._grid + nb_bus = grid.get_bus_vn_kv().shape[0] + grid.set_bus_voltage_limits(0.95 * grid.get_bus_vn_kv(), 1.05 * grid.get_bus_vn_kv()) + grid.set_line_current_limit_side1(line_limit_a1_ka) + grid.set_line_current_limit_side2(line_limit_a2_ka) + grid.set_trafo_current_limit_side1(trafo_limit_a1_ka) + grid.set_trafo_current_limit_side2(trafo_limit_a2_ka) + + # the feature must be explicitly requested at construction time + security_analysis = ContingencyAnalysis(env, compute_limit_violations=True) + security_analysis.add_single_contingency(0, name="line_0") # `name` is optional + security_analysis.add_all_n1_contingencies() + + res = security_analysis.run() # or run_ac() / run_dc() to also pick the algorithm family + + for violation in res.pre_contingency_result.limit_violations: + print(violation.element_type, violation.element_id, violation.side, + violation.violation_type, violation.value, violation.limit) + + for cont in res.post_contingency_results: # a list, in the order contingencies were added + print(cont.element_ids, cont.contingency_name, cont.converged, cont.limit_violations) + +Each ``LimitViolation`` reports: + +- ``element_type``: :class:`lightsim2grid.contingencyAnalysis.ViolationElementType` + (``BUS``, ``LINE`` or ``TRAFO``); +- ``element_id``: the grid-model bus id (for ``BUS``) or the local line / trafo id (for + ``LINE`` / ``TRAFO``); +- ``side``: ``1`` or ``2`` for ``LINE`` / ``TRAFO`` (unused, ``0``, for ``BUS``); +- ``violation_type``: :class:`lightsim2grid.contingencyAnalysis.LimitViolationType` + (``LOW_VOLTAGE``, ``HIGH_VOLTAGE`` or ``CURRENT``); +- ``value`` / ``limit``: the value reached and the limit that was violated. + +Each ``ContingencyResult`` reports ``element_ids`` (the branch ids disconnected by this +contingency -- always present, even without a ``name``), the optional user-supplied +``contingency_name`` (set via ``add_single_contingency(..., name=...)``), whether the +contingency ``converged``, and its ``limit_violations``. + +.. warning:: + + This feature is **opt-in** and must be requested at construction time + (``ContingencyAnalysis(env, compute_limit_violations=True)``, or by setting + ``security_analysis.compute_limit_violations = True`` before running). Leaving it to its + default (``False``) means ``run`` / ``run_ac`` / ``run_dc`` raise a ``RuntimeError``, and + the usual ``get_flows()`` is completely unaffected -- there is no need to pay for the extra + per-element voltage / current checks if you only want the flows. + + Also, if a contingency (or the pre-contingency case) does not converge, its + ``limit_violations`` is left empty because no result is available for it -- this does + **not** mean there is no violation, unlike a converged case with an empty list, which + genuinely means none was found. + +Violation checking is fully compatible with the ``handle_disconnected_grid`` and ``nb_thread`` +options described above: it is performed **inline, per contingency, inside the thread that +solves it** (not as a separate post-processing pass), so it does not affect the multi-threading +performance characteristics; and masked (disconnected-island) buses are correctly excluded from +the voltage checks. + .. _sa_benchmarks: Benchmarks (Contingency Analysis) diff --git a/lightsim2grid/contingencyAnalysis.py b/lightsim2grid/contingencyAnalysis.py index d6fc1ea9..c3197ffb 100644 --- a/lightsim2grid/contingencyAnalysis.py +++ b/lightsim2grid/contingencyAnalysis.py @@ -6,14 +6,19 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__all__ = ["ContingencyAnalysisCPP"] +__all__ = ["ContingencyAnalysisCPP", "LimitViolation", "ViolationElementType", + "LimitViolationType", "PreContingencyResult", + "ContingencyResult", "SecurityAnalysisResult"] import copy +from dataclasses import dataclass +from typing import List, Optional, Tuple import numpy as np from collections.abc import Iterable from lightsim2grid.algorithm import AlgorithmType -from .lightsim2grid_cpp import ContingencyAnalysisCPP +from .lightsim2grid_cpp import (ContingencyAnalysisCPP, LimitViolation, + ViolationElementType, LimitViolationType) try: from lightsim2grid.lightSimBackend import LightSimBackend @@ -24,6 +29,40 @@ GRID2OP_INSTALLED = False +@dataclass +class PreContingencyResult: + """Limit violations for the pre-contingency ("n", no disconnection) case. + + .. note:: + If ``converged`` is False, ``limit_violations`` is empty because no result is + available -- this does NOT mean there is no violation. + """ + converged: bool + limit_violations: List[LimitViolation] + + +@dataclass +class ContingencyResult: + """Limit violations for a single simulated contingency. + + .. note:: + If ``converged`` is False (the contingency was skipped or diverged), ``limit_violations`` + is empty because no result is available -- this does NOT mean there is no violation. + """ + element_ids: List[int] #: branch ids (lines then trafos) disconnected by this contingency + contingency_name: Optional[str] #: user-supplied name, see `add_single_contingency` + converged: bool + limit_violations: List[LimitViolation] + + +@dataclass +class SecurityAnalysisResult: + """Result of `ContingencyAnalysis.run` / `run_ac` / `run_dc`, modeled after pypowsybl's + security analysis result.""" + pre_contingency_result: PreContingencyResult + post_contingency_results: List[ContingencyResult] + + class __ContingencyAnalysis(object): """ This class allows to perform a "security analysis" from a given grid state. @@ -87,7 +126,7 @@ class __ContingencyAnalysis(object): """ STR_TYPES = (str, np.str_) # np.str deprecated in numpy 1.20 and earlier versions not supported anyway - def __init__(self, grid2op_env): + def __init__(self, grid2op_env, compute_limit_violations: bool = False): if not GRID2OP_INSTALLED: raise RuntimeError("Impossible to use the python wrapper `ContingencyAnalysis` " "when grid2op is not installed. Please fall back to the " @@ -108,9 +147,10 @@ def __init__(self, grid2op_env): else: raise RuntimeError("`ContingencyAnalysis` can only be created " "with a grid2op `Environment` or a `LightSimBackend`") - self.computer = ContingencyAnalysisCPP(self._ls_backend._grid) + self.computer = ContingencyAnalysisCPP(self._ls_backend._grid, bool(compute_limit_violations)) self._contingency_order = {} # key: contingency (as tuple), value: order in which it is entered self._all_contingencies = [] + self._contingency_names = {} # key: contingency (as tuple), value: user-supplied name (or None) self.__computed = False self._vs = None self._ampss = None @@ -150,6 +190,27 @@ def handle_disconnected_grid(self, val: bool): raise ValueError("The `handle_disconnected_grid` attribute must be a boolean.") self.computer.handle_disconnected_grid = bool(val) + @property + def compute_limit_violations(self): + """Whether limit violations are computed inline, per contingency, during `run` / + `run_ac` / `run_dc` (see also `get_violations` on the underlying `computer`). Default: + false. Computing violations means an extra per-element current / voltage check in + every contingency's solve, so leave this off if you only need `get_flows`. Can only be + set at construction time (`ContingencyAnalysis(env, compute_limit_violations=True)`) or + via this setter; changing it clears any previously-computed results. + """ + return self.computer.compute_limit_violations + + @compute_limit_violations.setter + def compute_limit_violations(self, val: bool): + if bool(val) != val: + raise ValueError("The `compute_limit_violations` attribute must be a boolean.") + val = bool(val) + if val == self.computer.compute_limit_violations: + return # no-op, matches the C++ side (which also no-ops and does not clear) + self.computer.compute_limit_violations = val # this clears the C++-side results / contingencies + self.clear() # keep the python-side bookkeeping (contingency order / names) in sync + @property def nb_thread(self): """Number of OS threads used to solve the contingencies (default: 1). @@ -194,6 +255,7 @@ def clear(self, with_contlist=True): self.computer.clear() self._contingency_order = {} self._all_contingencies = [] + self._contingency_names = {} else: self.computer.clear_results_only() @@ -215,7 +277,7 @@ def _single_cont_to_li_int(self, single_cont): li_disc.append(stuff) return li_disc - def add_single_contingency(self, *args): + def add_single_contingency(self, *args, name=None): """ This function allows to add a single contingency specified by either the powerlines names (which should match env.name_line) or by their ID. @@ -223,7 +285,12 @@ def add_single_contingency(self, *args): The contingency added can be a "n-1" which will simulate a single powerline disconnection or a "n-k" which will simulate the disconnection of multiple powerlines. - It does not accept any keword arguments. + It does not accept any positional keyword arguments, but accepts the keyword-only + `name` argument: an optional, user-supplied string used to identify this contingency + in the result of `run` / `run_ac` / `run_dc` (`ContingencyResult.contingency_name`). If + not given, `contingency_name` is `None` for this contingency. If this exact contingency + was already registered (same set of disconnected elements), `name` is ignored (the + first registration wins). Examples -------- @@ -265,6 +332,7 @@ def add_single_contingency(self, *args): my_id = len(self._contingency_order) self._contingency_order[li_disc_tup] = my_id self._all_contingencies.append(li_disc_tup) + self._contingency_names[li_disc_tup] = name except Exception as exc_: raise RuntimeError(f"Impossible to add the contingency {args}. The most likely cause " f"is that you try to disconnect a powerline that is not present " @@ -389,6 +457,101 @@ def get_flows(self, *args): return self._mws[orders_], self._ampss[orders_], self._vs[orders_] + def run(self) -> SecurityAnalysisResult: + """ + Run this contingency analysis and report, for the pre-contingency ("n") case and for + each registered contingency, the list of limit violations (bus voltage out of + [vmin_kv, vmax_kv], line/trafo current above limit_a1_ka / limit_a2_ka -- see + `LSGrid.set_bus_voltage_limits` / `set_line_current_limit_side1` / `set_line_current_limit_side2` + / `set_trafo_current_limit_side1` / `set_trafo_current_limit_side2`). + + This requires `compute_limit_violations=True` (either passed at construction time or + set via ``this_instance.compute_limit_violations = True``), else a `RuntimeError` is + raised. Prefer `run_ac` / `run_dc` if you want to also select the algorithm family. + + The returned object mimics pypowsybl's security analysis result: + + .. code-block:: python + + res = security_analysis.run() + for v in res.pre_contingency_result.limit_violations: + ... + for cont in res.post_contingency_results: # a list, ordered like `add_single_contingency` calls + cont.element_ids # branch ids disconnected by this contingency (always present) + cont.contingency_name # optional, user-supplied via add_single_contingency(..., name=...) + cont.converged + cont.limit_violations + + .. note:: + A `converged == False` entry (pre- or post-contingency) has an empty + `limit_violations` list because no result is available for it (skipped or diverged + powerflow) -- this does NOT mean there is no violation, unlike a `converged == True` + entry with an empty list, which genuinely means no violation was found. + """ + if self.__is_closed: + raise RuntimeError("This is closed, you cannot use it.") + if not self.computer.compute_limit_violations: + raise RuntimeError("`run` (and `run_ac` / `run_dc`) require `compute_limit_violations=True`, " + "set either at construction time (`ContingencyAnalysis(env, " + "compute_limit_violations=True)`) or via " + "`this_instance.compute_limit_violations = True`.") + if not self.__computed: + self.compute_V() + + all_defaults = self.computer.my_defaults() + orders_ = np.zeros(len(all_defaults), dtype=int) + for id_cpp, cont_ in enumerate(all_defaults): + tup_ = tuple(cont_) + orders_[self._contingency_order[tup_]] = id_cpp + + converged = self.computer.converged() + violations = self.computer.get_violations() + + pre_contingency_result = PreContingencyResult( + converged=self.computer.converged_n(), + limit_violations=list(self.computer.get_violations_n()), + ) + post_contingency_results = [ + ContingencyResult( + element_ids=list(all_defaults[id_cpp]), + contingency_name=self._contingency_names.get(self._all_contingencies[id_me]), + converged=bool(converged[id_cpp]), + limit_violations=list(violations[id_cpp]), + ) + for id_me, id_cpp in enumerate(orders_) + ] + return SecurityAnalysisResult(pre_contingency_result, post_contingency_results) + + def _change_algorithm_family(self, want_dc: bool): + """internal: switch to a default AC / DC algorithm, but only if the current one is not + already of the requested family, so this does not needlessly `clear()` (and thus lose + the registered contingencies) when it is already the case.""" + current_is_dc = "DC_" in self.computer.get_algo_type().name + if current_is_dc == want_dc: + return + preferred = (["DC_KLU", "DC_SparseLU"] if want_dc else ["NR_KLU", "NR_SparseLU"]) + available = {a.name: a for a in self.computer.available_default_algorithms()} + for name in preferred: + if name in available: + self.change_algorithm(available[name]) + return + raise RuntimeError(f"Impossible to find a default {'DC' if want_dc else 'AC'} algorithm " + f"among {list(available.keys())}.") + + def run_ac(self) -> SecurityAnalysisResult: + """Like `run`, but first makes sure an AC algorithm is selected (switches to NR_KLU / + NR_SparseLU if the current algorithm is a DC one -- which clears any previously + registered contingency, exactly like `change_algorithm`; does nothing if already AC).""" + self._change_algorithm_family(want_dc=False) + return self.run() + + def run_dc(self) -> SecurityAnalysisResult: + """Like `run`, but first makes sure the DC algorithm is selected (switches to DC_KLU / + DC_SparseLU if the current algorithm is an AC one -- which clears any previously + registered contingency, exactly like `change_algorithm`; does nothing if already DC).""" + self._change_algorithm_family(want_dc=True) + return self.run() + def compute_V(self): """ This function allows to retrieve the complex voltage at each bus of the grid for each contingency. diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index ee997f96..3244f518 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -903,7 +903,8 @@ def init(net : pypo.network.Network, line_limit_groups["selected_limits_group_2"], ol_current, ) - model.set_line_thermal_limit(line_limit_a1_ka, line_limit_a2_ka) + model.set_line_current_limit_side1(line_limit_a1_ka) + model.set_line_current_limit_side2(line_limit_a2_ka) # for trafo # I extract trafo with `all_attributes=True` so that I have access to the `rho` @@ -990,7 +991,8 @@ def init(net : pypo.network.Network, trafo_limit_a1_ka, trafo_limit_a2_ka = _aux_current_limits( df_trafo.index, trafo_group_1, trafo_group_2, ol_current ) - model.set_trafo_thermal_limit(trafo_limit_a1_ka, trafo_limit_a2_ka) + model.set_trafo_current_limit_side1(trafo_limit_a1_ka) + model.set_trafo_current_limit_side2(trafo_limit_a2_ka) # phase-shifting transformers: declare the (alpha -> r/x correction) dependency so # lightsim2grid keeps the series impedance right when the shift changes, without any # "tap" concept (the per-step r/x deltas pypowsybl carries only in its tap steps). diff --git a/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py b/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py new file mode 100644 index 00000000..54c74266 --- /dev/null +++ b/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py @@ -0,0 +1,383 @@ +# Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Tests for the (opt-in) limit-violation reporting of `ContingencyAnalysis` / +`ContingencyAnalysisCPP`: `compute_limit_violations`, `converged` / `converged_n`, +`get_violations` / `get_violations_n`, and the python-side `run` / `run_ac` / `run_dc`. +""" + +import unittest +import warnings +import tempfile +import os +import numpy as np +import pandapower as pp +import pandapower.networks as pn + +from lightsim2grid.gridmodel import init_from_pandapower +from lightsim2grid.lightsim2grid_cpp import (ContingencyAnalysisCPP, + ViolationElementType, + LimitViolationType) +from lightsim2grid.algorithm import AlgorithmType + + +def _set_tight_limits(grid, vmin_kv=1e6, cur_ka=1e-6): + """makes every bus voltage and every line/trafo current violate: guarantees at least + one LOW_VOLTAGE and one CURRENT violation everywhere the corresponding element is used.""" + nb_bus = grid.get_bus_vn_kv().shape[0] + grid.set_bus_voltage_limits(np.full(nb_bus, vmin_kv), np.full(nb_bus, np.nan)) + n_line = len(grid.get_lines()) + n_trafo = len(grid.get_trafos()) + grid.set_line_current_limit_side1(np.full(n_line, cur_ka)) + grid.set_line_current_limit_side2(np.full(n_line, np.nan)) + grid.set_trafo_current_limit_side1(np.full(n_trafo, cur_ka)) + grid.set_trafo_current_limit_side2(np.full(n_trafo, np.nan)) + + +class TestFlagGating(unittest.TestCase): + """the whole feature must be opt-in: default False, and toggling it must not affect + the pre-existing get_flows / get_voltages API at all.""" + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + + def tearDown(self): + self.env.close() + + def test_default_is_off_and_raises(self): + SA = ContingencyAnalysisCPP(self.env.backend._grid) + assert SA.compute_limit_violations is False + SA.add_all_n1() + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + for fun_name in ("converged", "get_violations", "converged_n", "get_violations_n"): + with self.assertRaises(RuntimeError): + getattr(SA, fun_name)() + + def test_on_at_construction_works(self): + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + assert SA.compute_limit_violations is True + SA.add_all_n1() + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + SA.converged() + SA.get_violations() + SA.converged_n() + SA.get_violations_n() + + def test_flows_unaffected_by_flag(self): + lid_cont = [0, 1, 2, 3] + SA_off = ContingencyAnalysisCPP(self.env.backend._grid, False) + SA_off.add_multiple_n1(lid_cont) + SA_off.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + flows_off = SA_off.compute_flows() + + SA_on = ContingencyAnalysisCPP(self.env.backend._grid, True) + SA_on.add_multiple_n1(lid_cont) + SA_on.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + flows_on = SA_on.compute_flows() + + assert np.array_equal(flows_off, flows_on, equal_nan=True) + + def test_setter_toggle_clears_and_raises_when_off(self): + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + SA.add_all_n1() + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + assert len(SA.get_violations()) == len(SA.my_defaults()) + + SA.compute_limit_violations = False + assert len(SA.my_defaults()) == 0, "toggling the flag should clear() the object" + with self.assertRaises(RuntimeError): + SA.get_violations() + + +class TestConvergedFlag(unittest.TestCase): + """the `converged` flag must exactly reflect which contingencies actually produced a + result (and get_violations() must stay empty, not fabricated, for the others).""" + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + + def tearDown(self): + self.env.close() + + def test_converged_matches_nan_heuristic(self): + # 17 converges, 18 diverges (splits the grid), 19 converges -- see + # test_SecurityAnlysis_cpp.test_compute_nonconnected_graph for the reference behaviour + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + lid_cont = [17, 18, 19] + SA.add_multiple_n1(lid_cont) + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + res_v = SA.get_voltages() + res_flows = SA.compute_flows() + converged = SA.converged() + violations = SA.get_violations() + for cont_id in range(len(lid_cont)): + nan_heuristic_converged = np.all(np.isfinite(res_flows[cont_id])) and \ + np.max(np.abs(res_v[cont_id])) > 0. + assert bool(converged[cont_id]) == nan_heuristic_converged, \ + f"converged() disagrees with the nan/0 heuristic for contingency {cont_id}" + if not converged[cont_id]: + assert violations[cont_id] == [], \ + "a non-converged contingency must not report fabricated violations" + + +class TestThreadIndependence(unittest.TestCase): + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + _set_tight_limits(self.env.backend._grid) + + def tearDown(self): + self.env.close() + + def test_converged_and_violation_counts_match(self): + def _run(nb_thread): + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + SA.nb_thread = nb_thread + SA.add_all_n1() + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + return SA.converged(), SA.get_violations() + + conv1, viol1 = _run(1) + conv4, viol4 = _run(4) + assert conv1 == conv4 + assert [len(v) for v in viol1] == [len(v) for v in viol4] + + +class TestBasicViolations(unittest.TestCase): + """end-to-end: with deliberately tight limits, every kind of LimitViolationType / + ViolationElementType must show up, with the correct fields.""" + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + _set_tight_limits(self.env.backend._grid) + + def tearDown(self): + self.env.close() + + def test_base_case_and_per_contingency_violations(self): + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + SA.add_all_n1() + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + + assert SA.converged_n() is True + viol_n = SA.get_violations_n() + assert len(viol_n) > 0 + types_seen = {(v.element_type, v.violation_type) for v in viol_n} + assert (ViolationElementType.BUS, LimitViolationType.LOW_VOLTAGE) in types_seen + assert (ViolationElementType.LINE, LimitViolationType.CURRENT) in types_seen + assert (ViolationElementType.TRAFO, LimitViolationType.CURRENT) in types_seen + for v in viol_n: + assert np.isfinite(v.value) + assert np.isfinite(v.limit) + if v.violation_type == LimitViolationType.LOW_VOLTAGE: + assert v.value < v.limit + else: + assert v.value > v.limit + + violations = SA.get_violations() + assert sum(len(v) for v in violations) > 0 + + def test_disconnected_branch_of_its_own_contingency_is_skipped(self): + # a contingency must never report a CURRENT violation on the very branch it disconnects + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + SA.add_n1(0) + SA.compute(self.env.backend.V, self.env.backend.max_it, self.env.backend.tol) + viol0 = SA.get_violations()[0] + assert not any(v.element_type == ViolationElementType.LINE and v.element_id == 0 + for v in viol0) + + +class TestMaskedBusExclusion(unittest.TestCase): + """with `handle_disconnected_grid=True`, a masked (forced-to-0V) bus must never be + reported as a LOW_VOLTAGE violation.""" + def setUp(self): + self.max_it = 30 + self.tol = 1e-8 + net = pp.create_empty_network(sn_mva=1.) + for _ in range(4): + pp.create_bus(net, vn_kv=20.) + pp.create_ext_grid(net, 0, vm_pu=1.0) + pp.create_line_from_parameters(net, 0, 1, length_km=1., r_ohm_per_km=0.1, + x_ohm_per_km=0.3, c_nf_per_km=0., max_i_ka=1.) # line 0 + pp.create_line_from_parameters(net, 1, 2, length_km=1., r_ohm_per_km=0.1, + x_ohm_per_km=0.3, c_nf_per_km=0., max_i_ka=1.) # line 1 + pp.create_line_from_parameters(net, 2, 3, length_km=1., r_ohm_per_km=0.1, + x_ohm_per_km=0.3, c_nf_per_km=0., max_i_ka=1.) # line 2 (splits) + pp.create_load(net, 1, p_mw=1.0, q_mvar=0.2) + pp.create_load(net, 2, p_mw=1.0, q_mvar=0.2) + pp.create_load(net, 3, p_mw=1.0, q_mvar=0.2) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.grid = init_from_pandapower(net) + self.V0 = np.ones(self.grid.get_bus_vn_kv().shape[0], dtype=complex) + # any positive vmin_kv would make the masked (0V) bus 3 violate if it were checked + nb_bus = self.grid.get_bus_vn_kv().shape[0] + self.grid.set_bus_voltage_limits(np.full(nb_bus, 1.), np.full(nb_bus, np.nan)) + + def test_masked_bus_not_reported(self): + SA = ContingencyAnalysisCPP(self.grid, True) + SA.add_n1(2) # disconnect line 2 -> isolates bus 3 + SA.handle_disconnected_grid = True + SA.compute(1. * self.V0, self.max_it, self.tol) + v = SA.get_voltages() + assert v[0, 3] == 0., "sanity check: bus 3 should be masked (0V)" + assert SA.converged()[0] is True + viol0 = SA.get_violations()[0] + assert not any(v.element_type == ViolationElementType.BUS and v.element_id == 3 + for v in viol0), "the masked bus must be excluded from voltage checks" + + +class TestDCPhaseShiftSide2(unittest.TestCase): + """side-2 current computation (only exercised by limit-violation checking, the existing + batch API only ever computes side 1): build a grid with a phase-shifting transformer (so + dc_x_tau_shift != 0) and check both sides trigger sensible, finite, positive violations + for a very tight limit, in both AC and DC.""" + def _build(self): + case = pn.case14() + hv_bus, lv_bus = 0, 2 + pp.create_transformer_from_parameters( + case, hv_bus=hv_bus, lv_bus=lv_bus, sn_mva=9900.0, + vn_hv_kv=case.bus.iloc[hv_bus]["vn_kv"], vn_lv_kv=case.bus.iloc[lv_bus]["vn_kv"], + i0_percent=0.0, vk_percent=2070.288000, vkr_percent=0.0, + shift_degree=-10.0, pfe_kw=0., tap_side="hv") + case.name = "case14_shift" + with tempfile.TemporaryDirectory() as path: + case_name = os.path.join(path, "this_case.json") + pp.to_json(case, case_name) + from lightsim2grid import LightSimBackend + backend = LightSimBackend() + type(backend)._clear_grid_dependant_class_attributes() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + type(backend).env_name = case.name + backend.load_grid(case_name) + backend.assert_grid_correct() + backend._init_pp_backend._grid.ext_grid["in_service"] = False + backend._init_pp_backend._grid.ext_grid.loc[0, "in_service"] = True + backend._init_pp_backend._grid.gen["slack"] = False + return backend + + def _check_both_sides_trigger(self, is_dc): + backend = self._build() + conv, exc_ = backend.runpf(is_dc=is_dc) + assert conv, f"reference powerflow did not converge: {exc_}" + grid = backend._grid + trafos = grid.get_trafos() + idx = len(trafos) - 1 # the phase-shifting trafo we just added + + n_trafo = len(trafos) + lim1 = np.full(n_trafo, np.nan) + lim2 = np.full(n_trafo, np.nan) + lim1[idx] = 1e-9 # any nonzero current on this trafo will violate + lim2[idx] = 1e-9 + grid.set_trafo_current_limit_side1(lim1) + grid.set_trafo_current_limit_side2(lim2) + + SA = ContingencyAnalysisCPP(grid, True) + if is_dc: + SA.change_algorithm(AlgorithmType.DC_KLU) + SA.add_all_n1() + SA.compute(backend.V.copy(), 10, 1e-8) + + assert SA.converged_n() is True + viol_n = {v.side: v for v in SA.get_violations_n() if v.element_id == idx} + assert 1 in viol_n, "side 1 of the phase-shifting trafo should violate" + assert 2 in viol_n, "side 2 of the phase-shifting trafo should violate" + for side, v in viol_n.items(): + assert v.element_type == ViolationElementType.TRAFO + assert v.violation_type == LimitViolationType.CURRENT + assert np.isfinite(v.value) and v.value > 0. + + def test_ac(self): + self._check_both_sides_trigger(is_dc=False) + + def test_dc(self): + self._check_both_sides_trigger(is_dc=True) + + +class TestPythonWrapperRun(unittest.TestCase): + """end-to-end test of the python-side `ContingencyAnalysis.run` API (pypowsybl-like + shape: res.pre_contingency_result.limit_violations / res.post_contingency_results).""" + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + _set_tight_limits(self.env.backend._grid) + + def tearDown(self): + self.env.close() + + def test_run_requires_the_flag(self): + from lightsim2grid.contingencyAnalysis import ContingencyAnalysis + sa = ContingencyAnalysis(self.env) + sa.add_single_contingency(0) + with self.assertRaises(RuntimeError): + sa.run() + + def test_run_result_shape_order_and_names(self): + from lightsim2grid.contingencyAnalysis import (ContingencyAnalysis, SecurityAnalysisResult, + PreContingencyResult, ContingencyResult) + sa = ContingencyAnalysis(self.env, compute_limit_violations=True) + sa.add_single_contingency(4, name="fourth") + sa.add_single_contingency(0, name="first") + sa.add_single_contingency(7) # no name + + res = sa.run() + assert isinstance(res, SecurityAnalysisResult) + assert isinstance(res.pre_contingency_result, PreContingencyResult) + assert res.pre_contingency_result.converged is True + assert len(res.pre_contingency_result.limit_violations) > 0 + + assert isinstance(res.post_contingency_results, list) + assert len(res.post_contingency_results) == 3 + # order must match insertion order (4, 0, 7), not the c++-internal order + expected_ids = [[4], [0], [7]] + expected_names = ["fourth", "first", None] + for cont, exp_ids, exp_name in zip(res.post_contingency_results, expected_ids, expected_names): + assert isinstance(cont, ContingencyResult) + assert cont.element_ids == exp_ids + assert cont.contingency_name == exp_name + assert cont.converged is True + assert len(cont.limit_violations) > 0 + + def test_run_ac_and_run_dc(self): + from lightsim2grid.contingencyAnalysis import ContingencyAnalysis + sa = ContingencyAnalysis(self.env, compute_limit_violations=True) + sa.add_single_contingency(0) + res_ac = sa.run_ac() + assert len(res_ac.post_contingency_results) == 1 + + # switching family clears the registered contingencies (documented behaviour, + # mirrors change_algorithm) -- switch first, then (re-)register, then run + sa._change_algorithm_family(want_dc=True) + sa.add_single_contingency(0) + res_dc = sa.run_dc() + assert len(res_dc.post_contingency_results) == 1 + + +if __name__ == "__main__": + unittest.main() diff --git a/lightsim2grid/tests/test_limits_pypowsybl.py b/lightsim2grid/tests/test_limits_pypowsybl.py index edce22c2..593c84f1 100644 --- a/lightsim2grid/tests/test_limits_pypowsybl.py +++ b/lightsim2grid/tests/test_limits_pypowsybl.py @@ -94,12 +94,12 @@ def test_voltage_limits(self): self.assertAlmostEqual(vmin[by_name["VL3"]], 80.0) self.assertAlmostEqual(vmax[by_name["VL3"]], 100.0) - def test_line_thermal_limit(self): + def test_line_current_limit(self): line = [el for el in self.model.get_lines() if el.name == "L12"][0] self.assertAlmostEqual(line.limit_a1_ka, 0.5, places=6) # min(500, 600) A self.assertAlmostEqual(line.limit_a2_ka, 0.45, places=6) - def test_trafo_thermal_limit(self): + def test_trafo_current_limit(self): trafo = [el for el in self.model.get_trafos() if el.name == "T23"][0] self.assertAlmostEqual(trafo.limit_a1_ka, 0.1234, places=6) self.assertTrue(np.isnan(trafo.limit_a2_ka)) @@ -132,7 +132,8 @@ def _build_ls_grid_with_limits(): g.set_bus_voltage_limits(np.array([18.0, np.nan]), np.array([22.0, np.nan])) g.init_powerlines(np.array([0.01]), np.array([0.05]), np.array([0.0 + 0.0j]), np.array([0], dtype=np.int32), np.array([1], dtype=np.int32)) - g.set_line_thermal_limit(np.array([1.234]), np.array([np.nan])) + g.set_line_current_limit_side1(np.array([1.234])) + g.set_line_current_limit_side2(np.array([np.nan])) return g diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index 07027792..1717ab9c 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -9,11 +9,31 @@ #include "binding_declarations.hpp" #include "batch_algorithm/TimeSeries.hpp" #include "batch_algorithm/ContingencyAnalysis.hpp" +#include "batch_algorithm/LimitViolation.hpp" #include "help_fun_msg.hpp" using namespace ls2g; void bind_batch(py::module_& m) { + py::enum_(m, "ViolationElementType", "The kind of element on which a limit was violated.") + .value("BUS", ViolationElementType::BUS) + .value("LINE", ViolationElementType::LINE) + .value("TRAFO", ViolationElementType::TRAFO); + + py::enum_(m, "LimitViolationType", "The kind of limit that was violated.") + .value("LOW_VOLTAGE", LimitViolationType::LOW_VOLTAGE) + .value("HIGH_VOLTAGE", LimitViolationType::HIGH_VOLTAGE) + .value("CURRENT", LimitViolationType::CURRENT); + + py::class_(m, "LimitViolation", "A single limit violation, as detected by ContingencyAnalysisCPP.") + .def_readonly("element_type", &LimitViolation::element_type) + .def_readonly("element_id", &LimitViolation::element_id, + "grid-model bus id for BUS ; local (0-based, own type) line / trafo id otherwise") + .def_readonly("side", &LimitViolation::side, "1 or 2 for LINE / TRAFO ; unused (0) for BUS") + .def_readonly("violation_type", &LimitViolation::violation_type) + .def_readonly("value", &LimitViolation::value, "value reached") + .def_readonly("limit", &LimitViolation::limit, "limit that was violated"); + py::class_(m, "TimeSeriesCPP", DocComputers::Computers.c_str()) .def(py::init()) .def_property("init_from_n_powerflow", @@ -54,7 +74,16 @@ void bind_batch(py::module_& m) { .def("get_sbuses", &TimeSeries::get_sbuses, DocComputers::get_sbuses.c_str(), py::return_value_policy::reference_internal); py::class_(m, "ContingencyAnalysisCPP", DocSecurityAnalysis::SecurityAnalysis.c_str()) - .def(py::init()) + .def(py::init(), py::arg("grid_model"), py::arg("compute_limit_violations") = false) + .def_property("compute_limit_violations", + [](const ContingencyAnalysis & self){ return self.get_compute_limit_violations(); }, + [](ContingencyAnalysis & self, bool val){ self.set_compute_limit_violations(val); }, + R"mydelim(Whether limit violations are computed inline, per contingency, " + "during compute() (see converged / get_violations / converged_n / " + "get_violations_n). Default: false. Computing violations means an extra " + "per-element current / voltage check in every contingency's solve, so " + "users who only need compute_flows() / get_flows() should leave this off. " + "Changing this flag clears any previously-computed results.)mydelim") .def_property("init_from_n_powerflow", [](const ContingencyAnalysis & self){ return self.get_init_from_n_powerflow(); }, [](ContingencyAnalysis & self, bool val){ self.set_init_from_n_powerflow(val); }, @@ -123,6 +152,27 @@ void bind_batch(py::module_& m) { .def("get_voltages", &ContingencyAnalysis::get_voltages, DocSecurityAnalysis::get_voltages.c_str(), py::return_value_policy::reference_internal) .def("get_power_flows", &ContingencyAnalysis::get_power_flows, DocSecurityAnalysis::get_power_flows.c_str(), py::return_value_policy::reference_internal) + // limit violations (only usable if `compute_limit_violations=True`, see above ; + // raises otherwise). Row order matches `my_defaults()`. + .def("converged", [](const ContingencyAnalysis & self){ + const std::vector & c = self.converged(); // internal storage: char, not + // bool, so multi-threaded writes to disjoint indices during compute() can never + // race (std::vector is bit-packed and NOT safe for that). Convert to a + // fresh std::vector here (a copy, so no thread-safety concern) purely so + // Python gets a clean list[bool] instead of pybind11's char -> 1-char-str cast. + return std::vector(c.begin(), c.end()); + }, + "Per contingency (row order matches my_defaults()): whether it converged / was " + "actually simulated (False for skipped or diverged contingencies).") + .def("get_violations", &ContingencyAnalysis::get_violations, + "Per contingency (row order matches my_defaults()): list of LimitViolation.", + py::return_value_policy::reference_internal) + .def("converged_n", &ContingencyAnalysis::converged_n, + "Whether the pre-contingency ('n') powerflow converged.") + .def("get_violations_n", &ContingencyAnalysis::get_violations_n, + "List of LimitViolation for the pre-contingency ('n') case.", + py::return_value_policy::reference_internal) + // timers .def("total_time", &ContingencyAnalysis::total_time, DocComputers::total_time.c_str()) .def("solver_time", &ContingencyAnalysis::solver_time, DocComputers::solver_time.c_str()) diff --git a/src/bindings/python/binding_containers.cpp b/src/bindings/python/binding_containers.cpp index 733f7960..014ea843 100644 --- a/src/bindings/python/binding_containers.cpp +++ b/src/bindings/python/binding_containers.cpp @@ -245,8 +245,8 @@ void bind_containers(py::module_& m) { .def_readonly("res_q2_mvar", &TrafoInfo::res_q2_mvar, DocIterator::res_q_lv_mvar.c_str()) .def_readonly("res_v2_kv", &TrafoInfo::res_v2_kv, DocIterator::res_v_lv_kv.c_str()) .def_readonly("res_a2_ka", &TrafoInfo::res_a2_ka, DocIterator::res_a_lv_ka.c_str()) - .def_readonly("limit_a1_ka", &TrafoInfo::limit_a1_ka, "Thermal (current) limit, hv side, in kA (NaN if not set, see `LSGrid.set_trafo_thermal_limit`).") - .def_readonly("limit_a2_ka", &TrafoInfo::limit_a2_ka, "Thermal (current) limit, lv side, in kA (NaN if not set, see `LSGrid.set_trafo_thermal_limit`).") + .def_readonly("limit_a1_ka", &TrafoInfo::limit_a1_ka, "Current limit, hv side, in kA (NaN if not set, see `LSGrid.set_trafo_current_limit_side1`).") + .def_readonly("limit_a2_ka", &TrafoInfo::limit_a2_ka, "Current limit, lv side, in kA (NaN if not set, see `LSGrid.set_trafo_current_limit_side2`).") .def_readonly("res_theta1_deg", &TrafoInfo::res_theta1_deg, DocIterator::res_theta_hv_deg.c_str()) .def_readonly("res_theta2_deg", &TrafoInfo::res_theta2_deg, DocIterator::res_theta_lv_deg.c_str()) .def_readonly("yac_11", &TrafoInfo::yac_11, "TODO doc") @@ -304,8 +304,8 @@ void bind_containers(py::module_& m) { .def_readonly("res_q2_mvar", &LineInfo::res_q2_mvar, DocIterator::res_q_ex_mvar.c_str()) .def_readonly("res_v2_kv", &LineInfo::res_v2_kv, DocIterator::res_v_ex_kv.c_str()) .def_readonly("res_a2_ka", &LineInfo::res_a2_ka, DocIterator::res_a_ex_ka.c_str()) - .def_readonly("limit_a1_ka", &LineInfo::limit_a1_ka, "Thermal (current) limit, origin side, in kA (NaN if not set, see `LSGrid.set_line_thermal_limit`).") - .def_readonly("limit_a2_ka", &LineInfo::limit_a2_ka, "Thermal (current) limit, extremity side, in kA (NaN if not set, see `LSGrid.set_line_thermal_limit`).") + .def_readonly("limit_a1_ka", &LineInfo::limit_a1_ka, "Current limit, origin side, in kA (NaN if not set, see `LSGrid.set_line_current_limit_side1`).") + .def_readonly("limit_a2_ka", &LineInfo::limit_a2_ka, "Current limit, extremity side, in kA (NaN if not set, see `LSGrid.set_line_current_limit_side2`).") .def_readonly("res_theta1_deg", &LineInfo::res_theta1_deg, DocIterator::res_theta_or_deg.c_str()) .def_readonly("res_theta2_deg", &LineInfo::res_theta2_deg, DocIterator::res_theta_ex_deg.c_str()) .def_readonly("yac_11", &LineInfo::yac_11, "TODO doc") diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 4f6e9e58..a1e866eb 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -154,10 +154,14 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("set_line_names", &LSGrid::set_line_names, "TODO") .def("set_dcline_names", &LSGrid::set_dcline_names, "TODO") .def("set_trafo_names", &LSGrid::set_trafo_names, "TODO") - .def("set_line_thermal_limit", &LSGrid::set_line_thermal_limit, - "Set the per-side thermal (current) limit of each powerline, in kA (see `limit_a1_ka`/`limit_a2_ka` on `LineInfo`).") - .def("set_trafo_thermal_limit", &LSGrid::set_trafo_thermal_limit, - "Set the per-side thermal (current) limit of each transformer, in kA (see `limit_a1_ka`/`limit_a2_ka` on `TrafoInfo`).") + .def("set_line_current_limit_side1", &LSGrid::set_line_current_limit_side1, + "Set the side-1 current limit of each powerline, in kA (see `limit_a1_ka` on `LineInfo`).") + .def("set_line_current_limit_side2", &LSGrid::set_line_current_limit_side2, + "Set the side-2 current limit of each powerline, in kA (see `limit_a2_ka` on `LineInfo`).") + .def("set_trafo_current_limit_side1", &LSGrid::set_trafo_current_limit_side1, + "Set the side-1 current limit of each transformer, in kA (see `limit_a1_ka` on `TrafoInfo`).") + .def("set_trafo_current_limit_side2", &LSGrid::set_trafo_current_limit_side2, + "Set the side-2 current limit of each transformer, in kA (see `limit_a2_ka` on `TrafoInfo`).") .def("set_gen_names", &LSGrid::set_gen_names, "TODO") .def("set_load_names", &LSGrid::set_load_names, "TODO") .def("set_storage_names", &LSGrid::set_storage_names, "TODO") diff --git a/src/core/DataConverter.cpp b/src/core/DataConverter.cpp index 40103de4..8c6d6358 100644 --- a/src/core/DataConverter.cpp +++ b/src/core/DataConverter.cpp @@ -134,7 +134,7 @@ std::tuple(branch_r.size()); diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index dbc5bc1c..8617b271 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -213,7 +213,7 @@ void LSGrid::set_ls_to_orig(const IntVect & ls_to_orig){ return; } - if(ls_to_orig.size() != substations_.nb_bus()) + if(static_cast(ls_to_orig.size()) != substations_.nb_bus()) throw std::runtime_error("Impossible to set the converter ls_to_orig: the provided vector has not the same size as the number of bus on the grid."); set_ls_to_orig_internal(ls_to_orig); } @@ -233,7 +233,7 @@ void LSGrid::set_orig_to_ls(const IntVect & orig_to_ls){ throw std::runtime_error("Impossible to set the converter orig_to_ls: the number of 'non -1' component in the provided vector does not match the number of buses on the grid."); _ls_to_orig = IntVect::Constant(nb_bus_ls, -1); size_t ls2or_ind = 0; - for(auto or2ls_ind = 0; or2ls_ind < nb_bus_ls; ++or2ls_ind){ + for(size_t or2ls_ind = 0; or2ls_ind < nb_bus_ls; ++or2ls_ind){ const auto my_ind = _orig_to_ls[or2ls_ind]; if(my_ind >= 0){ _ls_to_orig[ls2or_ind] = my_ind; @@ -263,8 +263,8 @@ void LSGrid::set_ls_to_orig_internal(const IntVect & ls_to_orig) noexcept{ void LSGrid::init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, const RealVect & bus_vn_kv, - int nb_line, - int nb_trafo){ + int /*nb_line*/, + int /*nb_trafo*/){ /** initialize the bus_vn_kv_ member and @@ -989,7 +989,7 @@ CplxVect LSGrid::pre_process_solver( GlobalBusIdVect & id_solver_to_me, GlobalBusIdVect & slack_bus_id_me, SolverBusIdVect & slack_bus_id_solver, - bool is_ac, // kept for API compatibility; DC now goes through pre_process_dc_solver + bool /*is_ac*/, // kept for API compatibility; DC now goes through pre_process_dc_solver const AlgoControl & solver_control, bool init_pv_vm_targets) { @@ -1093,7 +1093,7 @@ void LSGrid::init_Bbus(Eigen::SparseMatrix & Bbus, } void LSGrid::init_slack_bus(const SolverBusIdVect& id_me_to_solver, - const GlobalBusIdVect& id_solver_to_me, + const GlobalBusIdVect& /*id_solver_to_me*/, const GlobalBusIdVect & slack_bus_id_me, SolverBusIdVect & slack_bus_id_solver) { @@ -1184,7 +1184,7 @@ void LSGrid::fillSbus_me(CplxVect & Sbus, bool ac, const SolverBusIdVect& id_me_ void LSGrid::fillpv_pq(const SolverBusIdVect& id_me_to_solver, const GlobalBusIdVect& id_solver_to_me, const SolverBusIdVect & slack_bus_id_solver, - const AlgoControl & solver_control) + const AlgoControl & /*solver_control*/) { // Nothing to do if neither pv, nor pq nor the dimension of the problem has changed @@ -1317,8 +1317,8 @@ void LSGrid::reset_results(){ } CplxVect LSGrid::dc_pf(const CplxVect & Vinit, - int max_iter, // not used for DC - real_type tol // not used for DC + int /*max_iter*/, // not used for DC + real_type /*tol*/ // not used for DC ) { //TODO SLACK: improve distributed slack for DC mode ! @@ -1401,7 +1401,7 @@ RealMat LSGrid::get_lodf(){ // convert it to solver bus id IntVect from_bus_solver(nb_el); // TODO : SolverBusIdVect here IntVect to_bus_solver(nb_el); - for(int el_id = 0; el_id < nb_el; ++el_id){ + for(size_t el_id = 0; el_id < nb_el; ++el_id){ // from side GlobalBusId f_grid_bus = from_bus[el_id]; SolverBusId f_solver_bus = id_me_to_dc_solver_[f_grid_bus.cast_int()]; diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index c9615cf1..829b4f44 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -643,16 +643,22 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, trafos_.nb(), "set_trafo_names"); trafos_.set_names(names); } - // per-side thermal (current) limit, in kA, optional: empty if never set - void set_line_thermal_limit(const RealVect & limit_a1_ka, const RealVect & limit_a2_ka){ - GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_thermal_limit (side1)"); - GenericContainer::check_size(limit_a2_ka, powerlines_.nb(), "set_line_thermal_limit (side2)"); - powerlines_.set_thermal_limit(limit_a1_ka, limit_a2_ka); + // per-side current limit, in kA, optional: empty if never set + void set_line_current_limit_side1(const RealVect & limit_a1_ka){ + GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_current_limit_side1"); + powerlines_.set_limit_a1_ka(limit_a1_ka); } - void set_trafo_thermal_limit(const RealVect & limit_a1_ka, const RealVect & limit_a2_ka){ - GenericContainer::check_size(limit_a1_ka, trafos_.nb(), "set_trafo_thermal_limit (side1)"); - GenericContainer::check_size(limit_a2_ka, trafos_.nb(), "set_trafo_thermal_limit (side2)"); - trafos_.set_thermal_limit(limit_a1_ka, limit_a2_ka); + void set_line_current_limit_side2(const RealVect & limit_a2_ka){ + GenericContainer::check_size(limit_a2_ka, powerlines_.nb(), "set_line_current_limit_side2"); + powerlines_.set_limit_a2_ka(limit_a2_ka); + } + void set_trafo_current_limit_side1(const RealVect & limit_a1_ka){ + GenericContainer::check_size(limit_a1_ka, trafos_.nb(), "set_trafo_current_limit_side1"); + trafos_.set_limit_a1_ka(limit_a1_ka); + } + void set_trafo_current_limit_side2(const RealVect & limit_a2_ka){ + GenericContainer::check_size(limit_a2_ka, trafos_.nb(), "set_trafo_current_limit_side2"); + trafos_.set_limit_a2_ka(limit_a2_ka); } void set_gen_names(const std::vector & names){ GenericContainer::check_size(names, generators_.nb(), "set_gen_names"); @@ -1668,7 +1674,7 @@ class LS2G_API LSGrid final std::vector > tripletList; tripletList.reserve(Ybus.nonZeros()); const auto n_col = Ybus.cols(); - for (size_t col_=0; col_ < n_col; ++col_){ + for (Eigen::Index col_=0; col_ < n_col; ++col_){ for (typename Eigen::SparseMatrix::InnerIterator it(Ybus, col_); it; ++it) { if(relabel_row) tripletList.push_back({static_cast(id_solver_to_me[static_cast(it.row())]), diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 165a04c9..ab226879 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -180,9 +180,9 @@ class LS2G_API SubstationContainer final : public IteratorAdder(nb_bus(), true); // by default everything is connected // check that a "substation" always has the same vn_kv for all of its buses - for(size_t sub_id=0; sub_id < n_sub; sub_id++){ + for(int sub_id=0; sub_id < n_sub; sub_id++){ real_type ref_vn_kv = bus_vn_kv(sub_id); - for(size_t bus_id=1; bus_id < nmax_busbar_per_sub; bus_id++) + for(int bus_id=1; bus_id < nmax_busbar_per_sub; bus_id++) { real_type this_bus_vn_kv = bus_vn_kv(local_to_gridmodel(sub_id, LocalBusId(bus_id)).cast_int()); if(abs(this_bus_vn_kv - ref_vn_kv) > BaseConstants::_tol_equal_float){ diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index 384e2d6c..8c8247f2 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -180,7 +180,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants Eigen::Ref vect_ydc_ft = structure_data.ydc_12(); Eigen::Ref dc_x_tau_shift = structure_data.dc_x_tau_shift(); // not used in AC nor if it's powerline anyway - Eigen::Index nb_el = structure_data.nb(); + size_t nb_el = structure_data.nb(); RealVect res; for(size_t el_id = 0; el_id < nb_el; ++el_id){ @@ -336,7 +336,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants size_t _reset_data_and_check_vinit(const CplxVect & Vinit){ const size_t nb_total_bus = _grid_model.total_bus(); - if(Vinit.size() != nb_total_bus){ + if(static_cast(Vinit.size()) != nb_total_bus){ std::ostringstream exc_; exc_ << "TimeSeries::compute_Sbuses: Size of the Vinit should be the same as the total number of buses. Currently: "; exc_ << "Vinit: " << Vinit.size() << " and there are " << nb_total_bus << " buses."; diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 363b65db..f980e40d 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -17,6 +17,171 @@ namespace ls2g { +namespace { + +// appends BUS limit violations for a single, already-solved voltage vector `V` (solver +// numbering). `masked_solver_ids` is nullptr for the base ("n") case; in "handle disconnected +// grid" mode it is this contingency's masked solver-bus-id list -- those buses are forced to +// V=0 post-solve and must never be checked (else every one of them would spuriously report a +// LOW_VOLTAGE violation). +void check_bus_voltage_violations( + const CplxVect & V, + const SolverBusIdVect & id_me_to_solver, + Eigen::Ref bus_vmin_kv, + Eigen::Ref bus_vmax_kv, + Eigen::Ref bus_vn_kv, + const std::vector * masked_solver_ids, + std::vector & out) +{ + const Eigen::Index nb_bus = bus_vmin_kv.size(); + if(nb_bus == 0) return; // voltage limits never configured on this grid + + std::vector is_masked; + if(masked_solver_ids != nullptr && !masked_solver_ids->empty()){ + is_masked.assign(static_cast(V.size()), false); + for(int b : *masked_solver_ids){ + if(b >= 0 && static_cast(b) < is_masked.size()) is_masked[static_cast(b)] = true; + } + } + + for(Eigen::Index grid_id = 0; grid_id < nb_bus; ++grid_id){ + const real_type vmin = bus_vmin_kv(grid_id); + const real_type vmax = bus_vmax_kv(grid_id); + if(isnan(vmin) && isnan(vmax)) continue; // no limit configured for this specific bus + + const int solver_id = id_me_to_solver(static_cast(grid_id)).cast_int(); + if(solver_id == BaseConstants::_deactivated_bus_id) continue; + if(!is_masked.empty() && is_masked[static_cast(solver_id)]) continue; + + const real_type vm_kv = std::abs(V(solver_id)) * bus_vn_kv(grid_id); + if(!isnan(vmin) && vm_kv < vmin){ + out.push_back(LimitViolation{ViolationElementType::BUS, static_cast(grid_id), 0, + LimitViolationType::LOW_VOLTAGE, vm_kv, vmin}); + } else if(!isnan(vmax) && vm_kv > vmax){ + out.push_back(LimitViolation{ViolationElementType::BUS, static_cast(grid_id), 0, + LimitViolationType::HIGH_VOLTAGE, vm_kv, vmax}); + } + } +} + +// appends CURRENT limit violations (both sides) for a single, already-solved voltage vector +// `V` (solver numbering), for every element of `structure_data` (LineContainer or +// TrafoContainer). `skip_ids` are the LOCAL (own-type) element ids disconnected BY THIS +// CONTINGENCY: the contingency only edits Ybus coefficients, it never flips +// `get_status_global()`, so these elements would otherwise still look "connected" and produce +// a bogus current from the pre-removal coefficients -- exactly why the existing +// BaseBatchSolverSynch::clean_flows() has to retroactively zero these columns for the batch +// matrix API; here we just skip them proactively. +template +void check_current_violations( + const T & structure_data, + ViolationElementType el_type, + const CplxVect & V, + const SolverBusIdVect & id_me_to_solver, + Eigen::Ref bus_vn_kv, + bool ac_solver_used, + real_type sn_mva, + Eigen::Ref limit1, + Eigen::Ref limit2, + const std::vector & skip_ids, + std::vector & out) +{ + if(limit1.size() == 0 && limit2.size() == 0) return; // thermal limits never configured + + const auto & el_status = structure_data.get_status_global(); + const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); + const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); + + Eigen::Ref yac_eff_11 = structure_data.yac_eff_11(); + Eigen::Ref yac_eff_12 = structure_data.yac_eff_12(); + Eigen::Ref yac_eff_21 = structure_data.yac_eff_21(); + Eigen::Ref yac_eff_22 = structure_data.yac_eff_22(); + Eigen::Ref ydc_11 = structure_data.ydc_11(); + Eigen::Ref ydc_12 = structure_data.ydc_12(); + Eigen::Ref ydc_21 = structure_data.ydc_21(); + Eigen::Ref ydc_22 = structure_data.ydc_22(); + Eigen::Ref dc_x_tau_shift = structure_data.dc_x_tau_shift(); + const bool has_tau_shift = dc_x_tau_shift.size() > 0; // trafo only, empty for lines + + const size_t nb_el = structure_data.nb(); + const real_type sqrt_3 = sqrt(3.); + + std::vector skip; + if(!skip_ids.empty()){ + skip.assign(nb_el, false); + for(int id : skip_ids){ + if(id >= 0 && static_cast(id) < nb_el) skip[static_cast(id)] = true; + } + } + + for(size_t el_id = 0; el_id < nb_el; ++el_id){ + if(!el_status[el_id]) continue; + if(!skip.empty() && skip[el_id]) continue; + + const Eigen::Index el_idx = static_cast(el_id); + const bool has_lim1 = limit1.size() > 0 && !isnan(limit1(el_idx)); + const bool has_lim2 = limit2.size() > 0 && !isnan(limit2(el_idx)); + if(!has_lim1 && !has_lim2) continue; + + const int bus_from_me = bus_from(static_cast(el_id)).cast_int(); + const int bus_to_me = bus_to(static_cast(el_id)).cast_int(); + const int solver_from = id_me_to_solver(bus_from_me).cast_int(); + const int solver_to = id_me_to_solver(bus_to_me).cast_int(); + if(solver_from == BaseConstants::_deactivated_bus_id || + solver_to == BaseConstants::_deactivated_bus_id) continue; + + const cplx_type Efrom = V(solver_from); + const cplx_type Eto = V(solver_to); + const real_type v_from_kv = std::abs(Efrom) * bus_vn_kv(bus_from_me); + const real_type v_to_kv = std::abs(Eto) * bus_vn_kv(bus_to_me); + + real_type amps1 = 0.; + real_type amps2 = 0.; + if(ac_solver_used){ + // side 1: mirrors BaseBatchSolverSynch::compute_amps_flows (self=from, mutual=to) + const cplx_type I_from = std::conj(yac_eff_11(el_idx) * Efrom + yac_eff_12(el_idx) * Eto); + const cplx_type S_from = Efrom * I_from; + amps1 = std::abs(S_from) * sn_mva / (sqrt_3 * v_from_kv); + + // side 2: mirrors TwoSidesContainer_rxh_A::compute_results_tsc_rxha (self=to, mutual=from) + const cplx_type I_to = std::conj(yac_eff_22(el_idx) * Eto + yac_eff_21(el_idx) * Efrom); + const cplx_type S_to = Eto * I_to; + amps2 = std::abs(S_to) * sn_mva / (sqrt_3 * v_to_kv); + } else { + const real_type theta_from = std::arg(Efrom); + const real_type theta_to = std::arg(Eto); + // side 1: mirrors BaseBatchSolverSynch::compute_amps_flows exactly (incl. the + // tau-shift subtraction with no extra sn_mva factor, BaseBatchSolverSynch.hpp:158) + real_type p_from = (ydc_11(el_idx) * theta_from + ydc_12(el_idx) * theta_to) * sn_mva; + if(has_tau_shift) p_from -= dc_x_tau_shift(el_idx); + amps1 = std::abs(p_from) / (sqrt_3 * v_from_kv); + + // side 2: mirrored from side 1 (self=to, mutual=from). The tau-shift sign here is + // the OPPOSITE of side 1's -- verified against the fundamental DC power-flow + // invariant (no losses are modeled in DC, so p_from must equal -p_to exactly for + // any series branch, phase-shifting or not): with ydc_22==ydc_11 and + // ydc_21==ydc_12 (as they are for a simple series admittance), using the same sign + // on both sides would break that identity by 2*dc_x_tau_shift, while the opposite + // sign (used here) preserves p_from+p_to==0 exactly, confirmed numerically on a + // case14 grid with an added phase-shifting transformer. + real_type p_to = (ydc_22(el_idx) * theta_to + ydc_21(el_idx) * theta_from) * sn_mva; + if(has_tau_shift) p_to += dc_x_tau_shift(el_idx); + amps2 = std::abs(p_to) / (sqrt_3 * v_to_kv); + } + + if(has_lim1 && amps1 > limit1(el_idx)){ + out.push_back(LimitViolation{el_type, static_cast(el_id), 1, + LimitViolationType::CURRENT, amps1, limit1(el_idx)}); + } + if(has_lim2 && amps2 > limit2(el_idx)){ + out.push_back(LimitViolation{el_type, static_cast(el_id), 2, + LimitViolationType::CURRENT, amps2, limit2(el_idx)}); + } + } +} + +} // anonymous namespace + bool ContingencyAnalysis::check_invertible(const Eigen::SparseMatrix & Ybus) const{ std::vector visited(Ybus.cols(), false); std::vector already_added(Ybus.cols(), false); @@ -209,14 +374,14 @@ void ContingencyAnalysis::init_li_coeffs( for(auto br_id : this_cont_id){ int el_id; const TwoSidesContainer_rxh_A *p_branch; - if(br_id < n_line_) + if(static_cast(br_id) < n_line_) { // this is a powerline el_id = br_id; p_branch = & powerlines; }else{ // this is a trafo - el_id = br_id - n_line_; + el_id = br_id - static_cast(n_line_); p_branch = & trafos; } @@ -374,6 +539,18 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ const bool ac_solver_used = _algo.ac_solver_used(); size_t nb_steps = _li_defaults.size(); + // limit-violation checking (only if requested, see set_compute_limit_violations()): + // my_defaults_vect() walks the whole `_li_defaults` set, so it is built once here (before + // any per-contingency work, single- or multi-threaded) rather than once per contingency. + const std::vector > li_defaults_vect = + _compute_limit_violations_ ? my_defaults_vect() : std::vector >(); + if(_compute_limit_violations_){ + _converged.assign(nb_steps, 0); + _violations.assign(nb_steps, std::vector()); + _converged_n_ = false; + _violations_n_.clear(); + } + // prepare the gridmodel (compute Ybus, Sbus etc.) CplxVect Vinit_solver = prepare_solver_input_base(Vinit, ac_solver_used); @@ -409,6 +586,33 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ timer_preproc ); + if(_compute_limit_violations_){ + // pre-contingency ("n") case: this is the only point at which the base-case V is + // observable -- the per-contingency solves (below) reuse / overwrite the member + // `_algo`'s state in the single-threaded path, and build separate per-thread copies + // only afterward (in init_thread) in the multi-threaded path. + _converged_n_ = n_powerflow_has_conv; + if(n_powerflow_has_conv){ + const CplxVect V_n = _algo.get_V(); + const std::vector no_skip; + check_bus_voltage_violations(V_n, id_me_to_solver_, + _grid_model.get_bus_vmin_kv(), _grid_model.get_bus_vmax_kv(), + _grid_model.get_bus_vn_kv(), nullptr, _violations_n_); + check_current_violations(_grid_model.get_powerlines_as_data(), ViolationElementType::LINE, + V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), + ac_solver_used, sn_mva, + _grid_model.get_powerlines_as_data().get_limit_a1_ka(), + _grid_model.get_powerlines_as_data().get_limit_a2_ka(), + no_skip, _violations_n_); + check_current_violations(_grid_model.get_trafos_as_data(), ViolationElementType::TRAFO, + V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), + ac_solver_used, sn_mva, + _grid_model.get_trafos_as_data().get_limit_a1_ka(), + _grid_model.get_trafos_as_data().get_limit_a2_ka(), + no_skip, _violations_n_); + } + } + if(!n_powerflow_has_conv) return; auto timer_thread = CustTimer(); @@ -424,7 +628,8 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ 0, nb_cont, _algo, _algo_controler, Ybus_, Vinit_solver, ac_solver_used, mask_mode, max_iter, tol, sn_mva, - _timer_modif_Ybus, _nb_solved, _timer_solver, err, false); + _timer_modif_Ybus, _nb_solved, _timer_solver, err, false, + _compute_limit_violations_ ? &li_defaults_vect : nullptr); if(err) std::rethrow_exception(err); _timer_total = timer.duration(); return; @@ -475,14 +680,15 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ for(int t = 1; t < nb_thread; ++t){ size_t b, e; range_of(t, b, e); - threads.emplace_back([=, &init_thread, &algo_for, &ybus_for, &controls, &th_timer_modif, + threads.emplace_back([=, this, &init_thread, &algo_for, &ybus_for, &controls, &th_timer_modif, &th_nb_solved, &th_timer_solver, &th_err, &Vinit_solver](){ init_thread(t); // build this worker's solver / control / Ybus copy in parallel run_contingency_range( b, e, algo_for(t), controls[t], ybus_for(t), Vinit_solver, ac_solver_used, mask_mode, max_iter, tol, sn_mva, - th_timer_modif[t], th_nb_solved[t], th_timer_solver[t], th_err[t], true); + th_timer_modif[t], th_nb_solved[t], th_timer_solver[t], th_err[t], true, + _compute_limit_violations_ ? &li_defaults_vect : nullptr); }); } _timer_thread_init = timer_thread.duration(); @@ -495,7 +701,8 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ b, e, algo_for(0), controls[0], ybus_for(0), Vinit_solver, ac_solver_used, mask_mode, max_iter, tol, sn_mva, - th_timer_modif[0], th_nb_solved[0], th_timer_solver[0], th_err[0], true); + th_timer_modif[0], th_nb_solved[0], th_timer_solver[0], th_err[0], true, + _compute_limit_violations_ ? &li_defaults_vect : nullptr); } for(auto & th : threads) th.join(); @@ -529,7 +736,8 @@ void ContingencyAnalysis::run_contingency_range( int & nb_solved, double & timer_solver, std::exception_ptr & err, - bool needs_solver_init) + bool needs_solver_init, + const std::vector > * li_defaults_vect) { try { CplxVect V; @@ -610,6 +818,40 @@ void ContingencyAnalysis::run_contingency_range( } if (do_store) _voltages.row(cont_id)(id_solver_to_me_.as_eigen()) = V.array(); + + if(li_defaults_vect != nullptr){ + _converged[cont_id] = do_store ? 1 : 0; + if(do_store){ + // split this contingency's disconnected branch ids (grid-wide numbering, + // lines first then trafos, see init_li_coeffs) into per-type LOCAL ids so + // check_current_violations can skip them (they still look "connected" via + // get_status_global(), only the Ybus coefficients were edited) + std::vector skip_lines, skip_trafos; + for(int br_id : (*li_defaults_vect)[cont_id]){ + if(static_cast(br_id) < n_line_) skip_lines.push_back(br_id); + else skip_trafos.push_back(static_cast(br_id - static_cast(n_line_))); + } + + const std::vector * masked_ids = (mask_mode && !_li_masked[cont_id].empty()) + ? &_li_masked[cont_id] : nullptr; + check_bus_voltage_violations(V, id_me_to_solver_, + _grid_model.get_bus_vmin_kv(), _grid_model.get_bus_vmax_kv(), + _grid_model.get_bus_vn_kv(), masked_ids, + _violations[cont_id]); + check_current_violations(_grid_model.get_powerlines_as_data(), ViolationElementType::LINE, + V, id_me_to_solver_, _grid_model.get_bus_vn_kv(), + ac_solver_used, sn_mva, + _grid_model.get_powerlines_as_data().get_limit_a1_ka(), + _grid_model.get_powerlines_as_data().get_limit_a2_ka(), + skip_lines, _violations[cont_id]); + check_current_violations(_grid_model.get_trafos_as_data(), ViolationElementType::TRAFO, + V, id_me_to_solver_, _grid_model.get_bus_vn_kv(), + ac_solver_used, sn_mva, + _grid_model.get_trafos_as_data().get_limit_a1_ka(), + _grid_model.get_trafos_as_data().get_limit_a2_ka(), + skip_trafos, _violations[cont_id]); + } + } } } catch(...) { err = std::current_exception(); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 1d8df190..44a6d0f9 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -10,6 +10,7 @@ #define SECURITYANALYSIS_H #include "BaseBatchSolverSynch.hpp" +#include "LimitViolation.hpp" #include #include @@ -22,14 +23,31 @@ have been disconnected class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch { public: - explicit ContingencyAnalysis(const LSGrid & init_grid_model) noexcept: + explicit ContingencyAnalysis(const LSGrid & init_grid_model, + bool compute_limit_violations = false) noexcept: BaseBatchSolverSynch(init_grid_model), _li_defaults(), _li_coeffs(), + _compute_limit_violations_(compute_limit_violations), _timer_modif_Ybus(0.), _timer_thread_init(0.) { } + // whether limit violations are computed (see `converged`, `get_violations`, + // `converged_n`, `get_violations_n` below). Defaults to `False` (settable at + // construction too): computing violations means an extra per-element current/voltage + // check inline in every contingency's solve, so users who only need + // `compute_flows()`/`get_flows()` should not pay for it. Changing this flag clears any + // previously-computed results (limit-violation results as well as voltages/flows, + // same as `clear()`) so stale results computed under the other setting can never be + // returned. + bool get_compute_limit_violations() const noexcept {return _compute_limit_violations_;} + void set_compute_limit_violations(bool val){ + if(val == _compute_limit_violations_) return; + _compute_limit_violations_ = val; + clear(); + } + ~ContingencyAnalysis() noexcept = default; ContingencyAnalysis(const ContingencyAnalysis&) = delete; ContingencyAnalysis(ContingencyAnalysis&&) = delete; @@ -38,7 +56,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch // utilities to add defaults to simulate void add_all_n1(){ - for(int l_id = 0; l_id < n_total_; ++l_id){ + for(int l_id = 0; l_id < static_cast(n_total_); ++l_id){ std::set this_default = {l_id}; _li_defaults.insert(this_default); } @@ -72,6 +90,10 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch _li_coeffs.clear(); _li_masked.clear(); _skip_mask.clear(); + _converged.clear(); + _violations.clear(); + _converged_n_ = false; + _violations_n_.clear(); _timer_total = 0.; _timer_modif_Ybus = 0.; _timer_thread_init = 0.; @@ -81,6 +103,10 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch BaseBatchSolverSynch::clear(); _li_masked.clear(); _skip_mask.clear(); + _converged.clear(); + _violations.clear(); + _converged_n_ = false; + _violations_n_.clear(); _timer_total = 0.; _timer_modif_Ybus = 0.; _timer_thread_init = 0.; @@ -168,6 +194,27 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch return res; } + // limit violations (only available if constructed with `compute_limit_violations=True`, + // see get_compute_limit_violations() above; computed inline, per contingency, during + // `compute()`). Row order matches `my_defaults_vect()` / `_voltages` rows. + const std::vector & converged() const { + _check_limit_violations_enabled("converged"); + return _converged; + } + const std::vector > & get_violations() const { + _check_limit_violations_enabled("get_violations"); + return _violations; + } + // pre-contingency ("n") case + bool converged_n() const { + _check_limit_violations_enabled("converged_n"); + return _converged_n_; + } + const std::vector & get_violations_n() const { + _check_limit_violations_enabled("get_violations_n"); + return _violations_n_; + } + // timers double total_time() const {return _timer_total;} double preprocessing_time() const {return _timer_pre_proc;} @@ -184,13 +231,25 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch exc_ << el << " contingency id should be > 0"; throw std::runtime_error(exc_.str()); } - if(el >= n_total_){ + if(el >= static_cast(n_total_)){ std::ostringstream exc_; exc_ << "SecurityAnalysis: cannot add the contingency with id "; exc_ << el << " because the grid counts only " << n_total_ << " powerlines / trafos."; throw std::runtime_error(exc_.str()); } } + // raises if the user never asked for limit violations to be computed (see + // get_compute_limit_violations()) + void _check_limit_violations_enabled(const std::string & fun_name) const { + if(!_compute_limit_violations_){ + std::ostringstream exc_; + exc_ << "ContingencyAnalysis::" << fun_name << ": limit violations were not " + "requested. Construct this object with `compute_limit_violations=True` " + "to use this feature."; + throw std::runtime_error(exc_.str()); + } + } + void init_li_coeffs(bool ac_solver_used, const SolverBusIdVect &id_me_to_solver); // remove the line parameters from Ybus, this is to emulate its disconnection. // `algo` is the solver whose internal Ybus is updated in DC mode (one per thread). @@ -243,7 +302,18 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch int & nb_solved, double & timer_solver, std::exception_ptr & err, - bool needs_solver_init); + bool needs_solver_init, + // limit-violation checking: only non-null when + // _compute_limit_violations_ is true. Built once (before + // the threads are spawned, see compute()) since it walks + // the whole `_li_defaults` set -- avoids every contingency + // (or every thread) rebuilding it via my_defaults_vect(). + // The per-bus / per-branch limit vectors themselves are NOT + // threaded through here: they are trivial reference-return + // accessors on the (already shared, read-only during this + // phase) _grid_model, so run_contingency_range fetches them + // directly where needed instead of adding 7 more parameters. + const std::vector > * li_defaults_vect); private: // li_default @@ -258,6 +328,13 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch std::vector > _li_masked; // per contingency: masked solver bus ids (mask mode) std::vector _skip_mask; // per contingency: 1 => skip (strands the ref slack) + // limit violations (see get_compute_limit_violations() / converged() / get_violations()) + bool _compute_limit_violations_ = false; + std::vector _converged; // per contingency, aligned with my_defaults_vect() + std::vector > _violations; // per contingency, aligned with my_defaults_vect() + bool _converged_n_ = false; // pre-contingency ("n") case + std::vector _violations_n_; + //timers double _timer_modif_Ybus; // time to update the Ybus between the defaults simulation double _timer_thread_init; diff --git a/src/core/batch_algorithm/LimitViolation.hpp b/src/core/batch_algorithm/LimitViolation.hpp new file mode 100644 index 00000000..2cd82e4c --- /dev/null +++ b/src/core/batch_algorithm/LimitViolation.hpp @@ -0,0 +1,42 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef LIMITVIOLATION_H +#define LIMITVIOLATION_H + +#include "Utils.hpp" + +namespace ls2g { + +// the kind of element on which a limit was violated +enum class ViolationElementType : int { + BUS = 0, + LINE = 1, + TRAFO = 2 +}; + +// the kind of limit that was violated +enum class LimitViolationType : int { + LOW_VOLTAGE = 0, + HIGH_VOLTAGE = 1, + CURRENT = 2 +}; + +// a single limit violation, as detected by ContingencyAnalysis (see compute_limit_violations) +struct LimitViolation { + ViolationElementType element_type; + int element_id; // grid-model bus id for BUS ; local (0-based, own type) line / trafo id otherwise + int side; // 1 or 2 for LINE / TRAFO ; unused (0) for BUS + LimitViolationType violation_type; + real_type value; // value reached + real_type limit; // limit that was violated +}; + +} // namespace ls2g + +#endif // LIMITVIOLATION_H diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index e595cfd0..33949620 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -86,7 +86,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch bool add // if true call += else calls -= ) const { - auto nb_el = structure_data.nb(); + size_t nb_el = structure_data.nb(); const auto & el_status = structure_data.get_status(); const auto & el_bus_id = structure_data.get_bus_id(); SolverBusId bus_id_solver; @@ -123,7 +123,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch bool add // if true call += else calls -= ) const { - auto nb_el = structure_data.nb(); + size_t nb_el = structure_data.nb(); const auto & el_status = structure_data.get_status(); const auto & el_bus_id = structure_data.get_bus_id(); SolverBusId bus_id_solver; diff --git a/src/core/element_container/Container_IteratorUtils.hpp b/src/core/element_container/Container_IteratorUtils.hpp index f311f27c..f5f81256 100644 --- a/src/core/element_container/Container_IteratorUtils.hpp +++ b/src/core/element_container/Container_IteratorUtils.hpp @@ -105,7 +105,7 @@ class IteratorAdder { throw std::range_error("You cannot ask for a negative element id."); } - if(id >= static_cast(this)->nb()) + if(id >= static_cast(static_cast(this)->nb())) { throw std::range_error("Load out of bound. Not enough elements of this type on the grid."); } diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index 8a941cae..7d881193 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -159,7 +159,7 @@ void ConverterStationContainer::change_v(int station_id, real_type new_v_pu, Dua void ConverterStationContainer::fillSbus_station(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, - bool ac, + bool /*ac*/, const std::vector & skip_p) const { const int nb_station = nb(); @@ -235,7 +235,7 @@ void ConverterStationContainer::fillpv(std::vector & bus_pv, } } -void ConverterStationContainer::init_q_vector(int nb_bus, +void ConverterStationContainer::init_q_vector(int /*nb_bus*/, Eigen::VectorXi & total_gen_per_bus, RealVect & total_q_min_per_bus, RealVect & total_q_max_per_bus) const diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 6a9ef1d7..77fb779f 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -165,7 +165,7 @@ RealVect GeneratorContainer::get_slack_weights_solver( return res; } -void GeneratorContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const { +void GeneratorContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_gen = nb(); GlobalBusId bus_id_me; SolverBusId bus_id_solver; @@ -272,7 +272,7 @@ void GeneratorContainer::get_vm_for_dc(RealVect & Vm){ } } -void GeneratorContainer::_change_p(int gen_id, real_type new_p, bool my_status, DualAlgoControl & solver_control) +void GeneratorContainer::_change_p(int gen_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) { if (abs(target_p_mw_(gen_id) - new_p) > _tol_equal_float) { solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); @@ -338,7 +338,7 @@ void GeneratorContainer::change_v_nothrow(int gen_id, real_type new_v_pu, DualAl } } -bool GeneratorContainer::_change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) { +bool GeneratorContainer::_change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) { // el_id is validated (and the proper IndexError raised) by `_generic_change_bus`, // which the caller runs *after* this function. Bail out here on an out-of-range // id so the `regulated_bus_id_` write below never touches memory out of bounds. @@ -455,7 +455,7 @@ void GeneratorContainer::set_p_slack(const RealVect& node_mismatch, } } -void GeneratorContainer::init_q_vector(int nb_bus, +void GeneratorContainer::init_q_vector(int /*nb_bus*/, Eigen::VectorXi & total_gen_per_bus, RealVect & total_q_min_per_bus, RealVect & total_q_max_per_bus) const diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 400b0afe..623b3c7c 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -150,12 +150,12 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter } virtual void _compute_results( - const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, + const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, bool ac) override { set_osc_pq_res_p(); if(ac){ diff --git a/src/core/element_container/GenericContainer.cpp b/src/core/element_container/GenericContainer.cpp index 03ce900f..d6dfbbc3 100644 --- a/src/core/element_container/GenericContainer.cpp +++ b/src/core/element_container/GenericContainer.cpp @@ -62,7 +62,7 @@ void GenericContainer::_generic_change_bus( int el_id, const GridModelBusId & new_gridmodel_bus_id, GlobalBusIdVect & el_bus_ids, - DualAlgoControl & solver_control, + DualAlgoControl & /*solver_control*/, int nb_max_bus) const { // bus id here "me_id" and NOT "solver_id" @@ -109,7 +109,7 @@ GridModelBusId GenericContainer::_get_bus(int el_id, const std::vector & s return res; } -void GenericContainer::v_kv_from_vpu(const Eigen::Ref & Va, +void GenericContainer::v_kv_from_vpu(const Eigen::Ref & /*Va*/, const Eigen::Ref & Vm, const std::vector & status, int nb_element, @@ -148,12 +148,12 @@ void GenericContainer::v_kv_from_vpu(const Eigen::Ref & Va, } void GenericContainer::v_deg_from_va(const Eigen::Ref & Va, - const Eigen::Ref & Vm, + const Eigen::Ref & /*Vm*/, const std::vector & status, int nb_element, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const RealVect & /*bus_vn_kv*/, RealVect & theta) const { for(int el_id = 0; el_id < nb_element; ++el_id){ diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index 32744412..42f8e583 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -30,10 +30,10 @@ class LS2G_API GenericContainer : public BaseConstants { public: - virtual void fillYbus(std::vector > & res, - bool ac, - const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva) const { + virtual void fillYbus(std::vector > & /*res*/, + bool /*ac*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + real_type /*sn_mva*/) const { // nothing to do by default // is overriden mainly for "branches" (lines, transformers etc.) }; @@ -41,72 +41,72 @@ class LS2G_API GenericContainer : public BaseConstants // Real-valued DC admittance matrix (Bbus) contribution. DC only needs `Bbus . theta = Pbus` // (all real), so this fills real triplets directly (no complex temporary). // Only "branches" (lines, transformers) contribute to the DC Bbus. - virtual void fillBdc(std::vector > & res, - const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva) const { + virtual void fillBdc(std::vector > & /*res*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + real_type /*sn_mva*/) const { // nothing to do by default // is overriden mainly for "branches" (lines, transformers etc.) }; - virtual void fillBp_Bpp(std::vector > & Bp, - std::vector > & Bpp, - const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva, - FDPFMethod xb_or_bx) const { + virtual void fillBp_Bpp(std::vector > & /*Bp*/, + std::vector > & /*Bpp*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + real_type /*sn_mva*/, + FDPFMethod /*xb_or_bx*/) const { // nothing to do by default // is overriden mainly for "branches" (lines, transformers etc.) }; - virtual void fillBf_for_PTDF(std::vector > & Bf, - const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva, - int nb_line, - bool transpose) const { + virtual void fillBf_for_PTDF(std::vector > & /*Bf*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + real_type /*sn_mva*/, + int /*nb_line*/, + bool /*transpose*/) const { // nothing to do by default // is overriden mainly for "branches" (lines, transformers etc.) }; - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const { + virtual void fillSbus(CplxVect & /*Sbus*/, const SolverBusIdVect & /*id_grid_to_solver*/, bool /*ac*/) const { // nothing to do by default // is overriden mainly for "one side elements" (loads, generators etc.) }; - virtual void fillpv(std::vector& bus_pv, - std::vector & has_bus_been_added, - const SolverBusIdVect& slack_bus_id_solver, - const SolverBusIdVect & id_grid_to_solver) const { + virtual void fillpv(std::vector& /*bus_pv*/, + std::vector & /*has_bus_been_added*/, + const SolverBusIdVect& /*slack_bus_id_solver*/, + const SolverBusIdVect & /*id_grid_to_solver*/) const { // nothing to do by default // is overriden mainly for "generators" }; - virtual void get_q(std::vector& q_by_bus) { + virtual void get_q(std::vector& /*q_by_bus*/) { // nothing to do by default // is overriden mainly for "generators" }; - virtual void set_p_slack(const RealVect& node_mismatch, const SolverBusIdVect & id_grid_to_solver) { + virtual void set_p_slack(const RealVect& /*node_mismatch*/, const SolverBusIdVect & /*id_grid_to_solver*/) { // nothing to do by default // is overriden mainly for "generators" }; static const int _deactivated_bus_id; - virtual void reconnect_connected_buses(SubstationContainer & substation) const { + virtual void reconnect_connected_buses(SubstationContainer & /*substation*/) const { // nothing to do by default }; /**computes the total amount of power for each bus (for generator only)**/ - virtual void gen_p_per_bus(std::vector & res) const { + virtual void gen_p_per_bus(std::vector & /*res*/) const { // nothing to do by default // is overriden mainly for "one side elements" (loads, generators etc.) }; - virtual void nb_line_end(std::vector & res) const { + virtual void nb_line_end(std::vector & /*res*/) const { // nothing to do by default // is overriden mainly for "branches" (lines, transformers etc.) }; - virtual void get_graph(std::vector > & res) const { + virtual void get_graph(std::vector > & /*res*/) const { // nothing to do by default // is overriden mainly for "branches" (lines, transformers etc.) }; - virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) { + virtual void disconnect_if_not_in_main_component(std::vector & /*busbar_in_main_component*/) { // nothing to do by default }; @@ -152,7 +152,7 @@ class LS2G_API GenericContainer : public BaseConstants static void _check_in_range(IntType el_id, const Cont & cont, FunName fun_name="") { // TODO debug mode: only in debug mode - if(el_id >= cont.size()) + if(el_id >= static_cast(cont.size())) { // TODO DEBUG MODE: only check in debug mode std::ostringstream exc_; diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index b7486b81..d5f397c2 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -210,7 +210,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer > & res) const override { + virtual void get_graph(std::vector > & /*res*/) const override { // for buses only connected through a hvdc line, i don't add them // they are not in the same "connected component" } @@ -344,11 +344,11 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer > & Bp, - std::vector > & Bpp, - const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva, - FDPFMethod xb_or_bx) const override { + virtual void fillBp_Bpp(std::vector > & /*Bp*/, + std::vector > & /*Bpp*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + real_type /*sn_mva*/, + FDPFMethod /*xb_or_bx*/) const override { // no Bp coeffs for hvdc lines } diff --git a/src/core/element_container/LoadContainer.cpp b/src/core/element_container/LoadContainer.cpp index 980c9ef0..92e25b6d 100644 --- a/src/core/element_container/LoadContainer.cpp +++ b/src/core/element_container/LoadContainer.cpp @@ -29,7 +29,7 @@ void LoadContainer::set_state(LoadContainer::StateRes & my_state) void LoadContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, - bool ac) const + bool /*ac*/) const { int nb_load = nb(); GlobalBusId bus_id_me; diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 1b80ca8f..848184a8 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -81,12 +81,12 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; protected: - virtual void _compute_results(const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, + virtual void _compute_results(const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, bool ac) override { diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index cb8a844d..afb6994b 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -424,34 +424,34 @@ class OneSideContainer : public GenericContainer virtual void _reset_results() { // nothing to do by default }; - virtual void _compute_results(const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, - bool ac) { + virtual void _compute_results(const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, + bool /*ac*/) { // nothing to do by default }; - virtual bool _deactivate(int el_id, DualAlgoControl & solver_control) { + virtual bool _deactivate(int el_id, DualAlgoControl & /*solver_control*/) { // nothing do to by default if(status_[el_id]) return true; return false; }; - virtual bool _reactivate(int el_id, DualAlgoControl & solver_control) { + virtual bool _reactivate(int el_id, DualAlgoControl & /*solver_control*/) { // nothing to do by default if(!status_[el_id]) return false; return true; }; - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) { + virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & /*solver_control*/, int /*nb_bus*/) { // nothing to do by default if(bus_id_(el_id) == new_bus_id) return false; // nothing to do if the bus did not changed return true; }; - virtual void _change_p(int el_id, real_type new_p, bool my_status, DualAlgoControl & solver_control) { + virtual void _change_p(int /*el_id*/, real_type /*new_p*/, bool /*my_status*/, DualAlgoControl & /*solver_control*/) { // nothing to do by default }; - virtual void _change_q(int el_id, real_type new_p, bool my_status,DualAlgoControl & solver_control) { + virtual void _change_q(int /*el_id*/, real_type /*new_p*/, bool /*my_status*/,DualAlgoControl & /*solver_control*/) { // nothing to do by default }; diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 97b7d7d3..3bf0b4eb 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -198,13 +198,13 @@ class OneSideContainer_PQ : public OneSideContainer // nothing to do by default, as this class should be used as template for "one side" (eg loads or generators) // elements }; - virtual void _compute_results(const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, - bool ac) override { + virtual void _compute_results(const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, + bool /*ac*/) override { // nothing to do by default, as this class should be used as template for "one side" (eg loads or generators) // elements }; @@ -225,7 +225,7 @@ class OneSideContainer_PQ : public OneSideContainer } return false; }; - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) override { + virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { if(bus_id_(el_id) != new_bus_id){ solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -233,12 +233,12 @@ class OneSideContainer_PQ : public OneSideContainer } return false; }; - virtual void _change_p(int el_id, real_type new_p, bool my_status, DualAlgoControl & solver_control) override { + virtual void _change_p(int el_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override { if (abs(target_p_mw_(el_id) - new_p) > _tol_equal_float) { solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); } }; - virtual void _change_q(int el_id, real_type new_q, bool my_status,DualAlgoControl & solver_control) override { + virtual void _change_q(int el_id, real_type new_q, bool /*my_status*/,DualAlgoControl & solver_control) override { if (abs(target_q_mvar_(el_id) - new_q) > _tol_equal_float) { solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); } diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index 576f6ac2..b37e4f73 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -62,7 +62,7 @@ class OneSideContainer_ForBranch : public OneSideContainer // regular implementation public: OneSideContainer_ForBranch() noexcept = default; - explicit OneSideContainer_ForBranch(bool is_trafo) noexcept{}; + explicit OneSideContainer_ForBranch(bool /*is_trafo*/) noexcept{}; virtual ~OneSideContainer_ForBranch() noexcept = default; // public generic API @@ -96,10 +96,10 @@ class OneSideContainer_ForBranch : public OneSideContainer } void init_osc_forB( - const RealVect & els_p, - const RealVect & els_q, + const RealVect & /*els_p*/, + const RealVect & /*els_q*/, const Eigen::VectorXi & els_bus_id, - const std::string & name_el + const std::string & /*name_el*/ ) // osc: one side element { init_osc(els_bus_id); @@ -110,13 +110,13 @@ class OneSideContainer_ForBranch : public OneSideContainer // nothing to do by default, as this class should be used as template for "branch" (eg lines or trafos) // elements }; - virtual void _compute_results(const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, - bool ac) override { + virtual void _compute_results(const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, + bool /*ac*/) override { // nothing to do by default, as this class should be used as template for "branch" (eg lines or trafos) // elements @@ -142,7 +142,7 @@ class OneSideContainer_ForBranch : public OneSideContainer } return false; }; - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) override { + virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { const GridModelBusId & bus_me_id = bus_id_(el_id); if(bus_me_id != new_bus_id) { diff --git a/src/core/element_container/SGenContainer.cpp b/src/core/element_container/SGenContainer.cpp index 34623304..1bc5c455 100644 --- a/src/core/element_container/SGenContainer.cpp +++ b/src/core/element_container/SGenContainer.cpp @@ -68,7 +68,7 @@ void SGenContainer::set_state(SGenContainer::StateRes & my_state ) reset_results(); } -void SGenContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const { +void SGenContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_sgen = nb(); GlobalBusId bus_id_me; SolverBusId bus_id_solver; diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index d74f5eea..4cb7d552 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -88,12 +88,12 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA protected: virtual void _compute_results( - const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, + const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, bool ac) override { set_osc_pq_res_p(); diff --git a/src/core/element_container/ShuntContainer.cpp b/src/core/element_container/ShuntContainer.cpp index 96f6cc3d..07dc0031 100644 --- a/src/core/element_container/ShuntContainer.cpp +++ b/src/core/element_container/ShuntContainer.cpp @@ -64,11 +64,11 @@ void ShuntContainer::fillYbus(std::vector > & res, } } -void ShuntContainer::fillBp_Bpp(std::vector > & Bp, +void ShuntContainer::fillBp_Bpp(std::vector > & /*Bp*/, std::vector > & Bpp, const SolverBusIdVect & id_grid_to_solver, real_type sn_mva, - FDPFMethod xb_or_bx) const + FDPFMethod /*xb_or_bx*/) const { const int nb_shunt = nb(); real_type tmp; @@ -130,11 +130,11 @@ void ShuntContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_t } } -void ShuntContainer::_compute_results(const Eigen::Ref & Va, - const Eigen::Ref & Vm, +void ShuntContainer::_compute_results(const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const RealVect & /*bus_vn_kv*/, real_type sn_mva, bool ac) { diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index cb4d0e34..19a770b6 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -81,7 +81,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; // in DC i need that protected: - virtual void _change_p(int shunt_id, real_type new_p, bool my_status, DualAlgoControl & solver_control) override + virtual void _change_p(int shunt_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override { if(abs(target_p_mw_(shunt_id) - new_p) > _tol_equal_float){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); @@ -89,14 +89,14 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator } } - virtual void _change_q(int shunt_id, real_type new_q, bool my_status, DualAlgoControl & solver_control) override + virtual void _change_q(int shunt_id, real_type new_q, bool /*my_status*/, DualAlgoControl & solver_control) override { if(abs(target_q_mvar_(shunt_id) - new_q) > _tol_equal_float){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); } } - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) override { + virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { if(bus_id_(el_id) != new_bus_id){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); diff --git a/src/core/element_container/StorageContainer.cpp b/src/core/element_container/StorageContainer.cpp index feeb61c9..1c2c98e7 100644 --- a/src/core/element_container/StorageContainer.cpp +++ b/src/core/element_container/StorageContainer.cpp @@ -29,7 +29,7 @@ void StorageContainer::set_state(StorageContainer::StateRes & my_state) void StorageContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, - bool ac) const + bool /*ac*/) const { int nb_storage = nb(); GlobalBusId bus_id_me; diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index 9e99196d..de69ac33 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -83,12 +83,12 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; protected: - virtual void _compute_results(const Eigen::Ref & Va, - const Eigen::Ref & Vm, - const Eigen::Ref & V, - const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, - real_type sn_mva, + virtual void _compute_results(const Eigen::Ref & /*Va*/, + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const RealVect & /*bus_vn_kv*/, + real_type /*sn_mva*/, bool ac) override { diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index f62570cb..dd016314 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -85,7 +85,7 @@ void SvcContainer::set_state(SvcContainer::StateRes & my_state) reset_results(); } -void SvcContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const +void SvcContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_svc = nb(); for(int svc_id = 0; svc_id < nb_svc; ++svc_id){ @@ -212,7 +212,7 @@ bool SvcContainer::_reactivate(int svc_id, DualAlgoControl & solver_control) return false; } -bool SvcContainer::_change_bus(int svc_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int nb_bus) +bool SvcContainer::_change_bus(int svc_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) { // el_id is validated (and the proper IndexError raised) by `_generic_change_bus`, // which the caller runs *after* this function. Bail out here on an out-of-range diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 566d86bf..6178bd4d 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -154,7 +154,7 @@ void TrafoContainer::set_shift_dependent_rx( base_x_ = x_; // sort each (alpha -> correction) table by ascending alpha so the interpolation // in _shift_rx_corr_pct is well defined - for(int el_id = 0; el_id < size; ++el_id){ + for(size_t el_id = 0; el_id < size; ++el_id){ auto & xs = rx_corr_alpha_[el_id]; auto & ys = rx_corr_pct_[el_id]; if(xs.size() != ys.size()) diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 8e58a9d1..5b99a8a7 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -231,10 +231,10 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A & side1_changed, const std::vector & side2_changed ) @@ -340,7 +340,7 @@ shift_rad(-1.0), is_tap_side1(true) { if(my_id < 0) return; - if(my_id >= r_data_trafo.nb()) return; + if(static_cast(my_id) >= r_data_trafo.nb()) return; is_tap_side1 = r_data_trafo.is_tap_side1_[my_id]; ratio = r_data_trafo.ratio_.coeff(my_id); shift_rad = r_data_trafo.shift_.coeff(my_id); diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index 61e9d2e3..e0f2c1fa 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -77,7 +77,7 @@ class TwoSidesContainer : public GenericContainer res_theta2_deg(0.) { if (my_id < 0) return; - if (my_id >= r_data_two_sides.nb()) return; + if (static_cast(my_id) >= r_data_two_sides.nb()) return; id = my_id; if(r_data_two_sides.names_.size()){ @@ -402,8 +402,8 @@ class TwoSidesContainer : public GenericContainer side_2_.set_state(std::get<5>(my_state)); auto size = nb(); if(names_.size() > 0) check_size(names_, size, "names"); // names are optional - if(side_1_.nb() != size) throw std::runtime_error("Side_1 do not have the proper size"); - if(side_2_.nb() != size) throw std::runtime_error("Side_2 do not have the proper size"); + if(static_cast(side_1_.nb()) != size) throw std::runtime_error("Side_1 do not have the proper size"); + if(static_cast(side_2_.nb()) != size) throw std::runtime_error("Side_2 do not have the proper size"); } bool resolve_status(int el_id, bool side_1_modif, DualAlgoControl & solver_control){ @@ -435,45 +435,45 @@ class TwoSidesContainer : public GenericContainer // hook when disconnecting or changing the bus a given element // for example used when disconnecting a powerline on only one side // to update yac_eff_12_, yac_eff_21_ etc. in TwoSidesContainer_rxh_A - virtual void _update_effective_coeffs_one_el(int el_id) { + virtual void _update_effective_coeffs_one_el(int /*el_id*/) { // nothing to do by default } - virtual bool _deactivate(int el_id, DualAlgoControl & solver_control) { + virtual bool _deactivate(int el_id, DualAlgoControl & /*solver_control*/) { // nothing to do by default: handled in derived class if(status_global_[el_id]) return true; return false; } - virtual bool _reactivate(int el_id, DualAlgoControl & solver_control) { + virtual bool _reactivate(int el_id, DualAlgoControl & /*solver_control*/) { // nothing to do by default: handled in derived class if(!status_global_[el_id]) return true; return false; } virtual void _change_bus_side_1( - int el_id, - GridModelBusId new_gridmodel_bus_id, - DualAlgoControl & solver_control, - const SubstationContainer & substation, - bool has_effectively_changed + int /*el_id*/, + GridModelBusId /*new_gridmodel_bus_id*/, + DualAlgoControl & /*solver_control*/, + const SubstationContainer & /*substation*/, + bool /*has_effectively_changed*/ ) { // nothing to do by default: handled in derived class } virtual void _change_bus_side_2( - int el_id, - GridModelBusId new_gridmodel_bus_id, - DualAlgoControl & solver_control, - const SubstationContainer & substation, - bool has_effectively_changed + int /*el_id*/, + GridModelBusId /*new_gridmodel_bus_id*/, + DualAlgoControl & /*solver_control*/, + const SubstationContainer & /*substation*/, + bool /*has_effectively_changed*/ ) { // nothing to do by default: handled in derived class } virtual void _update_topo( - DualAlgoControl & solver_control, - SubstationContainer & substations, - const std::vector & side1_changed, - const std::vector & side2_changed + DualAlgoControl & /*solver_control*/, + SubstationContainer & /*substations*/, + const std::vector & /*side1_changed*/, + const std::vector & /*side2_changed*/ ) { // nothing to do by default: handled in derived class diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index e4c1a461..b0e4a6c3 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -118,7 +118,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer ydc_22(0., 0.) { if(my_id < 0) return; - if(my_id >= r_data.nb()) return; + if(static_cast(my_id) >= r_data.nb()) return; r_pu = r_data.r_.coeff(my_id); x_pu = r_data.x_.coeff(my_id); h1_pu = r_data.h_side_1_.coeff(my_id); @@ -170,12 +170,14 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer std::vector // limit_a2_ka (optional, empty if unset) >; - // thermal (current) limit, in kA, per side -- input, not a powerflow result. + // current limit, in kA, per side -- input, not a powerflow result. // Optional: empty (size 0) if never set (e.g. pandapower-origin grids). - void set_thermal_limit(const RealVect & limit_a1_ka, const RealVect & limit_a2_ka){ - check_size(limit_a1_ka, nb(), "TwoSidesContainer_rxh_A::set_thermal_limit (side 1)"); - check_size(limit_a2_ka, nb(), "TwoSidesContainer_rxh_A::set_thermal_limit (side 2)"); + void set_limit_a1_ka(const RealVect & limit_a1_ka){ + check_size(limit_a1_ka, nb(), "TwoSidesContainer_rxh_A::set_limit_a1_ka"); limit_a1_ka_ = limit_a1_ka; + } + void set_limit_a2_ka(const RealVect & limit_a2_ka){ + check_size(limit_a2_ka, nb(), "TwoSidesContainer_rxh_A::set_limit_a2_ka"); limit_a2_ka_ = limit_a2_ka; } Eigen::Ref get_limit_a1_ka() const {return limit_a1_ka_;} @@ -222,7 +224,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer BX_fpdf_coeffs_.init(nb()); XB_fpdf_coeffs_.init(nb()); - for(int el_id = 0; el_id < nb(); ++el_id){ + for(int el_id = 0; el_id < static_cast(nb()); ++el_id){ BX_fpdf_coeffs_.assign_el(el_id, this->get_fdpf_coeffs(el_id, FDPFMethod::BX)); XB_fpdf_coeffs_.assign_el(el_id, this->get_fdpf_coeffs(el_id, FDPFMethod::XB)); } @@ -446,7 +448,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer std::vector > & res, bool ac, const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva) const + real_type /*sn_mva*/) const { const size_t nb_els = nb(); const std::vector & status1 = side_1_.get_status(); @@ -532,7 +534,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer virtual void fillBdc( std::vector > & res, const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva) const override + real_type /*sn_mva*/) const override { const size_t nb_els = nb(); const std::vector & status1 = side_1_.get_status(); @@ -571,7 +573,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer virtual void fillBp_Bpp(std::vector > & Bp, std::vector > & Bpp, const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva, + real_type /*sn_mva*/, FDPFMethod xb_or_bx) const { @@ -668,7 +670,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer void fillBf_for_PTDF(std::vector > & Bf, const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva, + real_type /*sn_mva*/, int nb_powerline, bool transpose) const { @@ -898,7 +900,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer return x_(el_id); } - virtual int fillBf_for_PTDF_id(int el_id, int nb_powerline) const{ + virtual int fillBf_for_PTDF_id(int el_id, int /*nb_powerline*/) const{ return el_id; } diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 2a7ba192..0514b9aa 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -166,7 +166,7 @@ Eigen::VectorXi BaseAlgo::extract_slack_bus_id(Eigen::Ref pv, { if(tmp[k]) { - if((i_res >= nb_slacks)){ + if((i_res >= static_cast(nb_slacks))){ // TODO DEBUG MODE throw std::runtime_error("BaseAlgo::extract_slack_bus_id: too many slack found. Maybe a bus is both PV and PQ ?"); } @@ -174,7 +174,7 @@ Eigen::VectorXi BaseAlgo::extract_slack_bus_id(Eigen::Ref pv, ++i_res; } } - if(res.size() != i_res){ + if(static_cast(res.size()) != i_res){ // TODO DEBUG MODE throw std::runtime_error("BaseAlgo::extract_slack_bus_id: Some slacks are not found in your grid."); } diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 4bcd8168..141b07c4 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -176,15 +176,15 @@ class LS2G_API BaseAlgo : public BaseConstants // Every AC solver overrides this; the DC solver does not (it uses `compute_pf_dc`) and // therefore inherits this throwing default (symmetric with `compute_pf_dc` below). virtual - bool compute_pf(const Eigen::SparseMatrix & Ybus, - CplxVect & V, // store the results of the powerflow and the Vinit ! - const CplxVect & Sbus, - Eigen::Ref slack_ids, - const RealVect & slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol + bool compute_pf(const Eigen::SparseMatrix & /*Ybus*/, + CplxVect & /*V*/, // store the results of the powerflow and the Vinit ! + const CplxVect & /*Sbus*/, + Eigen::Ref /*slack_ids*/, + const RealVect & /*slack_weights*/, + Eigen::Ref /*pv*/, + Eigen::Ref /*pq*/, + int /*max_iter*/, + real_type /*tol*/ ){ throw std::runtime_error("compute_pf (complex AC entry point) is not available for this solver (DC solvers use compute_pf_dc)."); } @@ -194,13 +194,13 @@ class LS2G_API BaseAlgo : public BaseConstants // `V` carries the complex initial voltage (slack angle + voltage setpoints) on input // and the complex result on output. There is no max_iter / tol: DC is a single linear solve. virtual - bool compute_pf_dc(const Eigen::SparseMatrix & Bbus, - CplxVect & V, - const RealVect & Pbus, - Eigen::Ref slack_ids, - const RealVect & slack_weights, - Eigen::Ref pv, - Eigen::Ref pq){ + bool compute_pf_dc(const Eigen::SparseMatrix & /*Bbus*/, + CplxVect & /*V*/, + const RealVect & /*Pbus*/, + Eigen::Ref /*slack_ids*/, + const RealVect & /*slack_weights*/, + Eigen::Ref /*pv*/, + Eigen::Ref /*pq*/){ throw std::runtime_error("compute_pf_dc is only available for DC solvers."); } @@ -211,15 +211,15 @@ class LS2G_API BaseAlgo : public BaseConstants virtual RealMat get_ptdf(){ throw std::runtime_error("Impossible to get the PTDF matrix with this solver type."); } - virtual RealMat get_lodf(const IntVect & from_bus, - const IntVect & to_bus){ // TODO interface is likely to change + virtual RealMat get_lodf(const IntVect & /*from_bus*/, + const IntVect & /*to_bus*/){ // TODO interface is likely to change throw std::runtime_error("Impossible to get the LODF matrix with this solver type."); } virtual Eigen::SparseMatrix get_bsdf(){ // TODO interface is likely to change throw std::runtime_error("Impossible to get the BSDF matrix with this solver type."); } - virtual void update_internal_Ybus(const Coeff & new_coeffs, bool add){ + virtual void update_internal_Ybus(const Coeff & /*new_coeffs*/, bool /*add*/){ throw std::runtime_error("Function update_internal_Ybus not implemented in general."); } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 553d532d..c2ac7326 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -410,13 +410,13 @@ void BaseDCAlgo::remove_slack_buses(int nb_bus_solver, const Eigen res_mat = Eigen::SparseMatrix(sizeYbus_without_slack_, sizeYbus_without_slack_); // TODO dist slack: -1 or -mat_bus_id_.size() here ???? std::vector > tripletList; tripletList.reserve(ref_mat.nonZeros()); - for (size_t k=0; k < nb_bus_solver; ++k){ + for (int k=0; k < nb_bus_solver; ++k){ if(mat_bus_id_(k) == -1) continue; // I don't add anything to the slack bus for (typename Eigen::SparseMatrix::InnerIterator it(ref_mat, k); it; ++it) { - size_t row_res = static_cast(it.row()); // TODO Eigen::Index here ? + int row_res = static_cast(it.row()); // TODO Eigen::Index here ? row_res = mat_bus_id_(row_res); - size_t col_res = static_cast(it.col()); // should be k // TODO Eigen::Index here ? + int col_res = static_cast(it.col()); // should be k // TODO Eigen::Index here ? col_res = mat_bus_id_(col_res); if(row_res == -1) continue; if(col_res == -1) continue; @@ -503,7 +503,7 @@ RealMat BaseDCAlgo::get_lodf(const IntVect & from_bus, const RealMat PTDF = get_ptdf(); // size n_line x n_bus RealMat LODF = RealMat::Zero(from_bus.size(), from_bus.rows()); // nb_line, nb_line const real_type tol_equal_float = _tol_equal_float; - for(size_t line_id=0; line_id < from_bus.size(); ++line_id){ + for(Eigen::Index line_id=0; line_id < from_bus.size(); ++line_id){ auto f_bus = from_bus(line_id); auto t_bus = to_bus(line_id); if ((f_bus == BaseConstants::_deactivated_bus_id) || (t_bus == BaseConstants::_deactivated_bus_id)){ diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 2ca611ce..ddd50b5d 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -65,7 +65,7 @@ class BaseFDPFAlgo: public BaseAlgo CplxVect evaluate_mismatch(const Eigen::SparseMatrix & Ybus, const CplxVect & V, const CplxVect & Sbus, - size_t slack_id, // id of the ref slack bus + size_t /*slack_id*/, // id of the ref slack bus real_type slack_absorbed, const RealVect & slack_weights) { diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp index f2f47bb0..e3266223 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp @@ -14,7 +14,7 @@ bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, const CplxVect & Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, // currently unused + const RealVect & /*slack_weights*/, // currently unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index 3c2fb4e1..5a81876c 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -188,7 +188,7 @@ inline void NRSystem::fill_internal_variables() cplx_type * ds_dva_val_ptr = dS_dVa_.valuePtr(); size_t pos = 0; - for (size_t col_id = 0; col_id < size_dS; ++col_id) { + for (size_t col_id = 0; col_id < static_cast(size_dS); ++col_id) { for (Eigen::SparseMatrix::InnerIterator it(Ybus, col_id); it; ++it) { const size_t row_id = static_cast(it.row()); const cplx_type el_ybus = it.value(); diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index 8ee95afc..85cd5515 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -52,7 +52,7 @@ class LS2G_API NoScalingPolicy final : public ScalingPolicy { public: virtual ScalingPolicyType type() const final {return ScalingPolicyType::NoScaling;} - virtual real_type scale(const NRSystem& system, const RealVect & F) final + virtual real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) final { return 1.; } From f141acabe893fc3fa4b026af003ae24cd08469e0 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 3 Jul 2026 17:44:44 +0200 Subject: [PATCH 022/166] fix some issues with the control of the algorithm in time series and security analysis Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 28 ++++++++ docs/security_analysis.rst | 27 ++++--- examples/contingency_analysis.py | 2 - lightsim2grid/contingencyAnalysis.py | 27 ++++--- ...st_ContingencyAnalysis_limit_violations.py | 38 +++++++++- .../tests/test_scaling_refactor_policies.py | 27 +++++++ src/bindings/python/binding_batch.cpp | 45 ++++++++++-- src/core/LSGrid.cpp | 2 + .../batch_algorithm/BaseBatchSolverSynch.hpp | 20 +++++- .../batch_algorithm/ContingencyAnalysis.cpp | 70 +++++++++++++------ .../batch_algorithm/ContingencyAnalysis.hpp | 6 +- src/core/batch_algorithm/LimitViolation.hpp | 21 +++--- src/core/batch_algorithm/TimeSeries.hpp | 2 +- 13 files changed, 251 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e0a72390..13ad14c3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -129,6 +129,34 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. order, so the list was misleading (harmless today since there is no data dependency between the two, but a latent hazard for future refactors). The initializer list now matches declaration order. +- [BREAKING] `ContingencyAnalysisCPP.compute()` (and the python `ContingencyAnalysis.run` / + `run_ac` / `run_dc` / `compute_V`) now raises a ``RuntimeError`` if the pre-contingency ("n", + no disconnection) powerflow itself does not converge, instead of silently returning with all + voltages at 0 and no contingency simulated at all. Every contingency is solved relative to + this base case, so a diverging base case made the whole analysis meaningless; failing loudly + is safer than silently returning an all-zero result that could be mistaken for "every + contingency was skipped". +- [ADDED] `ViolationElementType.GRID` and two new `LimitViolationType` values, + `NOT_SIMULATED` and `DIVERGENCE`, so a non-converged contingency (`converged()` / + `ContingencyResult.converged` is `False`) now reports exactly one `LimitViolation` in + `get_violations()` / `limit_violations` instead of an empty list: `NOT_SIMULATED` when a + pre-check (graph connectivity) skips the contingency without ever invoking the solver (eg it + splits the grid in multiple connected components), `DIVERGENCE` when the solver is invoked but + does not converge (eg reaches `max_iter`). This only applies to `get_violations()` / + `get_violations_n()` -- see the `ContingencyAnalysisCPP.compute()` change above for the + pre-contingency ("n") case itself. +- [FIXED] `ContingencyAnalysisCPP` / `TimeSeriesCPP` (and so the python `ContingencyAnalysis`, + `SecurityAnalysis` and `TimeSerie` wrappers) solved every powerflow with a fresh, independent, + *default* solver (`NR_SparseLU`, no scaling/damping policy), completely ignoring whatever + algorithm type or `AlgoConfig` (eg a `ScalingPolicyType.MaxVoltageChange` damping) was set on + the source `LSGrid`. This is the same class of bug as the `LSGrid` copy-constructor fix above, + but in `BaseBatchSolverSynch`: a grid whose own `ac_pf()` converged fine (thanks to a configured + damping policy) could see its pre-contingency ("n") powerflow diverge inside + `ContingencyAnalysisCPP.compute()`, now raising the `RuntimeError` added above instead of being + silently swallowed. The algorithm type and config are now copied from the grid model once at + construction time; `get_algo_config()` / `set_algo_config()` were added to + `ContingencyAnalysisCPP` / `TimeSeriesCPP` to re-apply a config afterwards (eg after + `change_algorithm()`, which resets to that algorithm's defaults). - [IMPROVED] (cpp) the codebase now compiles warning-free under ``-Wall -Wextra -Werror``: fixed 29 signed/unsigned comparison warnings (``int`` / ``Eigen::Index`` vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index 3cbf72d2..234e9adb 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -197,13 +197,14 @@ contingencies were added), not a dictionary: Each ``LimitViolation`` reports: - ``element_type``: :class:`lightsim2grid.contingencyAnalysis.ViolationElementType` - (``BUS``, ``LINE`` or ``TRAFO``); + (``BUS``, ``LINE``, ``TRAFO``, or ``GRID`` for a non-converged contingency, see below); - ``element_id``: the grid-model bus id (for ``BUS``) or the local line / trafo id (for - ``LINE`` / ``TRAFO``); -- ``side``: ``1`` or ``2`` for ``LINE`` / ``TRAFO`` (unused, ``0``, for ``BUS``); + ``LINE`` / ``TRAFO``); unused (``-1``) for ``GRID``; +- ``side``: ``1`` or ``2`` for ``LINE`` / ``TRAFO`` (unused, ``0``, for ``BUS`` / ``GRID``); - ``violation_type``: :class:`lightsim2grid.contingencyAnalysis.LimitViolationType` - (``LOW_VOLTAGE``, ``HIGH_VOLTAGE`` or ``CURRENT``); -- ``value`` / ``limit``: the value reached and the limit that was violated. + (``LOW_VOLTAGE``, ``HIGH_VOLTAGE``, ``CURRENT``, ``NOT_SIMULATED`` or ``DIVERGENCE``, see below); +- ``value`` / ``limit``: the value reached and the limit that was violated; unused (``NaN``) for + ``NOT_SIMULATED`` / ``DIVERGENCE``. Each ``ContingencyResult`` reports ``element_ids`` (the branch ids disconnected by this contingency -- always present, even without a ``name``), the optional user-supplied @@ -219,10 +220,18 @@ contingency ``converged``, and its ``limit_violations``. the usual ``get_flows()`` is completely unaffected -- there is no need to pay for the extra per-element voltage / current checks if you only want the flows. - Also, if a contingency (or the pre-contingency case) does not converge, its - ``limit_violations`` is left empty because no result is available for it -- this does - **not** mean there is no violation, unlike a converged case with an empty list, which - genuinely means none was found. + Also, if a contingency does not converge, its ``limit_violations`` contains exactly one + ``LimitViolation`` with ``element_type == ViolationElementType.GRID`` and ``violation_type`` + either ``LimitViolationType.NOT_SIMULATED`` (a pre-check -- eg it splits the grid in multiple + connected components -- skipped this contingency without ever invoking the solver) or + ``LimitViolationType.DIVERGENCE`` (the solver ran but did not converge, eg reached + ``max_iter``). This is unlike a converged case with an empty ``limit_violations`` list, which + genuinely means no violation was found. + + The pre-contingency ("n") case is different: if it does not converge, `compute` (and thus + `run` / `run_ac` / `run_dc`) raises a ``RuntimeError`` instead, since every contingency is + solved relative to that base case -- a diverging base case makes the whole analysis + meaningless. Violation checking is fully compatible with the ``handle_disconnected_grid`` and ``nb_thread`` options described above: it is performed **inline, per contingency, inside the thread that diff --git a/examples/contingency_analysis.py b/examples/contingency_analysis.py index 3c4050d7..a097ff77 100644 --- a/examples/contingency_analysis.py +++ b/examples/contingency_analysis.py @@ -11,8 +11,6 @@ import grid2op from grid2op.Parameters import Parameters -from grid2op.Action import BaseAction -from grid2op.Chronics import ChangeNothing import warnings from lightsim2grid import LightSimBackend, ContingencyAnalysis diff --git a/lightsim2grid/contingencyAnalysis.py b/lightsim2grid/contingencyAnalysis.py index c3197ffb..cb6a7e57 100644 --- a/lightsim2grid/contingencyAnalysis.py +++ b/lightsim2grid/contingencyAnalysis.py @@ -34,8 +34,10 @@ class PreContingencyResult: """Limit violations for the pre-contingency ("n", no disconnection) case. .. note:: - If ``converged`` is False, ``limit_violations`` is empty because no result is - available -- this does NOT mean there is no violation. + ``converged`` is always True here: `ContingencyAnalysisCPP.compute` raises a + ``RuntimeError`` if the pre-contingency powerflow itself does not converge (every + contingency is solved relative to this base case, so a diverging base case makes the + whole analysis meaningless). """ converged: bool limit_violations: List[LimitViolation] @@ -46,8 +48,11 @@ class ContingencyResult: """Limit violations for a single simulated contingency. .. note:: - If ``converged`` is False (the contingency was skipped or diverged), ``limit_violations`` - is empty because no result is available -- this does NOT mean there is no violation. + If ``converged`` is False, ``limit_violations`` contains exactly one ``LimitViolation`` + with ``element_type == ViolationElementType.GRID`` and ``violation_type`` either + ``LimitViolationType.NOT_SIMULATED`` (a pre-check skipped this contingency, eg it splits + the grid, without ever invoking the solver) or ``LimitViolationType.DIVERGENCE`` (the + solver ran but did not converge). """ element_ids: List[int] #: branch ids (lines then trafos) disconnected by this contingency contingency_name: Optional[str] #: user-supplied name, see `add_single_contingency` @@ -469,6 +474,10 @@ def run(self) -> SecurityAnalysisResult: set via ``this_instance.compute_limit_violations = True``), else a `RuntimeError` is raised. Prefer `run_ac` / `run_dc` if you want to also select the algorithm family. + A `RuntimeError` is also raised if the pre-contingency ("n") powerflow itself does not + converge -- every contingency is solved relative to this base case, so a diverging base + case makes the whole analysis meaningless. + The returned object mimics pypowsybl's security analysis result: .. code-block:: python @@ -483,10 +492,12 @@ def run(self) -> SecurityAnalysisResult: cont.limit_violations .. note:: - A `converged == False` entry (pre- or post-contingency) has an empty - `limit_violations` list because no result is available for it (skipped or diverged - powerflow) -- this does NOT mean there is no violation, unlike a `converged == True` - entry with an empty list, which genuinely means no violation was found. + A `converged == False` post-contingency entry has exactly one `LimitViolation` in + `limit_violations`, with `element_type == ViolationElementType.GRID` and + `violation_type` either `LimitViolationType.NOT_SIMULATED` (a pre-check skipped it, + eg it splits the grid) or `LimitViolationType.DIVERGENCE` (the solver ran but did not + converge) -- unlike a `converged == True` entry with an empty list, which genuinely + means no violation was found. """ if self.__is_closed: raise RuntimeError("This is closed, you cannot use it.") diff --git a/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py b/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py index 54c74266..8992c819 100644 --- a/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py +++ b/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py @@ -113,7 +113,8 @@ def tearDown(self): self.env.close() def test_converged_matches_nan_heuristic(self): - # 17 converges, 18 diverges (splits the grid), 19 converges -- see + # 17 converges, 18 diverges (splits the grid -> a pre-check skips it before the solver + # is ever invoked, hence NOT_SIMULATED), 19 converges -- see # test_SecurityAnlysis_cpp.test_compute_nonconnected_graph for the reference behaviour SA = ContingencyAnalysisCPP(self.env.backend._grid, True) lid_cont = [17, 18, 19] @@ -129,8 +130,39 @@ def test_converged_matches_nan_heuristic(self): assert bool(converged[cont_id]) == nan_heuristic_converged, \ f"converged() disagrees with the nan/0 heuristic for contingency {cont_id}" if not converged[cont_id]: - assert violations[cont_id] == [], \ - "a non-converged contingency must not report fabricated violations" + viol = violations[cont_id] + assert len(viol) == 1, \ + "a non-converged contingency must report exactly one GRID violation, " \ + f"got {viol}" + assert viol[0].element_type == ViolationElementType.GRID + assert viol[0].violation_type == LimitViolationType.NOT_SIMULATED, \ + "contingency 18 splits the grid: a pre-check should skip it before the " \ + "solver is invoked" + + +class TestBaseCaseDivergenceRaises(unittest.TestCase): + """the pre-contingency ("n") case is special: unlike a single contingency, it cannot + silently be reported as "no result available" (every contingency is solved relative to + it), so a non-converging base case must raise instead.""" + def setUp(self): + import grid2op + from lightsim2grid import LightSimBackend + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + self.env.reset(seed=0, options={"time serie id": 0}) + + def tearDown(self): + self.env.close() + + def test_n_divergence_raises(self): + SA = ContingencyAnalysisCPP(self.env.backend._grid, True) + SA.add_all_n1() + nb_bus = self.env.backend._grid.get_bus_vn_kv().shape[0] + # deliberately bad starting point + a single NR iteration: the base case cannot converge + bad_vinit = np.full(nb_bus, 0.5, dtype=complex) + with self.assertRaises(RuntimeError): + SA.compute(bad_vinit, 1, self.env.backend.tol) class TestThreadIndependence(unittest.TestCase): diff --git a/lightsim2grid/tests/test_scaling_refactor_policies.py b/lightsim2grid/tests/test_scaling_refactor_policies.py index d19c5a23..6d321a72 100644 --- a/lightsim2grid/tests/test_scaling_refactor_policies.py +++ b/lightsim2grid/tests/test_scaling_refactor_policies.py @@ -451,6 +451,33 @@ def test_set_ac_algo_config_round_trip(self): self.assertAlmostEqual(out.real_params[0], 0.4) self.assertAlmostEqual(out.real_params[4], 5e-4, places=10) + def test_algo_config_survives_copy(self): + """LSGrid.copy() (used internally by e.g. ContingencyAnalysis / TimeSeries, + which each hold their own internal copy of the grid model) must preserve + the AC/DC solver's AlgoConfig, not just its algorithm type. Regression + test for a bug where `LSGrid`'s copy constructor called `change_algorithm` + (which resets to a default AlgoConfig) without re-applying the source's + AlgoConfig, silently dropping e.g. a tuned MaxVoltageChange scaling policy.""" + gm = self._try_import_gridmodel_direct() + if gm is None: + self.skipTest("LSGrid C++ class not directly instantiable without a grid") + + # the AC solver (NR-family) is the one whose AlgoConfig (scaling policy, + # max_dVa/max_dVm, ...) is meaningful and must survive the copy; DC + # solvers don't override get_config/set_config (always empty AlgoConfig) + ac_cfg = AlgoConfig() + ac_cfg.int_params = [int(ScalingPolicyType.MaxVoltageChange), + int(RefactorPolicyType.EveryN), 20, 3] + ac_cfg.real_params = [0.4, 0.08, 1e-3, 0.7, 5e-4, 0.9] + gm.set_ac_algo_config(ac_cfg) + + gm_copy = gm.copy() + + out_ac = gm_copy.get_ac_algo_config() + self.assertEqual(out_ac.int_params, ac_cfg.int_params) + for a, b in zip(out_ac.real_params, ac_cfg.real_params): + self.assertAlmostEqual(a, b, places=10) + if __name__ == "__main__": unittest.main() diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index 1717ab9c..e14b3f53 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -18,21 +18,33 @@ void bind_batch(py::module_& m) { py::enum_(m, "ViolationElementType", "The kind of element on which a limit was violated.") .value("BUS", ViolationElementType::BUS) .value("LINE", ViolationElementType::LINE) - .value("TRAFO", ViolationElementType::TRAFO); + .value("TRAFO", ViolationElementType::TRAFO) + .value("GRID", ViolationElementType::GRID, + "The whole grid / contingency, not a specific element (see LimitViolationType.NOT_SIMULATED " + "/ LimitViolationType.DIVERGENCE)."); py::enum_(m, "LimitViolationType", "The kind of limit that was violated.") .value("LOW_VOLTAGE", LimitViolationType::LOW_VOLTAGE) .value("HIGH_VOLTAGE", LimitViolationType::HIGH_VOLTAGE) - .value("CURRENT", LimitViolationType::CURRENT); + .value("CURRENT", LimitViolationType::CURRENT) + .value("NOT_SIMULATED", LimitViolationType::NOT_SIMULATED, + "A pre-check (graph connectivity) skipped this contingency: the solver was never " + "invoked (element_type is ViolationElementType.GRID).") + .value("DIVERGENCE", LimitViolationType::DIVERGENCE, + "The solver was invoked for this contingency but did not converge (element_type is " + "ViolationElementType.GRID)."); py::class_(m, "LimitViolation", "A single limit violation, as detected by ContingencyAnalysisCPP.") .def_readonly("element_type", &LimitViolation::element_type) .def_readonly("element_id", &LimitViolation::element_id, - "grid-model bus id for BUS ; local (0-based, own type) line / trafo id otherwise") - .def_readonly("side", &LimitViolation::side, "1 or 2 for LINE / TRAFO ; unused (0) for BUS") + "grid-model bus id for BUS ; local (0-based, own type) line / trafo id otherwise ; " + "unused (-1) for GRID") + .def_readonly("side", &LimitViolation::side, "1 or 2 for LINE / TRAFO ; unused (0) for BUS / GRID") .def_readonly("violation_type", &LimitViolation::violation_type) - .def_readonly("value", &LimitViolation::value, "value reached") - .def_readonly("limit", &LimitViolation::limit, "limit that was violated"); + .def_readonly("value", &LimitViolation::value, + "value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE") + .def_readonly("limit", &LimitViolation::limit, + "limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE"); py::class_(m, "TimeSeriesCPP", DocComputers::Computers.c_str()) .def(py::init()) @@ -49,6 +61,13 @@ void bind_batch(py::module_& m) { .def("change_solver", &TimeSeries::change_algorithm, "DEPRECATED: use 'change_algorithm' instead") .def("available_default_algorithms", &TimeSeries::available_default_algorithms, DocLSGrid::available_default_algorithms.c_str()) .def("get_algo_type", &TimeSeries::get_algo_type, DocLSGrid::get_algo_type.c_str()) + .def("get_algo_config", &TimeSeries::get_algo_config, + "Config (eg ScalingPolicyType / damping parameters) of the internal solver used for " + "every step. Copied once from the grid model's own get_ac_algo_config() at " + "construction time, then independent of it; re-apply with set_algo_config() if you " + "change the grid model's config afterwards, or after change_algorithm().") + .def("set_algo_config", &TimeSeries::set_algo_config, py::arg("config"), + "See get_algo_config().") // timers .def("total_time", &TimeSeries::total_time, DocComputers::total_time.c_str()) @@ -118,6 +137,14 @@ void bind_batch(py::module_& m) { .def("change_solver", &ContingencyAnalysis::change_algorithm, "DEPRECATED: use 'change_algorithm' instead") .def("available_default_algorithms", &ContingencyAnalysis::available_default_algorithms, DocLSGrid::available_algorithm_names.c_str()) .def("get_algo_type", &ContingencyAnalysis::get_algo_type, DocLSGrid::get_algo_type.c_str()) + .def("get_algo_config", &ContingencyAnalysis::get_algo_config, + "Config (eg ScalingPolicyType / damping parameters) of the internal solver used for " + "the pre-contingency ('n') and every per-contingency powerflow. Copied once from the " + "grid model's own get_ac_algo_config() at construction time, then independent of it; " + "re-apply with set_algo_config() if you change the grid model's config afterwards, or " + "after change_algorithm().") + .def("set_algo_config", &ContingencyAnalysis::set_algo_config, py::arg("config"), + "See get_algo_config().") // add contingencies .def("add_all_n1", &ContingencyAnalysis::add_all_n1, DocSecurityAnalysis::add_all_n1.c_str()) @@ -165,7 +192,11 @@ void bind_batch(py::module_& m) { "Per contingency (row order matches my_defaults()): whether it converged / was " "actually simulated (False for skipped or diverged contingencies).") .def("get_violations", &ContingencyAnalysis::get_violations, - "Per contingency (row order matches my_defaults()): list of LimitViolation.", + "Per contingency (row order matches my_defaults()): list of LimitViolation. A " + "non-converged contingency (converged() is False) has exactly one LimitViolation " + "here, with element_type ViolationElementType.GRID and violation_type either " + "LimitViolationType.NOT_SIMULATED (a pre-check skipped it, eg it splits the grid) or " + "LimitViolationType.DIVERGENCE (the solver ran but did not converge).", py::return_value_policy::reference_internal) .def("converged_n", &ContingencyAnalysis::converged_n, "Whether the pre-contingency ('n') powerflow converged.") diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 8617b271..c7b3936e 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -64,7 +64,9 @@ LSGrid::LSGrid(const LSGrid & other) // assign the right solver reset(true, true, true); _algo.change_algorithm(other.get_algo_type()); + _algo.set_config(other.get_algo().get_config()); _dc_algo.change_algorithm(other.get_dc_algo_type()); + _dc_algo.set_config(other.get_dc_algo().get_config()); } //pickle diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index 8c8247f2..fb2c33de 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -28,7 +28,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants using RealMat = Eigen::Matrix; using CplxMat = Eigen::Matrix; - explicit BaseBatchSolverSynch(const LSGrid & init_grid_model) noexcept: + explicit BaseBatchSolverSynch(const LSGrid & init_grid_model): _grid_model(init_grid_model), n_line_(init_grid_model.nb_powerline()), n_trafos_(init_grid_model.nb_trafo()), @@ -42,6 +42,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // hvdc angle-droop data flows through this pointer (the member // _grid_model has a stable address, the class is not copyable) _algo.set_lsgrid(&_grid_model); + // inherit the source grid's AC algorithm (type + config, eg a + // ScalingPolicyType::MaxVoltageChange damping set via + // set_ac_algo_config): _algo is otherwise a fresh, independent, + // default (undamped NR_SparseLU) solver, so without this the n / + // n-1 powerflows run here can converge to a different root than + // (or fail to converge unlike) init_grid_model.ac_pf() -- same class + // of bug as the one just fixed in LSGrid's copy constructor. + _algo.change_algorithm(init_grid_model.get_algo_type()); + _algo.set_config(init_grid_model.get_ac_algo_config()); } virtual ~BaseBatchSolverSynch() noexcept = default; // to avoid warning about overload virtual BaseBatchSolverSynch(const BaseBatchSolverSynch&) = delete; @@ -70,6 +79,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants AlgorithmType get_algo_type() const {return _algo.get_type(); } + // the config (eg ScalingPolicyType / damping parameters) of the internal + // solver used for every n / n-1 powerflow here. This is a fresh, independent + // solver (see the constructor): the source LSGrid's own config is copied in + // once at construction time, but is NOT kept in sync afterwards, so re-apply + // it here if you change it on the grid model after building this object (or + // after a change_algorithm(), which resets to that new algorithm's defaults). + AlgoConfig get_algo_config() const {return _algo.get_config(); } + void set_algo_config(const AlgoConfig & cfg) {_algo.set_config(cfg); } + // // TODO // void change_gridmodel(const GridModel & new_grid_model){ // CplxVect V = CplxVect::Constant(new_grid_model.total_bus(), 1.04); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index f980e40d..4d930fa5 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace ls2g { @@ -586,35 +587,43 @@ void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_typ timer_preproc ); + if(!n_powerflow_has_conv){ + // a diverging base case makes the whole analysis meaningless (every contingency is + // solved starting from / relative to this state) -- fail loudly instead of silently + // returning with all-zero voltages and no contingency simulated at all. + std::ostringstream exc_; + exc_ << "ContingencyAnalysis::compute: the pre-contingency (\"n\", no disconnection) " + "powerflow did not converge (error: " << _algo.get_error() << "). No contingency " + "can be simulated from a non-converged base case; check the input `Vinit` / " + "`tol` / `max_iter`, or the grid state itself."; + throw std::runtime_error(exc_.str()); + } + if(_compute_limit_violations_){ // pre-contingency ("n") case: this is the only point at which the base-case V is // observable -- the per-contingency solves (below) reuse / overwrite the member // `_algo`'s state in the single-threaded path, and build separate per-thread copies // only afterward (in init_thread) in the multi-threaded path. - _converged_n_ = n_powerflow_has_conv; - if(n_powerflow_has_conv){ - const CplxVect V_n = _algo.get_V(); - const std::vector no_skip; - check_bus_voltage_violations(V_n, id_me_to_solver_, - _grid_model.get_bus_vmin_kv(), _grid_model.get_bus_vmax_kv(), - _grid_model.get_bus_vn_kv(), nullptr, _violations_n_); - check_current_violations(_grid_model.get_powerlines_as_data(), ViolationElementType::LINE, - V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), - ac_solver_used, sn_mva, - _grid_model.get_powerlines_as_data().get_limit_a1_ka(), - _grid_model.get_powerlines_as_data().get_limit_a2_ka(), - no_skip, _violations_n_); - check_current_violations(_grid_model.get_trafos_as_data(), ViolationElementType::TRAFO, - V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), - ac_solver_used, sn_mva, - _grid_model.get_trafos_as_data().get_limit_a1_ka(), - _grid_model.get_trafos_as_data().get_limit_a2_ka(), - no_skip, _violations_n_); - } + _converged_n_ = true; + const CplxVect V_n = _algo.get_V(); + const std::vector no_skip; + check_bus_voltage_violations(V_n, id_me_to_solver_, + _grid_model.get_bus_vmin_kv(), _grid_model.get_bus_vmax_kv(), + _grid_model.get_bus_vn_kv(), nullptr, _violations_n_); + check_current_violations(_grid_model.get_powerlines_as_data(), ViolationElementType::LINE, + V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), + ac_solver_used, sn_mva, + _grid_model.get_powerlines_as_data().get_limit_a1_ka(), + _grid_model.get_powerlines_as_data().get_limit_a2_ka(), + no_skip, _violations_n_); + check_current_violations(_grid_model.get_trafos_as_data(), ViolationElementType::TRAFO, + V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), + ac_solver_used, sn_mva, + _grid_model.get_trafos_as_data().get_limit_a1_ka(), + _grid_model.get_trafos_as_data().get_limit_a2_ka(), + no_skip, _violations_n_); } - if(!n_powerflow_has_conv) return; - auto timer_thread = CustTimer(); // now perform the security analysis, possibly split across several threads const size_t nb_cont = _li_coeffs.size(); @@ -751,6 +760,9 @@ void ContingencyAnalysis::run_contingency_range( auto timer_modif_Ybus = CustTimer(); bool do_store = false; bool conv = false; + // only meaningful when do_store ends up false (see below): why no result is + // available for this contingency. + LimitViolationType div_reason = LimitViolationType::NOT_SIMULATED; if(mask_mode){ // disconnected-grid handling: solve the largest component while masking @@ -783,7 +795,12 @@ void ContingencyAnalysis::run_contingency_range( // masked buses are not simulated: report 0 voltage for them for(int b : masked) if(b >= 0 && b < V.size()) V(b) = cplx_type(0., 0.); do_store = true; + } else { + div_reason = LimitViolationType::DIVERGENCE; } + } else { + // skipped contingency: strands the chosen reference slack, solver never ran + div_reason = LimitViolationType::NOT_SIMULATED; } // skipped contingency: conv stays false, voltages stay 0 } else { @@ -809,6 +826,9 @@ void ContingencyAnalysis::run_contingency_range( control.tell_none_changed(); needs_solver_init = false; } + if(!conv) div_reason = LimitViolationType::DIVERGENCE; + } else { + div_reason = LimitViolationType::NOT_SIMULATED; } timer_modif_Ybus = CustTimer(); @@ -821,6 +841,12 @@ void ContingencyAnalysis::run_contingency_range( if(li_defaults_vect != nullptr){ _converged[cont_id] = do_store ? 1 : 0; + if(!do_store){ + _violations[cont_id].push_back(LimitViolation{ + ViolationElementType::GRID, -1, 0, div_reason, + std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN()}); + } if(do_store){ // split this contingency's disconnected branch ids (grid-wide numbering, // lines first then trafos, see init_li_coeffs) into per-type LOCAL ids so diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 44a6d0f9..b9b92c5e 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -24,7 +24,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch { public: explicit ContingencyAnalysis(const LSGrid & init_grid_model, - bool compute_limit_violations = false) noexcept: + bool compute_limit_violations = false): BaseBatchSolverSynch(init_grid_model), _li_defaults(), _li_coeffs(), @@ -155,7 +155,9 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch int get_nb_thread() const {return _nb_thread;} void set_nb_thread(int n) {_nb_thread = (n < 1 ? 1 : n);} - // make the computation + // make the computation. Throws if the pre-contingency ("n", no disconnection) powerflow + // itself does not converge -- every contingency is solved starting from / relative to + // that base case, so a diverging base case makes the whole analysis meaningless. void compute(const CplxVect & Vinit, int max_iter, real_type tol); IntVect is_grid_connected_after_contingency(); diff --git a/src/core/batch_algorithm/LimitViolation.hpp b/src/core/batch_algorithm/LimitViolation.hpp index 2cd82e4c..6c9844f4 100644 --- a/src/core/batch_algorithm/LimitViolation.hpp +++ b/src/core/batch_algorithm/LimitViolation.hpp @@ -14,27 +14,30 @@ namespace ls2g { // the kind of element on which a limit was violated -enum class ViolationElementType : int { +enum class LS2G_API ViolationElementType : int { BUS = 0, LINE = 1, - TRAFO = 2 + TRAFO = 2, + GRID = 3 // the whole grid / contingency, not a specific element (see LimitViolationType::DIVERGENCE) }; // the kind of limit that was violated -enum class LimitViolationType : int { +enum class LS2G_API LimitViolationType : int { LOW_VOLTAGE = 0, HIGH_VOLTAGE = 1, - CURRENT = 2 + CURRENT = 2, + NOT_SIMULATED = 3, // a pre-check (graph connectivity) skipped the contingency: the solver was never invoked + DIVERGENCE = 4 // the solver was invoked (for the contingency, or the pre-contingency "n" case) but did not converge }; // a single limit violation, as detected by ContingencyAnalysis (see compute_limit_violations) -struct LimitViolation { +struct LS2G_API LimitViolation { ViolationElementType element_type; - int element_id; // grid-model bus id for BUS ; local (0-based, own type) line / trafo id otherwise - int side; // 1 or 2 for LINE / TRAFO ; unused (0) for BUS + int element_id; // grid-model bus id for BUS ; local (0-based, own type) line / trafo id otherwise ; unused (-1) for GRID + int side; // 1 or 2 for LINE / TRAFO ; unused (0) for BUS / GRID LimitViolationType violation_type; - real_type value; // value reached - real_type limit; // limit that was violated + real_type value; // value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE + real_type limit; // limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE }; } // namespace ls2g diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index 33949620..ccbd0b50 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -20,7 +20,7 @@ series of injections (productions and loads) to compute powerflows/ class LS2G_API TimeSeries final: public BaseBatchSolverSynch { public: - explicit TimeSeries(const LSGrid & init_grid_model) noexcept: + explicit TimeSeries(const LSGrid & init_grid_model): BaseBatchSolverSynch(init_grid_model), _Sbuses(), _status(1), // 1: success, 0: failure From 8acf1db97b5c8d667d1dde4319cfa148e1e28286 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:19:05 +0000 Subject: [PATCH 023/166] Add native from_matpower initializer for LSGrid Loads MATPOWER cases (.m via matpowercaseframes, .mat via scipy, or an already-parsed mpc dict/object) directly into LSGrid from the raw bus/gen/branch matrices, without ever constructing a pandapower net. This sidesteps a known bug in pandapower's own matpower converter that mishandles grids with several generators connected to the same bus: lightsim2grid.network.init_from_matpower keeps every mpc.gen row as an independent generator, including at the reference bus (distributed slack across co-located generators). Verified against the real 70,000-bus case_ACTIVSg70k.m (1944 buses with multiple generators, up to 17 on one bus): parses in ~1.8s and converts/powerflows correctly. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- lightsim2grid/network/__init__.py | 9 + .../network/from_matpower/__init__.py | 11 + .../network/from_matpower/_aux_add_branch.py | 76 ++++++ .../network/from_matpower/_aux_add_gen.py | 55 +++++ .../network/from_matpower/_aux_add_load.py | 42 ++++ .../network/from_matpower/_aux_add_shunt.py | 52 ++++ .../network/from_matpower/_aux_add_slack.py | 78 ++++++ .../network/from_matpower/_aux_check_legit.py | 46 ++++ .../from_matpower/_mp_bus_to_ls_bus.py | 47 ++++ .../network/from_matpower/_my_const.py | 30 +++ .../from_matpower/_parse_matpower_source.py | 108 +++++++++ .../network/from_matpower/initLSGrid.py | 139 +++++++++++ .../tests/test_init_from_matpower.py | 224 ++++++++++++++++++ pyproject.toml | 8 +- 14 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 lightsim2grid/network/from_matpower/__init__.py create mode 100644 lightsim2grid/network/from_matpower/_aux_add_branch.py create mode 100644 lightsim2grid/network/from_matpower/_aux_add_gen.py create mode 100644 lightsim2grid/network/from_matpower/_aux_add_load.py create mode 100644 lightsim2grid/network/from_matpower/_aux_add_shunt.py create mode 100644 lightsim2grid/network/from_matpower/_aux_add_slack.py create mode 100644 lightsim2grid/network/from_matpower/_aux_check_legit.py create mode 100644 lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py create mode 100644 lightsim2grid/network/from_matpower/_my_const.py create mode 100644 lightsim2grid/network/from_matpower/_parse_matpower_source.py create mode 100644 lightsim2grid/network/from_matpower/initLSGrid.py create mode 100644 lightsim2grid/tests/test_init_from_matpower.py diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 15f9464f..19b4d6e6 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -34,6 +34,15 @@ # pypowsybl is not installed pass +try: + from lightsim2grid.network.from_matpower import init as init_from_matpower # noqa + __all__.append("init_from_matpower") +except ImportError: + # should not happen: from_matpower has no hard dependency beyond numpy, + # matpowercaseframes/scipy are only imported lazily when actually reading + # a ".m"/".mat" file + pass + try: from lightsim2grid.network.compare_lsgrid import compare_lsgrid # noqa __all__.append("compare_lsgrid") diff --git a/lightsim2grid/network/from_matpower/__init__.py b/lightsim2grid/network/from_matpower/__init__.py new file mode 100644 index 00000000..df92c6dc --- /dev/null +++ b/lightsim2grid/network/from_matpower/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +__all__ = ["init"] + +from .initLSGrid import init diff --git a/lightsim2grid/network/from_matpower/_aux_add_branch.py b/lightsim2grid/network/from_matpower/_aux_add_branch.py new file mode 100644 index 00000000..20bbca73 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_add_branch.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._my_const import F_BUS, T_BUS, BR_R, BR_X, BR_B, TAP, SHIFT, BR_STATUS +from ._mp_bus_to_ls_bus import mp_bus_to_ls + + +def get_branch_split(branch): + """A branch row is a transformer if and only if its `TAP` column is non zero, + this is matpower's own convention (see e.g. `makeYbus.m`). `TAP == 0` means + "plain line, ratio 1.0". + """ + return branch[:, TAP] != 0. + + +def _aux_add_branch(model, branch, is_trafo, mp_to_ls, isolated_ls_bus): + """ + Add the lines and transformers of `mpc.branch` into the lightsim2grid "model". + + Parameters + ---------- + model + branch: numpy array + raw `mpc.branch` matrix + is_trafo: numpy bool array + for each row of `branch`, whether it should be treated as a transformer + (see `get_branch_split`) + mp_to_ls: dict + matpower bus number -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses + + """ + is_line = ~is_trafo + from_bus = mp_bus_to_ls(branch[:, F_BUS], mp_to_ls) + to_bus = mp_bus_to_ls(branch[:, T_BUS], mp_to_ls) + + # powerlines + line_r = branch[is_line, BR_R] + line_x = branch[is_line, BR_X] + line_h = 1j * branch[is_line, BR_B] + model.init_powerlines(line_r, line_x, line_h, from_bus[is_line], to_bus[is_line]) + + line_status = branch[is_line, BR_STATUS] != 0 + if isolated_ls_bus.size: + line_status &= ~np.isin(from_bus[is_line], isolated_ls_bus) + line_status &= ~np.isin(to_bus[is_line], isolated_ls_bus) + for line_id, is_ok in enumerate(line_status): + if not is_ok: + model.deactivate_powerline(line_id) + + # transformers + n_trafo = int(is_trafo.sum()) + trafo_r = branch[is_trafo, BR_R] + trafo_x = branch[is_trafo, BR_X] + trafo_b = 1j * branch[is_trafo, BR_B] + trafo_ratio = branch[is_trafo, TAP] + trafo_shift_degree = branch[is_trafo, SHIFT] + trafo_tap_hv = [True] * n_trafo + model.init_trafo(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, + from_bus[is_trafo], to_bus[is_trafo], True) + + trafo_status = branch[is_trafo, BR_STATUS] != 0 + if isolated_ls_bus.size: + trafo_status &= ~np.isin(from_bus[is_trafo], isolated_ls_bus) + trafo_status &= ~np.isin(to_bus[is_trafo], isolated_ls_bus) + for trafo_id, is_ok in enumerate(trafo_status): + if not is_ok: + model.deactivate_trafo(trafo_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_gen.py b/lightsim2grid/network/from_matpower/_aux_add_gen.py new file mode 100644 index 00000000..20aae605 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_add_gen.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._my_const import GEN_BUS, PG, QG, QMAX, QMIN, VG, GEN_STATUS +from ._mp_bus_to_ls_bus import mp_bus_to_ls + + +def _aux_add_gen(model, gen, mp_to_ls, isolated_ls_bus): + """ + Add the generators of `mpc.gen` into the lightsim2grid "model". + + Note + ---- + Several rows of `mpc.gen` can share the same `GEN_BUS`: they are all kept as + independent generators (no aggregation), which is the entire point of this + native matpower loader -- lightsim2grid's `LSGrid` has no uniqueness constraint + on a generator's bus id (`from_pypowsybl` already relies on this for grids with + several generators on the same bus). + + Matpower has no column equivalent to lightsim2grid's `voltage_regulator_on`: every + generator is assumed to regulate voltage (standard powerflow convention), which is + the modeling assumption made here. + + Parameters + ---------- + model + gen: numpy array + raw `mpc.gen` matrix + mp_to_ls: dict + matpower bus number -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses + + """ + if gen.shape[0] == 0: + return + + gen_bus = mp_bus_to_ls(gen[:, GEN_BUS], mp_to_ls) + voltage_regulator_on = [True] * gen.shape[0] + model.init_generators_full(gen[:, PG], gen[:, VG], gen[:, QG], voltage_regulator_on, + gen[:, QMIN], gen[:, QMAX], gen_bus) + + gen_status = gen[:, GEN_STATUS] > 0 + if isolated_ls_bus.size: + gen_status &= ~np.isin(gen_bus, isolated_ls_bus) + for gen_id, is_ok in enumerate(gen_status): + if not is_ok: + model.deactivate_gen(gen_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_load.py b/lightsim2grid/network/from_matpower/_aux_add_load.py new file mode 100644 index 00000000..ebea5582 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_add_load.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._my_const import BUS_I, PD, QD +from ._mp_bus_to_ls_bus import mp_bus_to_ls + + +def _aux_add_load(model, bus, mp_to_ls, isolated_ls_bus): + """ + Add the loads described by the `PD` / `QD` columns of `mpc.bus` into the + lightsim2grid "model". Matpower has no separate load table: a load is only + created for buses with a non zero `PD` or `QD`. + + Parameters + ---------- + model + bus: numpy array + raw `mpc.bus` matrix + mp_to_ls: dict + matpower bus number -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses + + """ + has_load = (bus[:, PD] != 0.) | (bus[:, QD] != 0.) + if not np.any(has_load): + return + + load_bus = mp_bus_to_ls(bus[has_load, BUS_I], mp_to_ls) + model.init_loads(bus[has_load, PD], bus[has_load, QD], load_bus) + + if isolated_ls_bus.size: + for load_id, is_isolated in enumerate(np.isin(load_bus, isolated_ls_bus)): + if is_isolated: + model.deactivate_load(load_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_shunt.py b/lightsim2grid/network/from_matpower/_aux_add_shunt.py new file mode 100644 index 00000000..2e8fc549 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_add_shunt.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._my_const import BUS_I, GS, BS +from ._mp_bus_to_ls_bus import mp_bus_to_ls + + +def _aux_add_shunt(model, bus, mp_to_ls, isolated_ls_bus): + """ + Add the shunts described by the `GS` / `BS` columns of `mpc.bus` into the + lightsim2grid "model". Matpower has no separate shunt table: a shunt is only + created for buses with a non zero `GS` or `BS`. + + Note + ---- + Matpower's `GS` is "shunt conductance (MW **demanded** at V = 1.0 p.u.)" (load + convention, positive = consumed) while `BS` is "shunt susceptance (MVAr + **injected** at V = 1.0 p.u.)" (generator convention, positive = supplied) -- the + opposite sign convention from lightsim2grid/pandapower's `q_mvar` (load + convention, positive = consumed). `BS` must therefore be negated. This matches + pandapower's own matpower/ppc importer (`pandapower.converter.pypower.from_ppc`), + which does `q_mvar=-ppc["bus"][is_shunt, BS]`. + + Parameters + ---------- + model + bus: numpy array + raw `mpc.bus` matrix + mp_to_ls: dict + matpower bus number -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses + + """ + has_shunt = (bus[:, GS] != 0.) | (bus[:, BS] != 0.) + if not np.any(has_shunt): + return + + shunt_bus = mp_bus_to_ls(bus[has_shunt, BUS_I], mp_to_ls) + model.init_shunt(bus[has_shunt, GS], -bus[has_shunt, BS], shunt_bus) + + if isolated_ls_bus.size: + for sh_id, is_isolated in enumerate(np.isin(shunt_bus, isolated_ls_bus)): + if is_isolated: + model.deactivate_shunt(sh_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_slack.py b/lightsim2grid/network/from_matpower/_aux_add_slack.py new file mode 100644 index 00000000..58929706 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_add_slack.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings +import numpy as np + +from ._my_const import BUS_I, BUS_TYPE, REF, GEN_BUS, GEN_STATUS +from ._mp_bus_to_ls_bus import mp_bus_to_ls + + +def _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus): + """ + Resolve the slack bus(es) from `mpc.bus[:, BUS_TYPE] == 3` (the matpower reference + bus) and assign every in-service generator connected to it as a (possibly + distributed) slack generator, with equal weight. + + Note + ---- + Unlike pandapower (which has a separate `ext_grid` table to fall back on), matpower + has no fallback: if the reference bus has no in-service generator connected to it, + there is no slack source and a `RuntimeError` is raised. + + Several generators can be connected to the reference bus (this is precisely the + multi-generator-per-bus case this loader is designed to support): they are all + assigned as slack, each with weight 1.0 (equal-weight distributed slack), consistent + with the `add_gen_slackbus` weighting convention already used by the pandapower + loader. Matpower has no column to derive a different weighting from. + + Parameters + ---------- + model + bus: numpy array + raw `mpc.bus` matrix + gen: numpy array + raw `mpc.gen` matrix + mp_to_ls: dict + matpower bus number -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses + + """ + ref_mask = bus[:, BUS_TYPE] == REF + n_ref = int(ref_mask.sum()) + if n_ref == 0: + warnings.warn("No bus with `BUS_TYPE == 3` (reference bus) found. lightsim2grid " + "could not assign any slack bus, you will need to do it manually " + "with `model.add_gen_slackbus(...)`.") + return + if n_ref > 1: + warnings.warn(f"{n_ref} buses found with `BUS_TYPE == 3` (reference bus), matpower " + f"normally expects a single one. All of them will be considered.") + + ref_bus_mp = bus[ref_mask, BUS_I] + ref_bus_ls = mp_bus_to_ls(ref_bus_mp, mp_to_ls) + + if gen.shape[0] == 0: + raise RuntimeError("No generator found at all, so no slack bus can be assigned.") + + gen_bus_ls = mp_bus_to_ls(gen[:, GEN_BUS], mp_to_ls) + gen_in_service = gen[:, GEN_STATUS] > 0 + if isolated_ls_bus.size: + gen_in_service = gen_in_service & ~np.isin(gen_bus_ls, isolated_ls_bus) + + is_slack_gen = np.isin(gen_bus_ls, ref_bus_ls) & gen_in_service + slack_gen_ids = np.where(is_slack_gen)[0] + if slack_gen_ids.size == 0: + raise RuntimeError(f"Could not find any in-service generator connected to the reference " + f"bus(es) {ref_bus_mp.tolist()}. Matpower has no separate slack-bus " + f"fallback (unlike pandapower's `ext_grid`): lightsim2grid needs at " + f"least one in-service generator at the reference bus to use as slack.") + + for gen_id in slack_gen_ids: + model.add_gen_slackbus(int(gen_id), 1.0) diff --git a/lightsim2grid/network/from_matpower/_aux_check_legit.py b/lightsim2grid/network/from_matpower/_aux_check_legit.py new file mode 100644 index 00000000..83b9a1ab --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_check_legit.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings +import numpy as np + +from ._my_const import MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, TAP, SHIFT, BUS_TYPE, NONE + + +def _aux_check_legit(bus, gen, branch): + """ + Check that the raw matpower matrices can be handled by lightsim2grid. + + Parameters + ---------- + bus, gen, branch: numpy arrays + The raw matpower matrices, as returned by `_parse_matpower_source.load_matpower_data` + + Returns + ------- + + """ + if bus.shape[1] < MIN_BUS_COLS: + raise RuntimeError(f"`mpc.bus` should have at least {MIN_BUS_COLS} columns, found {bus.shape[1]}.") + if gen.shape[0] and gen.shape[1] < MIN_GEN_COLS: + raise RuntimeError(f"`mpc.gen` should have at least {MIN_GEN_COLS} columns, found {gen.shape[1]}.") + if branch.shape[0] and branch.shape[1] < MIN_BRANCH_COLS: + raise RuntimeError(f"`mpc.branch` should have at least {MIN_BRANCH_COLS} columns, found {branch.shape[1]}.") + + if branch.shape[0]: + is_line = branch[:, TAP] == 0. + weird_shift = is_line & (branch[:, SHIFT] != 0.) + if np.any(weird_shift): + warnings.warn(f"{weird_shift.sum()} branch(es) have `TAP == 0` (so are treated as a plain " + f"powerline by lightsim2grid) but a non-zero `SHIFT`, which is not physically " + f"meaningful for a plain line. The `SHIFT` will be ignored for these branches.") + + if bus.shape[0] and np.any(bus[:, BUS_TYPE] == NONE): + n_isolated = int((bus[:, BUS_TYPE] == NONE).sum()) + warnings.warn(f"{n_isolated} bus(es) have `BUS_TYPE == {NONE}` (isolated). They will be deactivated, " + f"along with any load, shunt, generator or branch side still connected to them.") diff --git a/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py b/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py new file mode 100644 index 00000000..88b172a6 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py @@ -0,0 +1,47 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + + +def mp_bus_to_ls(mp_bus_id, mp_to_ls_converter): + """Map matpower bus numbers (arbitrary, not necessarily contiguous / 0-based) + to lightsim2grid contiguous 0-based bus ids, given the dict built by + `build_bus_remap`. + """ + return np.array([mp_to_ls_converter[mp_id] for mp_id in mp_bus_id], dtype=int) + + +def build_bus_remap(bus_i): + """ + Build the mapping from matpower bus number (`bus[:, BUS_I]`, arbitrary integers, + not necessarily contiguous / 0-based / sorted) to a contiguous 0-based lightsim2grid + bus id. + + Parameters + ---------- + bus_i: numpy array + The `BUS_I` column of `mpc.bus` (one entry per bus, in the original file order) + + Returns + ------- + mp_to_ls: dict + mapping from matpower bus number to lightsim2grid bus id + ls_to_orig: numpy array + for each lightsim2grid bus id, the original matpower bus number (useful to + keep on the model, mirroring `model._ls_to_orig` in the pandapower loader) + """ + bus_i = np.asarray(bus_i).astype(int) + if np.unique(bus_i).shape[0] != bus_i.shape[0]: + raise RuntimeError("Duplicated bus numbers found in `mpc.bus[:, BUS_I]`, " + "lightsim2grid cannot handle this.") + # buses keep the order they appear in mpc.bus (no need to sort: matpower bus + # numbers are opaque identifiers, not meaningful for ordering) + ls_to_orig = bus_i + mp_to_ls = {mp_id: ls_id for ls_id, mp_id in enumerate(bus_i)} + return mp_to_ls, ls_to_orig diff --git a/lightsim2grid/network/from_matpower/_my_const.py b/lightsim2grid/network/from_matpower/_my_const.py new file mode 100644 index 00000000..c37dd8ab --- /dev/null +++ b/lightsim2grid/network/from_matpower/_my_const.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +# column indices, 0-based, matching MATPOWER's lib/idx_bus.m +(BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, + BASE_KV, ZONE, VMAX, VMIN) = range(13) + +# bus type constants, matching MATPOWER's lib/idx_bus.m +PQ, PV, REF, NONE = 1, 2, 3, 4 + +# column indices, 0-based, matching MATPOWER's lib/idx_gen.m +(GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, + PMAX, PMIN, PC1, PC2, QC1MIN, QC1MAX, QC2MIN, QC2MAX, + RAMP_AGC, RAMP_10, RAMP_30, RAMP_Q, APF) = range(21) + +# column indices, 0-based, matching MATPOWER's lib/idx_brch.m +(F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, + TAP, SHIFT, BR_STATUS, ANGMIN, ANGMAX) = range(13) + +# minimum number of columns lightsim2grid needs to find in each matpower +# matrix (matpower case files may have fewer optional trailing columns, +# e.g. no ANGMIN / ANGMAX, or more, e.g. solved-case result columns) +MIN_BUS_COLS = VMIN + 1 +MIN_GEN_COLS = PMIN + 1 +MIN_BRANCH_COLS = BR_STATUS + 1 diff --git a/lightsim2grid/network/from_matpower/_parse_matpower_source.py b/lightsim2grid/network/from_matpower/_parse_matpower_source.py new file mode 100644 index 00000000..5a28f3ff --- /dev/null +++ b/lightsim2grid/network/from_matpower/_parse_matpower_source.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import os +import numpy as np + + +def _as_array(obj): + # accepts a numpy array, a pandas DataFrame (via ".values") or anything array-like + if hasattr(obj, "values"): + arr = np.asarray(obj.values) + else: + arr = np.asarray(obj) + if arr.ndim == 1: + # a single-row matpower matrix (e.g. exactly one branch) can come back as a + # flat 1d array, in particular through `scipy.io.loadmat(..., squeeze_me=True)` + # which squeezes away the (1, ncols) leading dimension. Restore it so that + # column indexing (`arr[:, SOME_COL]`) always works. + arr = arr.reshape(1, -1) + return arr + + +def _load_from_m(path): + """Parse a MATPOWER ``.m`` case file using the optional ``matpowercaseframes`` package.""" + try: + from matpowercaseframes import CaseFrames + except ImportError as exc_: + raise ImportError("Reading a matpower \".m\" file requires the optional " + "\"matpowercaseframes\" package. Install it with " + "`pip install matpowercaseframes` (or `pip install lightsim2grid[matpower]`).") from exc_ + cf = CaseFrames(path) + return _as_array(cf.bus), _as_array(cf.gen), _as_array(cf.branch), float(cf.baseMVA) + + +def _load_from_mat(path): + """Parse a MATPOWER ``.mat`` case file using the optional ``scipy`` package.""" + try: + import scipy.io + except ImportError as exc_: + raise ImportError("Reading a matpower \".mat\" file requires the optional " + "\"scipy\" package. Install it with " + "`pip install scipy` (or `pip install lightsim2grid[matpower]`).") from exc_ + mat = scipy.io.loadmat(path, struct_as_record=False, squeeze_me=True) + if "mpc" in mat: + mpc = mat["mpc"] + else: + candidates = [k for k in mat.keys() if not k.startswith("__")] + if len(candidates) != 1: + raise RuntimeError(f"Could not find a unique matpower case struct in \"{path}\". " + f"Found top level variables: {candidates}. Expected a single " + f"variable named \"mpc\" (or a single top level struct).") + mpc = mat[candidates[0]] + if hasattr(mpc, "bus"): + # scipy loaded it as a matlab struct (mat_struct instance) + bus, gen, branch, baseMVA = mpc.bus, mpc.gen, mpc.branch, mpc.baseMVA + else: + # scipy loaded it as a structured / record array + bus, gen, branch, baseMVA = mpc["bus"], mpc["gen"], mpc["branch"], mpc["baseMVA"] + return _as_array(bus), _as_array(gen), _as_array(branch), float(np.asarray(baseMVA).item()) + + +def _load_from_dict_like(source): + """Accept a plain dict (e.g. as returned by pypower's ``caseN()`` functions) or any + object exposing ``.bus`` / ``.gen`` / ``.branch`` / ``.baseMVA`` (e.g. a + ``matpowercaseframes.CaseFrames`` instance built by the user beforehand). + """ + if isinstance(source, dict): + bus, gen, branch, baseMVA = source["bus"], source["gen"], source["branch"], source["baseMVA"] + else: + bus, gen, branch, baseMVA = source.bus, source.gen, source.branch, source.baseMVA + return _as_array(bus), _as_array(gen), _as_array(branch), float(baseMVA) + + +def load_matpower_data(source): + """ + Normalize any accepted matpower "source" into plain numpy arrays. + + Parameters + ---------- + source: + Either a path (str or os.PathLike) to a ".m" or ".mat" matpower case file, + or an already parsed matpower case (a dict with "bus" / "gen" / "branch" / + "baseMVA" keys, e.g. from `pypower`, or any object exposing these as attributes, + e.g. a `matpowercaseframes.CaseFrames` instance). + + Returns + ------- + bus, gen, branch: numpy arrays + The raw matpower matrices (rows = elements, columns = matpower's + positional column layout, see `lightsim2grid.network.from_matpower._my_const`) + baseMVA: float + The system base MVA + """ + if isinstance(source, (str, os.PathLike)): + path = os.fspath(source) + if path.endswith(".m"): + return _load_from_m(path) + elif path.endswith(".mat"): + return _load_from_mat(path) + else: + raise RuntimeError(f"Unsupported matpower file extension for \"{path}\", " + f"expected a path ending in \".m\" or \".mat\".") + return _load_from_dict_like(source) diff --git a/lightsim2grid/network/from_matpower/initLSGrid.py b/lightsim2grid/network/from_matpower/initLSGrid.py new file mode 100644 index 00000000..e831d6e3 --- /dev/null +++ b/lightsim2grid/network/from_matpower/initLSGrid.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Natively parse a MATPOWER case (".m" file, ".mat" file, or already-parsed mpc dict / +object) and initialize a LSGrid c++ object from it, without ever going through a +pandapower or pypowsybl network. +""" + +from typing import Optional, Union +import os +import numpy as np + +from ...lightsim2grid_cpp import LSGrid +from ._parse_matpower_source import load_matpower_data +from ._mp_bus_to_ls_bus import build_bus_remap, mp_bus_to_ls +from ._aux_check_legit import _aux_check_legit +from ._aux_add_branch import get_branch_split, _aux_add_branch +from ._aux_add_load import _aux_add_load +from ._aux_add_shunt import _aux_add_shunt +from ._aux_add_gen import _aux_add_gen +from ._aux_add_slack import _aux_add_slack +from ._my_const import BUS_I, BUS_TYPE, BASE_KV, NONE + + +def init(source: Union[str, "os.PathLike", dict], + n_sub: Optional[int] = None, # number of voltage levels + n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level + ) -> LSGrid: + """ + Convert a MATPOWER case into a LSGrid. + + Unlike `lightsim2grid.network.init_from_pandapower`, this never constructs a + pandapower network: it reads MATPOWER's raw `bus` / `gen` / `branch` matrices + directly and initializes the `LSGrid` from them. In particular, several + generators connected to the same bus are all kept as independent generators + (no aggregation). + + This can fail to convert the grid and still not throw any error, use with care + (for example, you can run a powerflow after this conversion and compare the + results against another tool, e.g. pandapower or pypowsybl, on the same case). + + Cases for which conversion is not possible include, but are not limited to: + + - `mpc.gencost`, `mpc.areas` and `mpc.dcline` are ignored (not needed for a powerflow) + - matpower's `.m` files require the optional `matpowercaseframes` package, + `.mat` files require the optional `scipy` package. + + Parameters + ---------- + source: + Either a path (str or `os.PathLike`) to a ".m" or ".mat" matpower case file, + or an already parsed matpower case: a dict with "bus" / "gen" / "branch" / + "baseMVA" keys (e.g. as returned by `pypower`'s `caseN()` functions), or any + object exposing these as attributes (e.g. a `matpowercaseframes.CaseFrames` + instance built beforehand). + n_sub: + number of voltage levels / substations. If not provided, defaults to one + substation per matpower bus (`n_busbar_per_sub` is then forced to 1). + n_busbar_per_sub: + max number of buses allowed per substation / voltage level. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + bus, gen, branch, baseMVA = load_matpower_data(source) + _aux_check_legit(bus, gen, branch) + + mp_to_ls, ls_to_orig = build_bus_remap(bus[:, BUS_I]) + + is_trafo = get_branch_split(branch) + nb_line = int((~is_trafo).sum()) + nb_trafo = int(is_trafo.sum()) + + model = LSGrid() + model.set_sn_mva(baseMVA) + + if n_sub is None: + n_sub = bus.shape[0] + if n_busbar_per_sub is not None and n_busbar_per_sub != 1: + raise RuntimeError(f"If n_sub is None, n_busbar_per_sub must be None (or 1), found {n_busbar_per_sub}.") + n_busbar_per_sub = 1 + + try: + tmp = int(n_sub) + except ValueError as exc_: + raise RuntimeError("Impossible to convert n_sub to int") from exc_ + if tmp != n_sub: + raise RuntimeError(f"n_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") + n_sub = tmp + if n_sub <= 0: + raise RuntimeError(f"You need to provide a grid with at least 1 substation / voltage level, provided n_sub={n_sub}") + + try: + tmp = int(n_busbar_per_sub) + except ValueError as exc_: + raise RuntimeError("Impossible to convert n_busbar_per_sub to int") from exc_ + if tmp != n_busbar_per_sub: + raise RuntimeError(f"n_busbar_per_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") + n_busbar_per_sub = tmp + if n_busbar_per_sub <= 0: + raise RuntimeError(f"You need to provide a grid with at least 1 busbar per " + f"substation / voltage level, provided n_busbar_per_sub={n_busbar_per_sub}") + + model.init_bus(n_sub, n_busbar_per_sub, bus[:, BASE_KV], nb_line, nb_trafo) + model._ls_to_orig = ls_to_orig + + isolated_mask = bus[:, BUS_TYPE] == NONE + if np.any(isolated_mask): + isolated_ls_bus = mp_bus_to_ls(bus[isolated_mask, BUS_I], mp_to_ls) + for ls_bus_id in isolated_ls_bus: + model.deactivate_bus(int(ls_bus_id)) + else: + isolated_ls_bus = np.array([], dtype=int) + + # init the powerlines and transformers + _aux_add_branch(model, branch, is_trafo, mp_to_ls, isolated_ls_bus) + + # init the shunts + _aux_add_shunt(model, bus, mp_to_ls, isolated_ls_bus) + + # init the loads + _aux_add_load(model, bus, mp_to_ls, isolated_ls_bus) + + # init the generators + _aux_add_gen(model, gen, mp_to_ls, isolated_ls_bus) + + # deal with the slack bus(es) + _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus) + + return model diff --git a/lightsim2grid/tests/test_init_from_matpower.py b/lightsim2grid/tests/test_init_from_matpower.py new file mode 100644 index 00000000..49d60ff5 --- /dev/null +++ b/lightsim2grid/tests/test_init_from_matpower.py @@ -0,0 +1,224 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import os +import tempfile +import unittest +import warnings + +import numpy as np + +from lightsim2grid.network import init_from_matpower + + +def _toy_mpc(gen_status_row3=1, bus4_type=1): + """ + A small 4-bus toy matpower case, on purpose using non-contiguous / non-0-based + bus numbers (10, 20, 30, 40) to exercise the bus remap, one transformer (branch + 10-40, TAP=1.05), a shunt at bus 40 (GS=5, BS=3) and, most importantly, TWO + generators connected to the same (reference) bus 10 plus a 3rd, deactivated, one + at that same bus -- the multi-generator-per-bus scenario this loader exists for. + """ + bus = np.array([ + [10, 3, 0, 0, 0, 0, 1, 1.0, 0, 138, 1, 1.1, 0.9], + [20, 1, 50, 20, 0, 0, 1, 1.0, 0, 138, 1, 1.1, 0.9], + [30, 2, 30, 10, 0, 0, 1, 1.0, 0, 138, 1, 1.1, 0.9], + [40, bus4_type, 20, 5, 5, 3, 1, 1.0, 0, 138, 1, 1.1, 0.9], + ], dtype=float) + gen = np.array([ + [10, 20, 0, 999, -999, 1.0, 100, 1, 999, -999], + [10, 20, 0, 999, -999, 1.0, 100, 1, 999, -999], + [10, 20, 0, 999, -999, 1.0, 100, gen_status_row3, 999, -999], + [30, 40, 0, 999, -999, 1.0, 100, 1, 999, -999], + ], dtype=float) + branch = np.array([ + [10, 20, 0.01, 0.05, 0.02, 250, 250, 250, 0, 0, 1, -360, 360], + [20, 30, 0.01, 0.05, 0.02, 250, 250, 250, 0, 0, 1, -360, 360], + [30, 40, 0.01, 0.05, 0.02, 250, 250, 250, 0, 0, 1, -360, 360], + [40, 10, 0.01, 0.08, 0.0, 250, 250, 250, 1.05, 0, 1, -360, 360], + ], dtype=float) + return {"baseMVA": 100.0, "bus": bus, "gen": gen, "branch": branch} + + +def _run_pf(model, n_bus=4): + return model.ac_pf(np.full(n_bus, 1.0, dtype=complex), 10, 1e-8) + + +_TOY_M_FILE = """function mpc = case_test +mpc.version = '2'; +mpc.baseMVA = 100; + +%% bus data +mpc.bus = [ +\t10\t3\t0\t0\t0\t0\t1\t1.0\t0\t138\t1\t1.1\t0.9; +\t20\t1\t50\t20\t0\t0\t1\t1.0\t0\t138\t1\t1.1\t0.9; +\t30\t2\t30\t10\t0\t0\t1\t1.0\t0\t138\t1\t1.1\t0.9; +\t40\t1\t20\t5\t5\t3\t1\t1.0\t0\t138\t1\t1.1\t0.9; +]; + +%% generator data +mpc.gen = [ +\t10\t20\t0\t999\t-999\t1.0\t100\t1\t999\t-999; +\t10\t20\t0\t999\t-999\t1.0\t100\t1\t999\t-999; +\t30\t40\t0\t999\t-999\t1.0\t100\t1\t999\t-999; +]; + +%% branch data +mpc.branch = [ +\t10\t20\t0.01\t0.05\t0.02\t250\t250\t250\t0\t0\t1\t-360\t360; +\t20\t30\t0.01\t0.05\t0.02\t250\t250\t250\t0\t0\t1\t-360\t360; +\t30\t40\t0.01\t0.05\t0.02\t250\t250\t250\t0\t0\t1\t-360\t360; +\t40\t10\t0.01\t0.08\t0.0\t250\t250\t250\t1.05\t0\t1\t-360\t360; +]; +""" + + +class TestInitFromMatpowerDict(unittest.TestCase): + """Tests using an already parsed matpower case (a plain dict), which needs no + optional dependency (no matpowercaseframes, no scipy).""" + + def test_basic_conversion_converges(self): + model = init_from_matpower(_toy_mpc()) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0, "powerflow should have converged") + + def test_load_values_no_conversion_needed(self): + # matpower's PD/QD (bus columns) should be passed straight through, unchanged + model = init_from_matpower(_toy_mpc()) + _run_pf(model) + load_p, load_q, _ = model.get_loads_res() + np.testing.assert_allclose(sorted(load_p), sorted([50., 30., 20.])) + np.testing.assert_allclose(sorted(load_q), sorted([20., 10., 5.])) + + def test_shunt_bs_sign_is_flipped(self): + # matpower's BS is "injected" (generator-like sign), lightsim2grid/pandapower's + # q_mvar is "demanded" (load-like sign) -> BS must be negated, so a positive + # BS=3 should be reported back as a (roughly, since V != exactly 1pu) negative q + model = init_from_matpower(_toy_mpc()) + _run_pf(model) + shunt_p, shunt_q, _ = model.get_shunts_res() + self.assertEqual(shunt_p.shape[0], 1) + self.assertGreater(shunt_p[0], 0.) + self.assertLess(shunt_q[0], 0., "BS=3 (positive, injected) should map to a negative q_mvar (demanded)") + + def test_multiple_generators_same_bus_are_not_aggregated(self): + """The whole point of this native loader: several `mpc.gen` rows sharing the + same `GEN_BUS` (here, the reference bus) must survive as independent + generators, distributed-slack-style, instead of being silently merged like + pandapower's matpower converter reportedly does.""" + raw = _toy_mpc() + model = init_from_matpower(raw) + gen_p, gen_q, gen_v = model.get_gen_res() + self.assertEqual(len(gen_p), raw["gen"].shape[0], "no generator should be dropped or merged") + + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + gen_p, gen_q, gen_v = model.get_gen_res() + # the two in-service slack generators (rows 0 and 1) share the slack bus and + # an equal weight -> they should end up with (almost) identical dispatch + self.assertAlmostEqual(gen_p[0], gen_p[1], places=6) + self.assertAlmostEqual(gen_q[0], gen_q[1], places=6) + # the PV generator (row 3) is not slack: its P should stay at its PG setpoint + self.assertAlmostEqual(gen_p[3], 40., places=4) + + def test_deactivated_generator_status(self): + raw = _toy_mpc(gen_status_row3=0) # 3rd row (also at slack bus) is now OFF + model = init_from_matpower(raw) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + gen_p, gen_q, gen_v = model.get_gen_res() + self.assertEqual(len(gen_p), raw["gen"].shape[0]) + self.assertEqual(gen_p[2], 0.) + self.assertEqual(gen_q[2], 0.) + + def test_isolated_bus_is_defensively_deactivated(self): + """A raw matpower file is not guaranteed (unlike a pandapower net) to keep + every load/shunt/branch consistent with `BUS_TYPE == 4` (isolated) buses: + make sure lightsim2grid does not raise and just deactivates what's connected.""" + raw = _toy_mpc(bus4_type=4) # bus 40 becomes isolated, but still has a load/shunt/branch wired to it + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + model = init_from_matpower(raw) + self.assertTrue(any("isolated" in str(x.message) for x in w)) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + load_p, load_q, _ = model.get_loads_res() + # the load at the now-isolated bus 40 must have been deactivated (reported as 0) + self.assertIn(0., load_p) + + def test_no_in_service_generator_at_ref_bus_raises(self): + raw = _toy_mpc() + raw["gen"][:3, 7] = 0 # deactivate all 3 generators connected to the ref bus (bus 10) + with self.assertRaises(RuntimeError): + init_from_matpower(raw) + + +class TestInitFromMatpowerFile(unittest.TestCase): + """Tests for the file-path dispatch (".m" via matpowercaseframes, ".mat" via scipy).""" + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + + def tearDown(self): + self.tmpdir.cleanup() + + def test_m_file(self): + try: + import matpowercaseframes # noqa + except ImportError: + self.skipTest("matpowercaseframes is not installed") + path = os.path.join(self.tmpdir.name, "case_test.m") + with open(path, "w") as f: + f.write(_TOY_M_FILE) + model = init_from_matpower(path) + gen_p, _, _ = model.get_gen_res() + self.assertEqual(len(gen_p), 3) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + + def test_mat_file(self): + try: + import scipy.io + except ImportError: + self.skipTest("scipy is not installed") + raw = _toy_mpc() + path = os.path.join(self.tmpdir.name, "case_test.mat") + scipy.io.savemat(path, {"mpc": raw}) + model = init_from_matpower(path) + gen_p, _, _ = model.get_gen_res() + self.assertEqual(len(gen_p), raw["gen"].shape[0]) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + + def test_mat_file_single_row_tables(self): + # scipy.io.loadmat's squeeze_me=True collapses a (1, ncols) matrix down to a + # flat 1d array: make sure a case with a single branch/generator still works + try: + import scipy.io + except ImportError: + self.skipTest("scipy is not installed") + raw = _toy_mpc() + raw["bus"] = raw["bus"][:2] + raw["gen"] = raw["gen"][:1] + raw["branch"] = raw["branch"][:1] + path = os.path.join(self.tmpdir.name, "case_test_1row.mat") + scipy.io.savemat(path, {"mpc": raw}) + model = init_from_matpower(path) + gen_p, _, _ = model.get_gen_res() + self.assertEqual(len(gen_p), 1) + + def test_unsupported_extension_raises(self): + path = os.path.join(self.tmpdir.name, "case_test.json") + with open(path, "w") as f: + f.write("{}") + with self.assertRaises(RuntimeError): + init_from_matpower(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 59a2feda..c7c175d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ dependencies = [ ] [project.optional-dependencies] +matpower = [ + "scipy", + "matpowercaseframes", +] docs = [ "numpydoc>=0.9.2", "sphinx>=2.4.4", @@ -34,7 +38,9 @@ docs = [ "grid2op>=1.6.4", "recommonmark", "pypowsybl", - "pandapower" + "pandapower", + "scipy", + "matpowercaseframes" ] [project.urls] From a369e311cf9f2c4a66cf60afd694e2b0a4bda803 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:54:49 +0000 Subject: [PATCH 024/166] Address PR review comments on the matpower loader - support mpc.dcline: HVDC lines are now converted via model.init_dclines, using the same loss/sign conventions already validated by the pandapower loader (PF is negated to match lightsim2grid's "received at side 1" convention, LOSS1 * 100 -> loss_percent, LOSS0 -> loss_mw). Optional: falls back to no HVDC line when mpc.dcline is absent. - remove the n_sub parameter: there is always exactly one substation per matpower bus. n_busbar_per_sub now accepts any value >= 1 (previously forced to 1); the extra busbar sections it creates are deactivated since matpower has no data to populate them with. - clarify the "several reference buses" warning mentions equal weighting. - add matpowercaseframes to requirements_compile_ci.txt so CI actually exercises the ".m" file loading path instead of silently skipping it. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- .../network/from_matpower/_aux_add_dc_line.py | 70 ++++++++++++++++ .../network/from_matpower/_aux_add_slack.py | 3 +- .../network/from_matpower/_aux_check_legit.py | 9 ++- .../network/from_matpower/_my_const.py | 6 ++ .../from_matpower/_parse_matpower_source.py | 37 ++++++++- .../network/from_matpower/initLSGrid.py | 56 +++++++------ .../tests/test_init_from_matpower.py | 79 +++++++++++++++++++ requirements_compile_ci.txt | 1 + 8 files changed, 231 insertions(+), 30 deletions(-) create mode 100644 lightsim2grid/network/from_matpower/_aux_add_dc_line.py diff --git a/lightsim2grid/network/from_matpower/_aux_add_dc_line.py b/lightsim2grid/network/from_matpower/_aux_add_dc_line.py new file mode 100644 index 00000000..57f90130 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_aux_add_dc_line.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._my_const import (DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, + DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1) +from ._mp_bus_to_ls_bus import mp_bus_to_ls + + +def _aux_add_dc_line(model, dcline, mp_to_ls, isolated_ls_bus): + """ + Add the HVDC lines described by `mpc.dcline` into the lightsim2grid "model", using + the same simplified (station-loss-free, resistance-free) dc line model already used + by lightsim2grid's pandapower loader (`model.init_dclines`). + + Note + ---- + Matpower's `PF` is the MW flow "from" -> "to" measured at the `F_BUS` end (positive + means the `F_BUS` side sends power into the line), which is the same convention as + pandapower's dcline `p_mw`. `model.init_dclines` instead expects the power *received* + at side 1 (the `F_BUS` side) when positive, so `PF` must be negated, exactly as + lightsim2grid's pandapower loader already does for pandapower's `p_mw`. + + Matpower's loss model is `PT = PF - (LOSS0 + LOSS1 * PF)` with `LOSS1` a fraction of + `PF`, while lightsim2grid/pandapower's `loss_percent` expresses that same coefficient + as a percentage (`LOSS1 * 100`) and `loss_mw` is `LOSS0` directly. + + `PMIN` / `PMAX` (limits on `PF`) have no equivalent in `model.init_dclines` (this + simplified model does not enforce them), consistent with how the pandapower loader + already ignores pandapower's own dcline power limits. + + Parameters + ---------- + model + dcline: numpy array + raw `mpc.dcline` matrix (can have 0 rows: most matpower cases have no HVDC line) + mp_to_ls: dict + matpower bus number -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses + + """ + if dcline.shape[0] == 0: + return + + from_bus = mp_bus_to_ls(dcline[:, DC_F_BUS], mp_to_ls) + to_bus = mp_bus_to_ls(dcline[:, DC_T_BUS], mp_to_ls) + + p_mw = -dcline[:, DC_PF] + loss_percent = 100. * dcline[:, DC_LOSS1] + loss_mw = dcline[:, DC_LOSS0] + + model.init_dclines(from_bus, to_bus, p_mw, loss_percent, loss_mw, + dcline[:, DC_VF], dcline[:, DC_VT], + dcline[:, DC_QMINF], dcline[:, DC_QMAXF], + dcline[:, DC_QMINT], dcline[:, DC_QMAXT]) + + dc_status = dcline[:, DC_BR_STATUS] != 0 + if isolated_ls_bus.size: + dc_status &= ~np.isin(from_bus, isolated_ls_bus) + dc_status &= ~np.isin(to_bus, isolated_ls_bus) + for dc_id, is_ok in enumerate(dc_status): + if not is_ok: + model.deactivate_dcline(dc_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_slack.py b/lightsim2grid/network/from_matpower/_aux_add_slack.py index 58929706..fe08fa7d 100644 --- a/lightsim2grid/network/from_matpower/_aux_add_slack.py +++ b/lightsim2grid/network/from_matpower/_aux_add_slack.py @@ -53,7 +53,8 @@ def _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus): return if n_ref > 1: warnings.warn(f"{n_ref} buses found with `BUS_TYPE == 3` (reference bus), matpower " - f"normally expects a single one. All of them will be considered.") + f"normally expects a single one. All of them will be considered, " + f"with equal weights.") ref_bus_mp = bus[ref_mask, BUS_I] ref_bus_ls = mp_bus_to_ls(ref_bus_mp, mp_to_ls) diff --git a/lightsim2grid/network/from_matpower/_aux_check_legit.py b/lightsim2grid/network/from_matpower/_aux_check_legit.py index 83b9a1ab..f10fafe6 100644 --- a/lightsim2grid/network/from_matpower/_aux_check_legit.py +++ b/lightsim2grid/network/from_matpower/_aux_check_legit.py @@ -9,16 +9,17 @@ import warnings import numpy as np -from ._my_const import MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, TAP, SHIFT, BUS_TYPE, NONE +from ._my_const import (MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, + TAP, SHIFT, BUS_TYPE, NONE) -def _aux_check_legit(bus, gen, branch): +def _aux_check_legit(bus, gen, branch, dcline): """ Check that the raw matpower matrices can be handled by lightsim2grid. Parameters ---------- - bus, gen, branch: numpy arrays + bus, gen, branch, dcline: numpy arrays The raw matpower matrices, as returned by `_parse_matpower_source.load_matpower_data` Returns @@ -31,6 +32,8 @@ def _aux_check_legit(bus, gen, branch): raise RuntimeError(f"`mpc.gen` should have at least {MIN_GEN_COLS} columns, found {gen.shape[1]}.") if branch.shape[0] and branch.shape[1] < MIN_BRANCH_COLS: raise RuntimeError(f"`mpc.branch` should have at least {MIN_BRANCH_COLS} columns, found {branch.shape[1]}.") + if dcline.shape[0] and dcline.shape[1] < MIN_DCLINE_COLS: + raise RuntimeError(f"`mpc.dcline` should have at least {MIN_DCLINE_COLS} columns, found {dcline.shape[1]}.") if branch.shape[0]: is_line = branch[:, TAP] == 0. diff --git a/lightsim2grid/network/from_matpower/_my_const.py b/lightsim2grid/network/from_matpower/_my_const.py index c37dd8ab..61d99208 100644 --- a/lightsim2grid/network/from_matpower/_my_const.py +++ b/lightsim2grid/network/from_matpower/_my_const.py @@ -22,9 +22,15 @@ (F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, ANGMIN, ANGMAX) = range(13) +# column indices, 0-based, matching MATPOWER's lib/idx_dcline.m +(DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_PT, DC_QF, DC_QT, DC_VF, DC_VT, + DC_PMIN, DC_PMAX, DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, + DC_LOSS0, DC_LOSS1) = range(17) + # minimum number of columns lightsim2grid needs to find in each matpower # matrix (matpower case files may have fewer optional trailing columns, # e.g. no ANGMIN / ANGMAX, or more, e.g. solved-case result columns) MIN_BUS_COLS = VMIN + 1 MIN_GEN_COLS = PMIN + 1 MIN_BRANCH_COLS = BR_STATUS + 1 +MIN_DCLINE_COLS = DC_LOSS1 + 1 diff --git a/lightsim2grid/network/from_matpower/_parse_matpower_source.py b/lightsim2grid/network/from_matpower/_parse_matpower_source.py index 5a28f3ff..00935b16 100644 --- a/lightsim2grid/network/from_matpower/_parse_matpower_source.py +++ b/lightsim2grid/network/from_matpower/_parse_matpower_source.py @@ -9,6 +9,8 @@ import os import numpy as np +from ._my_const import MIN_DCLINE_COLS + def _as_array(obj): # accepts a numpy array, a pandas DataFrame (via ".values") or anything array-like @@ -25,6 +27,21 @@ def _as_array(obj): return arr +def _as_dcline_array(obj): + """Like `_as_array`, but `mpc.dcline` is optional (most matpower cases have no + HVDC line at all): missing / empty input becomes a (0, MIN_DCLINE_COLS) array + instead of an error or a malformed (1, 0) reshape. + """ + if obj is None: + return np.zeros((0, MIN_DCLINE_COLS)) + arr = np.asarray(obj.values) if hasattr(obj, "values") else np.asarray(obj) + if arr.size == 0: + return np.zeros((0, MIN_DCLINE_COLS)) + if arr.ndim == 1: + arr = arr.reshape(1, -1) + return arr + + def _load_from_m(path): """Parse a MATPOWER ``.m`` case file using the optional ``matpowercaseframes`` package.""" try: @@ -34,7 +51,9 @@ def _load_from_m(path): "\"matpowercaseframes\" package. Install it with " "`pip install matpowercaseframes` (or `pip install lightsim2grid[matpower]`).") from exc_ cf = CaseFrames(path) - return _as_array(cf.bus), _as_array(cf.gen), _as_array(cf.branch), float(cf.baseMVA) + dcline = getattr(cf, "dcline", None) + return (_as_array(cf.bus), _as_array(cf.gen), _as_array(cf.branch), + _as_dcline_array(dcline), float(cf.baseMVA)) def _load_from_mat(path): @@ -58,10 +77,16 @@ def _load_from_mat(path): if hasattr(mpc, "bus"): # scipy loaded it as a matlab struct (mat_struct instance) bus, gen, branch, baseMVA = mpc.bus, mpc.gen, mpc.branch, mpc.baseMVA + dcline = getattr(mpc, "dcline", None) else: # scipy loaded it as a structured / record array bus, gen, branch, baseMVA = mpc["bus"], mpc["gen"], mpc["branch"], mpc["baseMVA"] - return _as_array(bus), _as_array(gen), _as_array(branch), float(np.asarray(baseMVA).item()) + try: + dcline = mpc["dcline"] + except (KeyError, ValueError): + dcline = None + return (_as_array(bus), _as_array(gen), _as_array(branch), + _as_dcline_array(dcline), float(np.asarray(baseMVA).item())) def _load_from_dict_like(source): @@ -71,9 +96,12 @@ def _load_from_dict_like(source): """ if isinstance(source, dict): bus, gen, branch, baseMVA = source["bus"], source["gen"], source["branch"], source["baseMVA"] + dcline = source.get("dcline") else: bus, gen, branch, baseMVA = source.bus, source.gen, source.branch, source.baseMVA - return _as_array(bus), _as_array(gen), _as_array(branch), float(baseMVA) + dcline = getattr(source, "dcline", None) + return (_as_array(bus), _as_array(gen), _as_array(branch), + _as_dcline_array(dcline), float(baseMVA)) def load_matpower_data(source): @@ -93,6 +121,9 @@ def load_matpower_data(source): bus, gen, branch: numpy arrays The raw matpower matrices (rows = elements, columns = matpower's positional column layout, see `lightsim2grid.network.from_matpower._my_const`) + dcline: numpy array + The raw `mpc.dcline` matrix, or a `(0, MIN_DCLINE_COLS)` array if the source + has no dcline table at all (most matpower cases don't have any HVDC line) baseMVA: float The system base MVA """ diff --git a/lightsim2grid/network/from_matpower/initLSGrid.py b/lightsim2grid/network/from_matpower/initLSGrid.py index e831d6e3..332dfd1c 100644 --- a/lightsim2grid/network/from_matpower/initLSGrid.py +++ b/lightsim2grid/network/from_matpower/initLSGrid.py @@ -25,11 +25,11 @@ from ._aux_add_shunt import _aux_add_shunt from ._aux_add_gen import _aux_add_gen from ._aux_add_slack import _aux_add_slack +from ._aux_add_dc_line import _aux_add_dc_line from ._my_const import BUS_I, BUS_TYPE, BASE_KV, NONE def init(source: Union[str, "os.PathLike", dict], - n_sub: Optional[int] = None, # number of voltage levels n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level ) -> LSGrid: """ @@ -47,7 +47,7 @@ def init(source: Union[str, "os.PathLike", dict], Cases for which conversion is not possible include, but are not limited to: - - `mpc.gencost`, `mpc.areas` and `mpc.dcline` are ignored (not needed for a powerflow) + - `mpc.gencost` and `mpc.areas` are ignored (not needed for a powerflow) - matpower's `.m` files require the optional `matpowercaseframes` package, `.mat` files require the optional `scipy` package. @@ -59,11 +59,14 @@ def init(source: Union[str, "os.PathLike", dict], "baseMVA" keys (e.g. as returned by `pypower`'s `caseN()` functions), or any object exposing these as attributes (e.g. a `matpowercaseframes.CaseFrames` instance built beforehand). - n_sub: - number of voltage levels / substations. If not provided, defaults to one - substation per matpower bus (`n_busbar_per_sub` is then forced to 1). n_busbar_per_sub: - max number of buses allowed per substation / voltage level. + There is always exactly one substation / voltage level per matpower bus (matpower + has no notion of several busbar sections within a bus, so this is not configurable). + This parameter only controls how many buses / busbar sections lightsim2grid + allocates *per substation*, which is useful if you intend to perform grid2op-like + topology actions on the resulting grid afterwards. Defaults to 1 (no extra busbar + section). Any extra busbar section is deactivated, since nothing in the base + matpower case is ever connected to it. Returns ------- @@ -71,8 +74,8 @@ def init(source: Union[str, "os.PathLike", dict], The initialized network """ - bus, gen, branch, baseMVA = load_matpower_data(source) - _aux_check_legit(bus, gen, branch) + bus, gen, branch, dcline, baseMVA = load_matpower_data(source) + _aux_check_legit(bus, gen, branch, dcline) mp_to_ls, ls_to_orig = build_bus_remap(bus[:, BUS_I]) @@ -83,22 +86,10 @@ def init(source: Union[str, "os.PathLike", dict], model = LSGrid() model.set_sn_mva(baseMVA) - if n_sub is None: - n_sub = bus.shape[0] - if n_busbar_per_sub is not None and n_busbar_per_sub != 1: - raise RuntimeError(f"If n_sub is None, n_busbar_per_sub must be None (or 1), found {n_busbar_per_sub}.") + n_sub = bus.shape[0] + if n_busbar_per_sub is None: n_busbar_per_sub = 1 - try: - tmp = int(n_sub) - except ValueError as exc_: - raise RuntimeError("Impossible to convert n_sub to int") from exc_ - if tmp != n_sub: - raise RuntimeError(f"n_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") - n_sub = tmp - if n_sub <= 0: - raise RuntimeError(f"You need to provide a grid with at least 1 substation / voltage level, provided n_sub={n_sub}") - try: tmp = int(n_busbar_per_sub) except ValueError as exc_: @@ -110,9 +101,25 @@ def init(source: Union[str, "os.PathLike", dict], raise RuntimeError(f"You need to provide a grid with at least 1 busbar per " f"substation / voltage level, provided n_busbar_per_sub={n_busbar_per_sub}") - model.init_bus(n_sub, n_busbar_per_sub, bus[:, BASE_KV], nb_line, nb_trafo) + # lightsim2grid lays out global bus ids busbar-section-major: ids [0, n_sub) are + # busbar section 1 of every substation (one per matpower bus, in matpower order), + # ids [n_sub, 2*n_sub) are section 2, etc. `np.tile` replicates `bus[:, BASE_KV]` + # to match that layout. + vn_kv = np.tile(bus[:, BASE_KV], n_busbar_per_sub) + model.init_bus(n_sub, n_busbar_per_sub, vn_kv, nb_line, nb_trafo) + if n_busbar_per_sub > 1: + # extra busbar sections have no corresponding matpower bus at all + ls_to_orig = np.concatenate((ls_to_orig, + np.full(n_sub * (n_busbar_per_sub - 1), -1, dtype=int))) model._ls_to_orig = ls_to_orig + if n_busbar_per_sub > 1: + # matpower has no data for these extra busbar sections: nothing is connected + # to them in the base case, so deactivate them until a topology action moves + # something there + for ls_bus_id in range(n_sub, n_sub * n_busbar_per_sub): + model.deactivate_bus(ls_bus_id) + isolated_mask = bus[:, BUS_TYPE] == NONE if np.any(isolated_mask): isolated_ls_bus = mp_bus_to_ls(bus[isolated_mask, BUS_I], mp_to_ls) @@ -136,4 +143,7 @@ def init(source: Union[str, "os.PathLike", dict], # deal with the slack bus(es) _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus) + # init the HVDC lines (mpc.dcline), if any + _aux_add_dc_line(model, dcline, mp_to_ls, isolated_ls_bus) + return model diff --git a/lightsim2grid/tests/test_init_from_matpower.py b/lightsim2grid/tests/test_init_from_matpower.py index 49d60ff5..0e592414 100644 --- a/lightsim2grid/tests/test_init_from_matpower.py +++ b/lightsim2grid/tests/test_init_from_matpower.py @@ -158,6 +158,85 @@ def test_no_in_service_generator_at_ref_bus_raises(self): init_from_matpower(raw) +class TestInitFromMatpowerNBusbarPerSub(unittest.TestCase): + """There is always exactly one substation per matpower bus (not configurable), but + `n_busbar_per_sub` (number of busbar sections lightsim2grid allocates per substation, + for later grid2op-like topology actions) should accept any value >= 1.""" + + def test_default_is_one_busbar_per_sub(self): + model = init_from_matpower(_toy_mpc()) + np.testing.assert_array_equal(model.get_bus_status(), [True, True, True, True]) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + + def test_explicit_one_busbar_per_sub_same_as_default(self): + model = init_from_matpower(_toy_mpc(), n_busbar_per_sub=1) + np.testing.assert_array_equal(model.get_bus_status(), [True, True, True, True]) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + + def test_several_busbar_per_sub(self): + model = init_from_matpower(_toy_mpc(), n_busbar_per_sub=3) + status = model.get_bus_status() + self.assertEqual(len(status), 4 * 3) + # only the first busbar section of each substation (the one matpower actually + # describes) should be active; the 2 extra ones per substation are empty + np.testing.assert_array_equal(status[:4], [True, True, True, True]) + np.testing.assert_array_equal(status[4:], [False] * 8) + Vfinal = _run_pf(model, n_bus=12) + self.assertGreater(Vfinal.shape[0], 0) + + def test_n_busbar_per_sub_must_be_positive(self): + with self.assertRaises(RuntimeError): + init_from_matpower(_toy_mpc(), n_busbar_per_sub=0) + + +class TestInitFromMatpowerDCLine(unittest.TestCase): + """`mpc.dcline` is optional (most matpower cases have no HVDC line at all) and, + when present, should be converted with the same loss / sign conventions as + lightsim2grid's pandapower loader (`model.init_dclines`).""" + + @staticmethod + def _dcline_row(status=1): + # F_BUS, T_BUS, BR_STATUS, PF, PT, QF, QT, VF, VT, PMIN, PMAX, + # QMINF, QMAXF, QMINT, QMAXT, LOSS0, LOSS1 + return [20, 40, status, 10., 9.5, 0., 0., 1.0, 1.0, -100, 100, + -10, 10, -10, 10, 0.5, 0.05] + + def test_no_dcline_key_at_all(self): + raw = _toy_mpc() + self.assertNotIn("dcline", raw) + model = init_from_matpower(raw) + self.assertEqual(len(model.get_dclines()), 0) + + def test_dcline_target_p1_is_negated_pf(self): + raw = _toy_mpc() + raw["dcline"] = np.array([self._dcline_row()]) + model = init_from_matpower(raw) + dclines = model.get_dclines() + self.assertEqual(len(dclines), 1) + # matpower's PF (10 MW, "from" -> "to") maps to lightsim2grid's "power received + # at side 1" convention, so it must be negated (same as the pandapower loader) + self.assertAlmostEqual(dclines[0].target_p1_mw, -10.) + + def test_dcline_losses_match_matpower_formula(self): + raw = _toy_mpc() + raw["dcline"] = np.array([self._dcline_row()]) + model = init_from_matpower(raw) + _run_pf(model) + dclines = model.get_dclines() + # PT = PF - (LOSS0 + LOSS1 * PF) = 10 - (0.5 + 0.05 * 10) = 9.0 + self.assertAlmostEqual(dclines[0].res_p1_mw, -10., places=4) + self.assertAlmostEqual(dclines[0].res_p2_mw, 9.0, places=4) + + def test_deactivated_dcline(self): + raw = _toy_mpc() + raw["dcline"] = np.array([self._dcline_row(status=0)]) + model = init_from_matpower(raw) + Vfinal = _run_pf(model) + self.assertGreater(Vfinal.shape[0], 0) + + class TestInitFromMatpowerFile(unittest.TestCase): """Tests for the file-path dispatch (".m" via matpowercaseframes, ".mat" via scipy).""" diff --git a/requirements_compile_ci.txt b/requirements_compile_ci.txt index e43f5d15..572573c9 100644 --- a/requirements_compile_ci.txt +++ b/requirements_compile_ci.txt @@ -2,6 +2,7 @@ wheel setuptools pip scipy +matpowercaseframes pybind11>=2.4; python_version < '3.9' numpy>=1.20; python_version < '3.9' numpy>=2; python_version >= '3.9' From 2cc88c7bada0eea2315d6dc262c391030147a004 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:31:56 +0000 Subject: [PATCH 025/166] =?UTF-8?q?Add=20init=5Ffrom=5Fpf=5Fdelta=20loader?= =?UTF-8?q?=20for=20the=20PF=CE=94=20benchmark=20dataset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds an LSGrid from a single PFΔ (arXiv:2510.22048) dataset row, a PowerModels.jl/MATPOWER-format dict with a solved AC power-flow state attached. Implemented as a thin translation layer on top of the new init_from_matpower loader: the row's PowerModels dict is converted into a raw MATPOWER-shaped bus/gen/branch array triple and delegated to it, reusing its bus-id remapping, line/transformer splitting, generator/load/shunt conversion and slack-bus handling rather than duplicating that logic. Since huggingface.co was unreachable to fetch a real sample row, the PFΔ/PowerModels schema and unit conventions were instead verified directly against the pfdelta and PowerModels.jl source repositories, and the test fixture is an independent pandapower solve of case14 translated into the verified PFΔ schema, used to check that lightsim2grid's own AC powerflow reproduces the row's solved vm/va/pf/qf/pt/qt to solver tolerance. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 10 + lightsim2grid/network/__init__.py | 3 + .../network/from_pf_delta/__init__.py | 11 + .../network/from_pf_delta/_pf_delta_to_mpc.py | 129 +++ .../network/from_pf_delta/initLSGrid.py | 63 ++ .../tests/debug_gen_pf_delta_fixture.py | 139 ++++ lightsim2grid/tests/pf_delta_case14.json | 754 ++++++++++++++++++ lightsim2grid/tests/test_LSGrid_pf_delta.py | 156 ++++ 8 files changed, 1265 insertions(+) create mode 100644 lightsim2grid/network/from_pf_delta/__init__.py create mode 100644 lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py create mode 100644 lightsim2grid/network/from_pf_delta/initLSGrid.py create mode 100644 lightsim2grid/tests/debug_gen_pf_delta_fixture.py create mode 100644 lightsim2grid/tests/pf_delta_case14.json create mode 100644 lightsim2grid/tests/test_LSGrid_pf_delta.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 13ad14c3..c985b0e4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -319,6 +319,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. Grids built from pandapower never populate these (getters return an empty array / NaN per element). Not wired into `LightSimBackend.thermal_limit_a`, which keeps its existing behaviour. +- [ADDED] `lightsim2grid.network.init_from_pf_delta`: builds an `LSGrid` from one row of + the PFΔ benchmark dataset (arXiv:2510.22048, a PowerModels.jl/MATPOWER-format dict + with a solved power-flow state attached). Accepts either a parsed row (dict) or a + path to its `.json` file. Implemented as a thin translation layer on top of + `init_from_matpower`: the PFΔ row's PowerModels dict is converted into a raw + MATPOWER-shaped `bus`/`gen`/`branch` array triple and delegated to it, reusing its + bus-id remapping, line/transformer splitting, generator/load/shunt conversion and + slack-bus handling. Tested in `test_LSGrid_pf_delta.py`, including an end-to-end + check that lightsim2grid's own AC powerflow reproduces a row's solved `vm`/`va`/ + `pf`/`qf`/`pt`/`qt` to solver tolerance. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 19b4d6e6..da006533 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -43,6 +43,9 @@ # a ".m"/".mat" file pass +from lightsim2grid.network.from_pf_delta import init as init_from_pf_delta # noqa +__all__.append("init_from_pf_delta") + try: from lightsim2grid.network.compare_lsgrid import compare_lsgrid # noqa __all__.append("compare_lsgrid") diff --git a/lightsim2grid/network/from_pf_delta/__init__.py b/lightsim2grid/network/from_pf_delta/__init__.py new file mode 100644 index 00000000..df92c6dc --- /dev/null +++ b/lightsim2grid/network/from_pf_delta/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +__all__ = ["init"] + +from .initLSGrid import init diff --git a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py new file mode 100644 index 00000000..09a36994 --- /dev/null +++ b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py @@ -0,0 +1,129 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ..from_matpower._my_const import ( + BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, BASE_KV, ZONE, VMAX, VMIN, + GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, + F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, + MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, +) + + +def _is_transformer(branch): + """A PowerModels branch is a transformer if either its own `transformer` flag says + so, or its `tap`/`shift` are away from the "plain line" neutral (`tap == 1.0`, + `shift == 0.0`) -- PowerModels normalizes a matpower `TAP == 0` (its own "this is a + plain line" sentinel) to `tap = 1.0`, so this loader cannot just test `tap != 0` + like `from_matpower.get_branch_split` does on a raw mpc branch matrix. + """ + return bool(branch.get("transformer", False)) or branch.get("tap", 1.0) != 1.0 or branch.get("shift", 0.0) != 0.0 + + +def network_to_mpc(network: dict) -> dict: + """ + Convert a PFΔ / PowerModels.jl ``network`` dict into a raw-MATPOWER-shaped dict + (``{"bus", "gen", "branch"}`` numpy arrays in matpower's own column layout, plus + ``"baseMVA"``) consumable by :func:`lightsim2grid.network.from_matpower.init`. + + Row order is ``sorted(network[], key=int)`` for bus / gen / branch: this is a + deterministic, documented contract (not just an implementation detail), relied upon + by tests that need to map a built `LSGrid`'s element positions back to the original + PFΔ row's bus / gen / branch keys. + """ + for unsupported in ("dcline", "storage"): + if network.get(unsupported): + raise RuntimeError(f'"{unsupported}" is present in this PFΔ row but is not ' + f"supported by this loader.") + if network.get("multinetwork"): + raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") + + base_mva = float(network["baseMVA"]) + + bus_keys = sorted(network["bus"], key=int) + gen_keys = sorted(network["gen"], key=int) + branch_keys = sorted(network["branch"], key=int) + + # raw matpower embeds PD / QD / GS / BS directly on the bus row; PowerModels splits + # them into separate "load" / "shunt" dicts, so they need to be aggregated back + # (only active elements contribute -- matpower has no separate "load/shunt present + # but off" column, so an inactive one is equivalent to 0 demand/admittance here) + pd = {int(bus["bus_i"]): 0.0 for bus in network["bus"].values()} + qd, gs, bs = dict(pd), dict(pd), dict(pd) + for load in network.get("load", {}).values(): + if load.get("status", 1) == 0: + continue + b = int(load["load_bus"]) + pd[b] += load["pd"] + qd[b] += load["qd"] + for shunt in network.get("shunt", {}).values(): + if shunt.get("status", 1) == 0: + continue + b = int(shunt["shunt_bus"]) + gs[b] += shunt["gs"] * base_mva + bs[b] += shunt["bs"] * base_mva + + bus = np.zeros((len(bus_keys), MIN_BUS_COLS)) + for i, k in enumerate(bus_keys): + b = network["bus"][k] + bus_i = int(b["bus_i"]) + bus[i, BUS_I] = bus_i + bus[i, BUS_TYPE] = b["bus_type"] + bus[i, PD] = pd[bus_i] + bus[i, QD] = qd[bus_i] + bus[i, GS] = gs[bus_i] + bus[i, BS] = bs[bus_i] + bus[i, BUS_AREA] = 1.0 + bus[i, VM] = 1.0 + bus[i, VA] = 0.0 + bus[i, BASE_KV] = b.get("base_kv", 1.0) + bus[i, ZONE] = 1.0 + bus[i, VMAX] = b["vmax"] + bus[i, VMIN] = b["vmin"] + + gen = np.zeros((len(gen_keys), MIN_GEN_COLS)) + for i, k in enumerate(gen_keys): + g = network["gen"][k] + gen[i, GEN_BUS] = int(g["gen_bus"]) + gen[i, PG] = g["pg"] + gen[i, QG] = g["qg"] + gen[i, QMAX] = g["qmax"] + gen[i, QMIN] = g["qmin"] + gen[i, VG] = g["vg"] + gen[i, MBASE] = g.get("mbase", base_mva) + gen[i, GEN_STATUS] = g.get("gen_status", 1) + gen[i, PMAX] = g["pmax"] + gen[i, PMIN] = g["pmin"] + + branch = np.zeros((len(branch_keys), MIN_BRANCH_COLS)) + for i, k in enumerate(branch_keys): + br = network["branch"][k] + if br.get("g_fr", 0.0) != 0.0 or br.get("g_to", 0.0) != 0.0: + raise RuntimeError(f'branch "{k}" has a non-zero "g_fr"/"g_to" (line ' + f"conductance); the raw MATPOWER format this loader " + f"delegates to cannot represent that (only susceptance).") + is_trafo = _is_transformer(br) + branch[i, F_BUS] = int(br["f_bus"]) + branch[i, T_BUS] = int(br["t_bus"]) + branch[i, BR_R] = br["br_r"] + branch[i, BR_X] = br["br_x"] + branch[i, BR_B] = br.get("b_fr", 0.0) + br.get("b_to", 0.0) + branch[i, RATE_A] = br.get("rate_a", 0.0) + branch[i, RATE_B] = br.get("rate_b", 0.0) + branch[i, RATE_C] = br.get("rate_c", 0.0) + # matpower's own "this is a plain line" sentinel is TAP == 0, which is what + # `from_matpower.get_branch_split` tests for; PowerModels never uses 0 (it + # normalizes a matpower TAP==0 to tap=1.0), so it has to be re-encoded here + branch[i, TAP] = br.get("tap", 1.0) if is_trafo else 0.0 + # PowerModels stores shift/angles in radians (converted from matpower's + # degrees at parse time); matpower's raw SHIFT column is in degrees + branch[i, SHIFT] = np.rad2deg(br.get("shift", 0.0)) + branch[i, BR_STATUS] = br.get("br_status", 1) + + return {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} diff --git a/lightsim2grid/network/from_pf_delta/initLSGrid.py b/lightsim2grid/network/from_pf_delta/initLSGrid.py new file mode 100644 index 00000000..4127d896 --- /dev/null +++ b/lightsim2grid/network/from_pf_delta/initLSGrid.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Build a LSGrid from a single PFΔ (arXiv:2510.22048) dataset row: a PowerModels.jl-format +dict (MATPOWER-derived, per-unit) with a solved AC power-flow state attached. + +This loader translates the row's PowerModels ``network`` dict into a raw-MATPOWER-shaped +dict and delegates the actual `LSGrid` construction to +`lightsim2grid.network.from_matpower`, which already implements bus-id remapping, +line/transformer splitting, generator/load/shunt conversion and (possibly distributed) +slack-bus handling. +""" + +from typing import Optional, Union +import os +import json + +from ...lightsim2grid_cpp import LSGrid +from ..from_matpower import init as _init_from_matpower +from ._pf_delta_to_mpc import network_to_mpc + + +def init(row: Union[dict, str, "os.PathLike"], + n_sub: Optional[int] = None, # number of voltage levels + n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level + ) -> LSGrid: + """ + Convert a PFΔ dataset row into a LSGrid. + + Parameters + ---------- + row: + Either a PFΔ row already parsed into a dict (with a top-level ``"network"`` + key), or a path (str or `os.PathLike`) to a ``.json`` file containing that same + structure. + n_sub: + number of voltage levels / substations. If not provided, defaults to one + substation per PFΔ bus (`n_busbar_per_sub` is then forced to 1). + n_busbar_per_sub: + max number of buses allowed per substation / voltage level. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + if isinstance(row, (str, os.PathLike)): + with open(row, "r") as f: + row = json.load(f) + + if "network" not in row: + raise RuntimeError('Expected a PFΔ row (a dict with a top-level "network" key), ' + f"got a dict with keys {sorted(row.keys())}.") + + mpc = network_to_mpc(row["network"]) + return _init_from_matpower(mpc, n_sub=n_sub, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/tests/debug_gen_pf_delta_fixture.py b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py new file mode 100644 index 00000000..c5c3cfe4 --- /dev/null +++ b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +One-off (not collected by unittest / pytest) script used to (re)generate the committed +`pf_delta_case14.json` fixture used by `test_LSGrid_pf_delta.py`. + +Real PFΔ data could not be downloaded for this repo (huggingface.co is blocked in the dev +sandbox this was written in), so this script builds a PFΔ-schema-shaped row from an +independent, non-lightsim2grid source of truth: pandapower's own internal per-unit "ppc" +representation (`net["_ppc"]`, which uses the exact same MATPOWER/PyPower column layout +`lightsim2grid.network.from_matpower` and PowerModels.jl both use) of its bundled IEEE +case14 network, solved with pandapower's own AC power flow. This sidesteps having to +hand-derive pandapower's own trafo T-model -> per-unit conversion. + +Run manually (requires `pip install pandapower`): + python lightsim2grid/tests/debug_gen_pf_delta_fixture.py +""" + +import json +import os + +import numpy as np +import pandapower as pp +import pandapower.networks as pn +from pandapower.pypower.idx_bus import BUS_I, BUS_TYPE, PD, QD, GS, BS, BASE_KV, VMAX, VMIN, VM, VA +from pandapower.pypower.idx_gen import GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN +from pandapower.pypower.idx_brch import (F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, TAP, SHIFT, + BR_STATUS, PF, QF, PT, QT) + + +def build_pf_delta_row(net) -> dict: + ppc = net["_ppc"] + bus, gen, branch = ppc["bus"].real, ppc["gen"].real, ppc["branch"].real + base_mva = float(ppc["baseMVA"]) + + network = {"baseMVA": base_mva, "bus": {}, "gen": {}, "branch": {}, "load": {}, "shunt": {}} + solution = {"bus": {}, "gen": {}, "branch": {}} + + load_idx, shunt_idx = 1, 1 + for i in range(bus.shape[0]): + bus_i = int(bus[i, BUS_I]) + 1 # 1-based, mirrors PowerModels' own convention + key = str(i + 1) + network["bus"][key] = { + "bus_i": bus_i, + "bus_type": int(bus[i, BUS_TYPE]), + "base_kv": float(bus[i, BASE_KV]), + "vmax": float(bus[i, VMAX]), + "vmin": float(bus[i, VMIN]), + } + solution["bus"][key] = {"vm": float(bus[i, VM]), "va": float(np.deg2rad(bus[i, VA]))} + + pd_, qd_ = float(bus[i, PD]), float(bus[i, QD]) + if pd_ != 0.0 or qd_ != 0.0: + network["load"][str(load_idx)] = {"load_bus": bus_i, "pd": pd_, "qd": qd_, "status": 1} + load_idx += 1 + + gs_, bs_ = float(bus[i, GS]), float(bus[i, BS]) + if gs_ != 0.0 or bs_ != 0.0: + # matpower's GS/BS are MW/MVAr "at V=1pu"; PowerModels' gs/bs are per-unit + network["shunt"][str(shunt_idx)] = {"shunt_bus": bus_i, "gs": gs_ / base_mva, + "bs": bs_ / base_mva, "status": 1} + shunt_idx += 1 + + for i in range(gen.shape[0]): + key = str(i + 1) + network["gen"][key] = { + "gen_bus": int(gen[i, GEN_BUS]) + 1, + "pg": float(gen[i, PG]), + "qg": float(gen[i, QG]), + "qmax": float(gen[i, QMAX]), + "qmin": float(gen[i, QMIN]), + "vg": float(gen[i, VG]), + "mbase": float(gen[i, MBASE]), + "gen_status": int(gen[i, GEN_STATUS]), + "pmax": float(gen[i, PMAX]), + "pmin": float(gen[i, PMIN]), + } + solution["gen"][key] = {"pg": float(gen[i, PG]), "qg": float(gen[i, QG])} + + for i in range(branch.shape[0]): + key = str(i + 1) + tap = float(branch[i, TAP]) + shift_deg = float(branch[i, SHIFT]) + is_transformer = (abs(tap - 1.0) > 1e-9) or (shift_deg != 0.0) + b_total = float(branch[i, BR_B]) + network["branch"][key] = { + "f_bus": int(branch[i, F_BUS]) + 1, + "t_bus": int(branch[i, T_BUS]) + 1, + "br_r": float(branch[i, BR_R]), + "br_x": float(branch[i, BR_X]), + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": b_total / 2.0, + "b_to": b_total / 2.0, + "tap": tap, + "shift": np.deg2rad(shift_deg), # PowerModels stores angles in radians + "transformer": is_transformer, + "rate_a": float(branch[i, RATE_A]), + "br_status": int(branch[i, BR_STATUS]), + } + if int(branch[i, BR_STATUS]) != 0: + solution["branch"][key] = { + "pf": float(branch[i, PF]), "qf": float(branch[i, QF]), + "pt": float(branch[i, PT]), "qt": float(branch[i, QT]), + } + + return { + "network": network, + "solution": {"solution": solution, "termination_status": "SOLVED_PF", "objective": 0.0}, + "parent_seed": -1, + } + + +if __name__ == "__main__": + net = pn.case14() + pp.runpp(net, numba=False) + row = build_pf_delta_row(net) + + out_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") + with open(out_path, "w") as f: + json.dump(row, f, indent=2) + + n_trafo = sum(1 for br in row["network"]["branch"].values() if br["transformer"]) + n_shunt = len(row["network"]["shunt"]) + print(f"wrote {out_path}") + print(f" {len(row['network']['bus'])} buses, {len(row['network']['branch'])} branches " + f"({n_trafo} transformers), {len(row['network']['gen'])} gens, " + f"{len(row['network']['load'])} loads, {n_shunt} shunts") + for br in row["network"]["branch"].values(): + if br["transformer"]: + print(f" sample transformer: tap={br['tap']}, shift={br['shift']} rad") + for sh in row["network"]["shunt"].values(): + print(f" sample shunt: gs={sh['gs']} pu, bs={sh['bs']} pu") diff --git a/lightsim2grid/tests/pf_delta_case14.json b/lightsim2grid/tests/pf_delta_case14.json new file mode 100644 index 00000000..0fbf5e40 --- /dev/null +++ b/lightsim2grid/tests/pf_delta_case14.json @@ -0,0 +1,754 @@ +{ + "network": { + "baseMVA": 100.0, + "bus": { + "1": { + "bus_i": 1, + "bus_type": 3, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "2": { + "bus_i": 2, + "bus_type": 2, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "3": { + "bus_i": 3, + "bus_type": 2, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "4": { + "bus_i": 4, + "bus_type": 1, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "5": { + "bus_i": 5, + "bus_type": 1, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "6": { + "bus_i": 6, + "bus_type": 2, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "7": { + "bus_i": 7, + "bus_type": 1, + "base_kv": 14.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "8": { + "bus_i": 8, + "bus_type": 2, + "base_kv": 12.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "9": { + "bus_i": 9, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "10": { + "bus_i": 10, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "11": { + "bus_i": 11, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "12": { + "bus_i": 12, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "13": { + "bus_i": 13, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "14": { + "bus_i": 14, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + } + }, + "gen": { + "1": { + "gen_bus": 1, + "pg": 232.3932723516125, + "qg": -16.54930054271304, + "qmax": 0.0, + "qmin": 0.0, + "vg": 1.06, + "mbase": 1.0, + "gen_status": 1, + "pmax": 1000000000.0, + "pmin": -1000000000.0 + }, + "2": { + "gen_bus": 2, + "pg": 40.0, + "qg": 43.557100131702356, + "qmax": 50.0, + "qmin": -40.0, + "vg": 1.045, + "mbase": NaN, + "gen_status": 1, + "pmax": 140.0, + "pmin": 0.0 + }, + "3": { + "gen_bus": 3, + "pg": 0.0, + "qg": 25.07534849503837, + "qmax": 40.0, + "qmin": 0.0, + "vg": 1.01, + "mbase": NaN, + "gen_status": 1, + "pmax": 100.0, + "pmin": 0.0 + }, + "4": { + "gen_bus": 6, + "pg": 0.0, + "qg": 12.730944403419336, + "qmax": 24.0, + "qmin": -6.0, + "vg": 1.07, + "mbase": NaN, + "gen_status": 1, + "pmax": 100.0, + "pmin": 0.0 + }, + "5": { + "gen_bus": 8, + "pg": 0.0, + "qg": 17.623451366097335, + "qmax": 24.0, + "qmin": -6.0, + "vg": 1.09, + "mbase": NaN, + "gen_status": 1, + "pmax": 100.0, + "pmin": 0.0 + } + }, + "branch": { + "1": { + "f_bus": 1, + "t_bus": 2, + "br_r": 0.019379999999999998, + "br_x": 0.05916999999999999, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0264, + "b_to": 0.0264, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "2": { + "f_bus": 1, + "t_bus": 5, + "br_r": 0.05403, + "br_x": 0.22304, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0246, + "b_to": 0.0246, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "3": { + "f_bus": 2, + "t_bus": 3, + "br_r": 0.046990000000000004, + "br_x": 0.19797, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.021900000000000003, + "b_to": 0.021900000000000003, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "4": { + "f_bus": 2, + "t_bus": 4, + "br_r": 0.058109999999999995, + "br_x": 0.17632, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.017000000000000005, + "b_to": 0.017000000000000005, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "5": { + "f_bus": 2, + "t_bus": 5, + "br_r": 0.05695, + "br_x": 0.17388, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.017300000000000003, + "b_to": 0.017300000000000003, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "6": { + "f_bus": 3, + "t_bus": 4, + "br_r": 0.06701, + "br_x": 0.17103, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.006400000000000001, + "b_to": 0.006400000000000001, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "7": { + "f_bus": 4, + "t_bus": 5, + "br_r": 0.01335, + "br_x": 0.04211, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "8": { + "f_bus": 6, + "t_bus": 11, + "br_r": 0.09498, + "br_x": 0.19890000000000002, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "9": { + "f_bus": 6, + "t_bus": 12, + "br_r": 0.12291, + "br_x": 0.25581, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "10": { + "f_bus": 6, + "t_bus": 13, + "br_r": 0.06615, + "br_x": 0.13027, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "11": { + "f_bus": 9, + "t_bus": 10, + "br_r": 0.031810000000000005, + "br_x": 0.08449999999999999, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "12": { + "f_bus": 9, + "t_bus": 14, + "br_r": 0.12711, + "br_x": 0.27038, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "13": { + "f_bus": 10, + "t_bus": 11, + "br_r": 0.08205, + "br_x": 0.19207000000000002, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "14": { + "f_bus": 12, + "t_bus": 13, + "br_r": 0.22092, + "br_x": 0.19988, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "15": { + "f_bus": 13, + "t_bus": 14, + "br_r": 0.17093, + "br_x": 0.34802, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "16": { + "f_bus": 4, + "t_bus": 7, + "br_r": 0.0, + "br_x": 0.20912, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 0.9780000000000001, + "shift": 0.0, + "transformer": true, + "rate_a": 9900.0, + "br_status": 1 + }, + "17": { + "f_bus": 4, + "t_bus": 9, + "br_r": 0.0, + "br_x": 0.55618, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 0.9690000000000001, + "shift": 0.0, + "transformer": true, + "rate_a": 9900.0, + "br_status": 1 + }, + "18": { + "f_bus": 5, + "t_bus": 6, + "br_r": 0.0, + "br_x": 0.25202, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 0.932, + "shift": 0.0, + "transformer": true, + "rate_a": 9900.0, + "br_status": 1 + }, + "19": { + "f_bus": 7, + "t_bus": 8, + "br_r": 0.0, + "br_x": 0.17614999999999997, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "20": { + "f_bus": 7, + "t_bus": 9, + "br_r": 0.0, + "br_x": 0.11001, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + } + }, + "load": { + "1": { + "load_bus": 2, + "pd": 21.7, + "qd": 12.7, + "status": 1 + }, + "2": { + "load_bus": 3, + "pd": 94.2, + "qd": 19.0, + "status": 1 + }, + "3": { + "load_bus": 4, + "pd": 47.79999999999998, + "qd": -3.8999999999999986, + "status": 1 + }, + "4": { + "load_bus": 5, + "pd": 7.599999999999994, + "qd": 1.6000000000000014, + "status": 1 + }, + "5": { + "load_bus": 6, + "pd": 11.199999999999989, + "qd": 7.5000000000000036, + "status": 1 + }, + "6": { + "load_bus": 9, + "pd": 29.5, + "qd": 16.6, + "status": 1 + }, + "7": { + "load_bus": 10, + "pd": 9.0, + "qd": 5.799999999999997, + "status": 1 + }, + "8": { + "load_bus": 11, + "pd": 3.5, + "qd": 1.7999999999999972, + "status": 1 + }, + "9": { + "load_bus": 12, + "pd": 6.099999999999994, + "qd": 1.6000000000000014, + "status": 1 + }, + "10": { + "load_bus": 13, + "pd": 13.5, + "qd": 5.799999999999997, + "status": 1 + }, + "11": { + "load_bus": 14, + "pd": 14.899999999999977, + "qd": 5.0, + "status": 1 + } + }, + "shunt": { + "1": { + "shunt_bus": 9, + "gs": 0.0, + "bs": 0.19, + "status": 1 + } + } + }, + "solution": { + "solution": { + "bus": { + "1": { + "vm": 1.06, + "va": 0.0 + }, + "2": { + "vm": 1.045, + "va": -0.08696258579893273 + }, + "3": { + "vm": 1.0099999999999998, + "va": -0.22209489156210976 + }, + "4": { + "vm": 1.017670853697962, + "va": -0.17999407948922763 + }, + "5": { + "vm": 1.0195138598245423, + "va": -0.15313263861103937 + }, + "6": { + "vm": 1.0700000000000003, + "va": -0.24820233853475715 + }, + "7": { + "vm": 1.0615195324941464, + "va": -0.2331694843597481 + }, + "8": { + "vm": 1.09, + "va": -0.23316948435974816 + }, + "9": { + "vm": 1.0559317206401924, + "va": -0.2607263819753595 + }, + "10": { + "vm": 1.0509846250025665, + "va": -0.2634973917979791 + }, + "11": { + "vm": 1.056906518541777, + "va": -0.2581450528581688 + }, + "12": { + "vm": 1.055188563197416, + "va": -0.2631185865373742 + }, + "13": { + "vm": 1.0503817136292164, + "va": -0.26452692440256464 + }, + "14": { + "vm": 1.035529945856189, + "va": -0.279839888122914 + } + }, + "gen": { + "1": { + "pg": 232.3932723516125, + "qg": -16.54930054271304 + }, + "2": { + "pg": 40.0, + "qg": 43.557100131702356 + }, + "3": { + "pg": 0.0, + "qg": 25.07534849503837 + }, + "4": { + "pg": 0.0, + "qg": 12.730944403419336 + }, + "5": { + "pg": 0.0, + "qg": 17.623451366097335 + } + }, + "branch": { + "1": { + "pf": 156.8828905276527, + "qf": -20.404291683138474, + "pt": -152.58529019170547, + "qt": 27.67624972637868 + }, + "2": { + "pf": 75.51038182395982, + "qf": 3.8549911404258097, + "pt": -72.7475092314731, + "qt": 2.2293587111759536 + }, + "3": { + "pf": 73.23757923676087, + "qf": 3.5602029509070876, + "pt": -70.91430990261782, + "qt": 1.602232873051274 + }, + "4": { + "pf": 56.1314959376533, + "qf": -1.5503504015192866, + "pt": -54.454838105687045, + "qt": 3.0206874669465256 + }, + "5": { + "pf": 41.516215017043365, + "qf": 1.1709978559361658, + "pt": -40.61246166394656, + "qt": -2.099033965205479 + }, + "6": { + "pf": -23.285690096364732, + "qf": 4.4731156219870005, + "pt": 23.659135052973696, + "qt": -4.835652496761387 + }, + "7": { + "pf": -61.15823044177681, + "qf": 15.823641935196205, + "pt": 61.672650034965805, + "qt": -14.201004551736396 + }, + "8": { + "pf": 7.353276982534694, + "qf": 3.5604729704122, + "pt": -7.297903658998023, + "qt": -3.444514304888471 + }, + "9": { + "pf": 7.786067015260372, + "qf": 2.503414236925035, + "pt": -7.714257771106834, + "qt": -2.3539591661666264 + }, + "10": { + "pf": 17.747976862065833, + "qf": 7.2165753882075, + "pt": -17.535891466953235, + "qt": -6.798913038678878 + }, + "11": { + "pf": 5.227552469422822, + "qf": 4.219137798621632, + "pt": -5.214677618549477, + "qt": -4.184937078760028 + }, + "12": { + "pf": 9.426381029986103, + "qf": 3.6100062385294085, + "pt": -9.310226938642742, + "qt": -3.362930924097671 + }, + "13": { + "pf": -3.7853223813161305, + "qf": -1.6150629212243621, + "pt": 3.7979036591170114, + "qt": 1.6445143048589141 + }, + "14": { + "pf": 1.614257771112855, + "qf": 0.7539591661614763, + "pt": -1.6079595127943314, + "qt": -0.7482607419685264 + }, + "15": { + "pf": 5.643850979844714, + "qf": 1.7471737806199834, + "pt": -5.589773061258252, + "qt": -1.637069075615241 + }, + "16": { + "pf": 28.074175916296966, + "qf": -9.681065714800072, + "pt": -28.074175916296962, + "qt": 11.384279940557457 + }, + "17": { + "pf": 16.079757583068666, + "qf": -0.4276111815086662, + "pt": -16.079757583068666, + "qt": 1.7323220059159545 + }, + "18": { + "pf": 44.087320858828775, + "qf": 12.470679811899931, + "pt": -44.0873208588288, + "qt": -8.04951819212526 + }, + "19": { + "pf": 2.8943414276472364e-14, + "qf": -17.162970509241255, + "pt": -2.931151117721153e-14, + "qt": 17.623451366097335 + }, + "20": { + "pf": 28.074175915923504, + "qf": 5.778690568988576, + "pt": -28.074175915923504, + "qt": -4.976621868457215 + } + } + }, + "termination_status": "SOLVED_PF", + "objective": 0.0 + }, + "parent_seed": -1 +} \ No newline at end of file diff --git a/lightsim2grid/tests/test_LSGrid_pf_delta.py b/lightsim2grid/tests/test_LSGrid_pf_delta.py new file mode 100644 index 00000000..7b61962c --- /dev/null +++ b/lightsim2grid/tests/test_LSGrid_pf_delta.py @@ -0,0 +1,156 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import copy +import json +import os +import unittest + +import numpy as np + +from lightsim2grid.network import init_from_pf_delta +from lightsim2grid.network.from_pf_delta._pf_delta_to_mpc import network_to_mpc +from lightsim2grid.network.from_matpower._aux_add_branch import get_branch_split + +_FIXTURE_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") + + +def _branch_split_keys(network): + """Reproduces, from the row's `network` dict alone, which branch keys end up as + lines vs. transformers and in what order -- by reusing `network_to_mpc` (the exact + function `init_from_pf_delta` uses) and `from_matpower`'s own classification, so + this never drifts out of sync with the loader itself.""" + branch_keys = sorted(network["branch"], key=int) + is_trafo = get_branch_split(network_to_mpc(network)["branch"]) + line_keys = [k for k, t in zip(branch_keys, is_trafo) if not t] + trafo_keys = [k for k, t in zip(branch_keys, is_trafo) if t] + return line_keys, trafo_keys + + +def validate_against_row(model, row, tol=1e-5): + """Compares `model`'s CURRENT (already solved) AC state against the PFΔ row's own + solved `solution`. Does not run any powerflow itself. Returns a dict with the max + absolute error for each of "vm", "va", "pf", "qf", "pt", "qt" -- restricted to the + entries actually present in the solution (inactive elements are absent, not zero).""" + network = row["network"] + solution = row["solution"]["solution"] + + bus_keys = sorted(network["bus"], key=int) + line_keys, trafo_keys = _branch_split_keys(network) + + vm = model.get_Vm() + va = model.get_Va() + errors = {"vm": 0.0, "va": 0.0} + for ls_id, bus_key in enumerate(bus_keys): + bus_sol = solution["bus"].get(bus_key) + if bus_sol is None: + continue + errors["vm"] = max(errors["vm"], abs(vm[ls_id] - bus_sol["vm"])) + errors["va"] = max(errors["va"], abs(va[ls_id] - bus_sol["va"])) + + por, qor, _, _ = model.get_line_res1() + pex, qex, _, _ = model.get_line_res2() + phv, qhv, _, _ = model.get_trafo_res1() + plv, qlv, _, _ = model.get_trafo_res2() + + errors.update({"pf": 0.0, "qf": 0.0, "pt": 0.0, "qt": 0.0}) + for ls_id, branch_key in enumerate(line_keys): + branch_sol = solution["branch"].get(branch_key) + if branch_sol is None: + continue + errors["pf"] = max(errors["pf"], abs(por[ls_id] - branch_sol["pf"])) + errors["qf"] = max(errors["qf"], abs(qor[ls_id] - branch_sol["qf"])) + errors["pt"] = max(errors["pt"], abs(pex[ls_id] - branch_sol["pt"])) + errors["qt"] = max(errors["qt"], abs(qex[ls_id] - branch_sol["qt"])) + for ls_id, branch_key in enumerate(trafo_keys): + branch_sol = solution["branch"].get(branch_key) + if branch_sol is None: + continue + errors["pf"] = max(errors["pf"], abs(phv[ls_id] - branch_sol["pf"])) + errors["qf"] = max(errors["qf"], abs(qhv[ls_id] - branch_sol["qf"])) + errors["pt"] = max(errors["pt"], abs(plv[ls_id] - branch_sol["pt"])) + errors["qt"] = max(errors["qt"], abs(qlv[ls_id] - branch_sol["qt"])) + + return errors + + +class BaseTests: + def setUp(self): + with open(_FIXTURE_PATH, "r") as f: + self.row = json.load(f) + self.model = init_from_pf_delta(self.row) + + def test_counts(self): + network = self.row["network"] + line_keys, trafo_keys = _branch_split_keys(network) + assert len(self.model.get_lines()) == len(line_keys) + assert len(self.model.get_trafos()) == len(trafo_keys) + assert len(self.model.get_generators()) == len(network["gen"]) + assert len(self.model.get_loads()) == len(network["load"]) + assert len(self.model.get_shunts()) == len(network.get("shunt", {})) + + def test_fixture_exercises_taps_and_shunt(self): + # sanity check on the fixture itself: if these were all trivial (tap==1, + # shunt absent) the AC powerflow test below would not actually be exercising + # the tap-ratio / shunt-sign conversions + network = self.row["network"] + _, trafo_keys = _branch_split_keys(network) + assert len(trafo_keys) > 0, "fixture has no transformers, tap conversion untested" + assert any(network["branch"][k]["tap"] != 1.0 for k in trafo_keys) + assert len(network.get("shunt", {})) > 0, "fixture has no shunt, sign convention untested" + + def test_ac_pf_matches_solution(self): + nb_bus = len(self.row["network"]["bus"]) + V0 = np.full(nb_bus, 1.0, dtype=complex) + Vfinal = self.model.ac_pf(V0, 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + errors = validate_against_row(self.model, self.row, tol=1e-5) + for name, err in errors.items(): + assert err <= 1e-5, f"error too large for {name}: {err}" + + def test_accepts_path(self): + model = init_from_pf_delta(_FIXTURE_PATH) + nb_bus = len(self.row["network"]["bus"]) + Vfinal = model.ac_pf(np.full(nb_bus, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + def test_missing_network_key_raises(self): + with self.assertRaises(RuntimeError): + init_from_pf_delta(self.row["network"]) + + def test_nonzero_g_fr_raises(self): + row = copy.deepcopy(self.row) + first_branch_key = next(iter(row["network"]["branch"])) + row["network"]["branch"][first_branch_key]["g_fr"] = 0.01 + with self.assertRaises(RuntimeError): + init_from_pf_delta(row) + + def test_shift_is_converted_from_radians_to_degrees(self): + # case14 has no phase shifters (shift == 0 everywhere), so this exercises the + # rad -> deg conversion directly on a small synthetic network instead + network = { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 10.0, "qmin": -10.0, + "vg": 1.0, "pmax": 100.0, "pmin": 0.0, "gen_status": 1}}, + "load": {}, "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, + "tap": 1.05, "shift": np.pi / 6, "br_status": 1}}, + } + mpc = network_to_mpc(network) + np.testing.assert_allclose(mpc["branch"][0, 9], 30.0) # SHIFT column, degrees + + +class TestLSGridPFDelta(BaseTests, unittest.TestCase): + pass + + +if __name__ == "__main__": + unittest.main() From 8a90a56b79b4a9b7df29ea3c554d1ef3c9ffec2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:31:56 +0000 Subject: [PATCH 026/166] =?UTF-8?q?Add=20init=5Ffrom=5Fpf=5Fdelta=20loader?= =?UTF-8?q?=20for=20the=20PF=CE=94=20benchmark=20dataset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds an LSGrid from a single PFΔ (arXiv:2510.22048) dataset row, a PowerModels.jl/MATPOWER-format dict with a solved AC power-flow state attached. Implemented as a thin translation layer on top of the new init_from_matpower loader: the row's PowerModels dict is converted into a raw MATPOWER-shaped bus/gen/branch array triple and delegated to it, reusing its bus-id remapping, line/transformer splitting, generator/load/shunt conversion and slack-bus handling rather than duplicating that logic. Since huggingface.co was unreachable to fetch a real sample row, the PFΔ/PowerModels schema and unit conventions were instead verified directly against the pfdelta and PowerModels.jl source repositories, and the test fixture is an independent pandapower solve of case14 translated into the verified PFΔ schema, used to check that lightsim2grid's own AC powerflow reproduces the row's solved vm/va/pf/qf/pt/qt to solver tolerance. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 10 + lightsim2grid/network/__init__.py | 3 + .../network/from_pf_delta/__init__.py | 11 + .../network/from_pf_delta/_pf_delta_to_mpc.py | 129 +++ .../network/from_pf_delta/initLSGrid.py | 63 ++ .../tests/debug_gen_pf_delta_fixture.py | 139 ++++ lightsim2grid/tests/pf_delta_case14.json | 754 ++++++++++++++++++ lightsim2grid/tests/test_LSGrid_pf_delta.py | 156 ++++ 8 files changed, 1265 insertions(+) create mode 100644 lightsim2grid/network/from_pf_delta/__init__.py create mode 100644 lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py create mode 100644 lightsim2grid/network/from_pf_delta/initLSGrid.py create mode 100644 lightsim2grid/tests/debug_gen_pf_delta_fixture.py create mode 100644 lightsim2grid/tests/pf_delta_case14.json create mode 100644 lightsim2grid/tests/test_LSGrid_pf_delta.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 13ad14c3..c985b0e4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -319,6 +319,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. Grids built from pandapower never populate these (getters return an empty array / NaN per element). Not wired into `LightSimBackend.thermal_limit_a`, which keeps its existing behaviour. +- [ADDED] `lightsim2grid.network.init_from_pf_delta`: builds an `LSGrid` from one row of + the PFΔ benchmark dataset (arXiv:2510.22048, a PowerModels.jl/MATPOWER-format dict + with a solved power-flow state attached). Accepts either a parsed row (dict) or a + path to its `.json` file. Implemented as a thin translation layer on top of + `init_from_matpower`: the PFΔ row's PowerModels dict is converted into a raw + MATPOWER-shaped `bus`/`gen`/`branch` array triple and delegated to it, reusing its + bus-id remapping, line/transformer splitting, generator/load/shunt conversion and + slack-bus handling. Tested in `test_LSGrid_pf_delta.py`, including an end-to-end + check that lightsim2grid's own AC powerflow reproduces a row's solved `vm`/`va`/ + `pf`/`qf`/`pt`/`qt` to solver tolerance. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 19b4d6e6..da006533 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -43,6 +43,9 @@ # a ".m"/".mat" file pass +from lightsim2grid.network.from_pf_delta import init as init_from_pf_delta # noqa +__all__.append("init_from_pf_delta") + try: from lightsim2grid.network.compare_lsgrid import compare_lsgrid # noqa __all__.append("compare_lsgrid") diff --git a/lightsim2grid/network/from_pf_delta/__init__.py b/lightsim2grid/network/from_pf_delta/__init__.py new file mode 100644 index 00000000..df92c6dc --- /dev/null +++ b/lightsim2grid/network/from_pf_delta/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +__all__ = ["init"] + +from .initLSGrid import init diff --git a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py new file mode 100644 index 00000000..09a36994 --- /dev/null +++ b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py @@ -0,0 +1,129 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ..from_matpower._my_const import ( + BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, BASE_KV, ZONE, VMAX, VMIN, + GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, + F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, + MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, +) + + +def _is_transformer(branch): + """A PowerModels branch is a transformer if either its own `transformer` flag says + so, or its `tap`/`shift` are away from the "plain line" neutral (`tap == 1.0`, + `shift == 0.0`) -- PowerModels normalizes a matpower `TAP == 0` (its own "this is a + plain line" sentinel) to `tap = 1.0`, so this loader cannot just test `tap != 0` + like `from_matpower.get_branch_split` does on a raw mpc branch matrix. + """ + return bool(branch.get("transformer", False)) or branch.get("tap", 1.0) != 1.0 or branch.get("shift", 0.0) != 0.0 + + +def network_to_mpc(network: dict) -> dict: + """ + Convert a PFΔ / PowerModels.jl ``network`` dict into a raw-MATPOWER-shaped dict + (``{"bus", "gen", "branch"}`` numpy arrays in matpower's own column layout, plus + ``"baseMVA"``) consumable by :func:`lightsim2grid.network.from_matpower.init`. + + Row order is ``sorted(network[], key=int)`` for bus / gen / branch: this is a + deterministic, documented contract (not just an implementation detail), relied upon + by tests that need to map a built `LSGrid`'s element positions back to the original + PFΔ row's bus / gen / branch keys. + """ + for unsupported in ("dcline", "storage"): + if network.get(unsupported): + raise RuntimeError(f'"{unsupported}" is present in this PFΔ row but is not ' + f"supported by this loader.") + if network.get("multinetwork"): + raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") + + base_mva = float(network["baseMVA"]) + + bus_keys = sorted(network["bus"], key=int) + gen_keys = sorted(network["gen"], key=int) + branch_keys = sorted(network["branch"], key=int) + + # raw matpower embeds PD / QD / GS / BS directly on the bus row; PowerModels splits + # them into separate "load" / "shunt" dicts, so they need to be aggregated back + # (only active elements contribute -- matpower has no separate "load/shunt present + # but off" column, so an inactive one is equivalent to 0 demand/admittance here) + pd = {int(bus["bus_i"]): 0.0 for bus in network["bus"].values()} + qd, gs, bs = dict(pd), dict(pd), dict(pd) + for load in network.get("load", {}).values(): + if load.get("status", 1) == 0: + continue + b = int(load["load_bus"]) + pd[b] += load["pd"] + qd[b] += load["qd"] + for shunt in network.get("shunt", {}).values(): + if shunt.get("status", 1) == 0: + continue + b = int(shunt["shunt_bus"]) + gs[b] += shunt["gs"] * base_mva + bs[b] += shunt["bs"] * base_mva + + bus = np.zeros((len(bus_keys), MIN_BUS_COLS)) + for i, k in enumerate(bus_keys): + b = network["bus"][k] + bus_i = int(b["bus_i"]) + bus[i, BUS_I] = bus_i + bus[i, BUS_TYPE] = b["bus_type"] + bus[i, PD] = pd[bus_i] + bus[i, QD] = qd[bus_i] + bus[i, GS] = gs[bus_i] + bus[i, BS] = bs[bus_i] + bus[i, BUS_AREA] = 1.0 + bus[i, VM] = 1.0 + bus[i, VA] = 0.0 + bus[i, BASE_KV] = b.get("base_kv", 1.0) + bus[i, ZONE] = 1.0 + bus[i, VMAX] = b["vmax"] + bus[i, VMIN] = b["vmin"] + + gen = np.zeros((len(gen_keys), MIN_GEN_COLS)) + for i, k in enumerate(gen_keys): + g = network["gen"][k] + gen[i, GEN_BUS] = int(g["gen_bus"]) + gen[i, PG] = g["pg"] + gen[i, QG] = g["qg"] + gen[i, QMAX] = g["qmax"] + gen[i, QMIN] = g["qmin"] + gen[i, VG] = g["vg"] + gen[i, MBASE] = g.get("mbase", base_mva) + gen[i, GEN_STATUS] = g.get("gen_status", 1) + gen[i, PMAX] = g["pmax"] + gen[i, PMIN] = g["pmin"] + + branch = np.zeros((len(branch_keys), MIN_BRANCH_COLS)) + for i, k in enumerate(branch_keys): + br = network["branch"][k] + if br.get("g_fr", 0.0) != 0.0 or br.get("g_to", 0.0) != 0.0: + raise RuntimeError(f'branch "{k}" has a non-zero "g_fr"/"g_to" (line ' + f"conductance); the raw MATPOWER format this loader " + f"delegates to cannot represent that (only susceptance).") + is_trafo = _is_transformer(br) + branch[i, F_BUS] = int(br["f_bus"]) + branch[i, T_BUS] = int(br["t_bus"]) + branch[i, BR_R] = br["br_r"] + branch[i, BR_X] = br["br_x"] + branch[i, BR_B] = br.get("b_fr", 0.0) + br.get("b_to", 0.0) + branch[i, RATE_A] = br.get("rate_a", 0.0) + branch[i, RATE_B] = br.get("rate_b", 0.0) + branch[i, RATE_C] = br.get("rate_c", 0.0) + # matpower's own "this is a plain line" sentinel is TAP == 0, which is what + # `from_matpower.get_branch_split` tests for; PowerModels never uses 0 (it + # normalizes a matpower TAP==0 to tap=1.0), so it has to be re-encoded here + branch[i, TAP] = br.get("tap", 1.0) if is_trafo else 0.0 + # PowerModels stores shift/angles in radians (converted from matpower's + # degrees at parse time); matpower's raw SHIFT column is in degrees + branch[i, SHIFT] = np.rad2deg(br.get("shift", 0.0)) + branch[i, BR_STATUS] = br.get("br_status", 1) + + return {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} diff --git a/lightsim2grid/network/from_pf_delta/initLSGrid.py b/lightsim2grid/network/from_pf_delta/initLSGrid.py new file mode 100644 index 00000000..4127d896 --- /dev/null +++ b/lightsim2grid/network/from_pf_delta/initLSGrid.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Build a LSGrid from a single PFΔ (arXiv:2510.22048) dataset row: a PowerModels.jl-format +dict (MATPOWER-derived, per-unit) with a solved AC power-flow state attached. + +This loader translates the row's PowerModels ``network`` dict into a raw-MATPOWER-shaped +dict and delegates the actual `LSGrid` construction to +`lightsim2grid.network.from_matpower`, which already implements bus-id remapping, +line/transformer splitting, generator/load/shunt conversion and (possibly distributed) +slack-bus handling. +""" + +from typing import Optional, Union +import os +import json + +from ...lightsim2grid_cpp import LSGrid +from ..from_matpower import init as _init_from_matpower +from ._pf_delta_to_mpc import network_to_mpc + + +def init(row: Union[dict, str, "os.PathLike"], + n_sub: Optional[int] = None, # number of voltage levels + n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level + ) -> LSGrid: + """ + Convert a PFΔ dataset row into a LSGrid. + + Parameters + ---------- + row: + Either a PFΔ row already parsed into a dict (with a top-level ``"network"`` + key), or a path (str or `os.PathLike`) to a ``.json`` file containing that same + structure. + n_sub: + number of voltage levels / substations. If not provided, defaults to one + substation per PFΔ bus (`n_busbar_per_sub` is then forced to 1). + n_busbar_per_sub: + max number of buses allowed per substation / voltage level. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + if isinstance(row, (str, os.PathLike)): + with open(row, "r") as f: + row = json.load(f) + + if "network" not in row: + raise RuntimeError('Expected a PFΔ row (a dict with a top-level "network" key), ' + f"got a dict with keys {sorted(row.keys())}.") + + mpc = network_to_mpc(row["network"]) + return _init_from_matpower(mpc, n_sub=n_sub, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/tests/debug_gen_pf_delta_fixture.py b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py new file mode 100644 index 00000000..c5c3cfe4 --- /dev/null +++ b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +One-off (not collected by unittest / pytest) script used to (re)generate the committed +`pf_delta_case14.json` fixture used by `test_LSGrid_pf_delta.py`. + +Real PFΔ data could not be downloaded for this repo (huggingface.co is blocked in the dev +sandbox this was written in), so this script builds a PFΔ-schema-shaped row from an +independent, non-lightsim2grid source of truth: pandapower's own internal per-unit "ppc" +representation (`net["_ppc"]`, which uses the exact same MATPOWER/PyPower column layout +`lightsim2grid.network.from_matpower` and PowerModels.jl both use) of its bundled IEEE +case14 network, solved with pandapower's own AC power flow. This sidesteps having to +hand-derive pandapower's own trafo T-model -> per-unit conversion. + +Run manually (requires `pip install pandapower`): + python lightsim2grid/tests/debug_gen_pf_delta_fixture.py +""" + +import json +import os + +import numpy as np +import pandapower as pp +import pandapower.networks as pn +from pandapower.pypower.idx_bus import BUS_I, BUS_TYPE, PD, QD, GS, BS, BASE_KV, VMAX, VMIN, VM, VA +from pandapower.pypower.idx_gen import GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN +from pandapower.pypower.idx_brch import (F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, TAP, SHIFT, + BR_STATUS, PF, QF, PT, QT) + + +def build_pf_delta_row(net) -> dict: + ppc = net["_ppc"] + bus, gen, branch = ppc["bus"].real, ppc["gen"].real, ppc["branch"].real + base_mva = float(ppc["baseMVA"]) + + network = {"baseMVA": base_mva, "bus": {}, "gen": {}, "branch": {}, "load": {}, "shunt": {}} + solution = {"bus": {}, "gen": {}, "branch": {}} + + load_idx, shunt_idx = 1, 1 + for i in range(bus.shape[0]): + bus_i = int(bus[i, BUS_I]) + 1 # 1-based, mirrors PowerModels' own convention + key = str(i + 1) + network["bus"][key] = { + "bus_i": bus_i, + "bus_type": int(bus[i, BUS_TYPE]), + "base_kv": float(bus[i, BASE_KV]), + "vmax": float(bus[i, VMAX]), + "vmin": float(bus[i, VMIN]), + } + solution["bus"][key] = {"vm": float(bus[i, VM]), "va": float(np.deg2rad(bus[i, VA]))} + + pd_, qd_ = float(bus[i, PD]), float(bus[i, QD]) + if pd_ != 0.0 or qd_ != 0.0: + network["load"][str(load_idx)] = {"load_bus": bus_i, "pd": pd_, "qd": qd_, "status": 1} + load_idx += 1 + + gs_, bs_ = float(bus[i, GS]), float(bus[i, BS]) + if gs_ != 0.0 or bs_ != 0.0: + # matpower's GS/BS are MW/MVAr "at V=1pu"; PowerModels' gs/bs are per-unit + network["shunt"][str(shunt_idx)] = {"shunt_bus": bus_i, "gs": gs_ / base_mva, + "bs": bs_ / base_mva, "status": 1} + shunt_idx += 1 + + for i in range(gen.shape[0]): + key = str(i + 1) + network["gen"][key] = { + "gen_bus": int(gen[i, GEN_BUS]) + 1, + "pg": float(gen[i, PG]), + "qg": float(gen[i, QG]), + "qmax": float(gen[i, QMAX]), + "qmin": float(gen[i, QMIN]), + "vg": float(gen[i, VG]), + "mbase": float(gen[i, MBASE]), + "gen_status": int(gen[i, GEN_STATUS]), + "pmax": float(gen[i, PMAX]), + "pmin": float(gen[i, PMIN]), + } + solution["gen"][key] = {"pg": float(gen[i, PG]), "qg": float(gen[i, QG])} + + for i in range(branch.shape[0]): + key = str(i + 1) + tap = float(branch[i, TAP]) + shift_deg = float(branch[i, SHIFT]) + is_transformer = (abs(tap - 1.0) > 1e-9) or (shift_deg != 0.0) + b_total = float(branch[i, BR_B]) + network["branch"][key] = { + "f_bus": int(branch[i, F_BUS]) + 1, + "t_bus": int(branch[i, T_BUS]) + 1, + "br_r": float(branch[i, BR_R]), + "br_x": float(branch[i, BR_X]), + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": b_total / 2.0, + "b_to": b_total / 2.0, + "tap": tap, + "shift": np.deg2rad(shift_deg), # PowerModels stores angles in radians + "transformer": is_transformer, + "rate_a": float(branch[i, RATE_A]), + "br_status": int(branch[i, BR_STATUS]), + } + if int(branch[i, BR_STATUS]) != 0: + solution["branch"][key] = { + "pf": float(branch[i, PF]), "qf": float(branch[i, QF]), + "pt": float(branch[i, PT]), "qt": float(branch[i, QT]), + } + + return { + "network": network, + "solution": {"solution": solution, "termination_status": "SOLVED_PF", "objective": 0.0}, + "parent_seed": -1, + } + + +if __name__ == "__main__": + net = pn.case14() + pp.runpp(net, numba=False) + row = build_pf_delta_row(net) + + out_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") + with open(out_path, "w") as f: + json.dump(row, f, indent=2) + + n_trafo = sum(1 for br in row["network"]["branch"].values() if br["transformer"]) + n_shunt = len(row["network"]["shunt"]) + print(f"wrote {out_path}") + print(f" {len(row['network']['bus'])} buses, {len(row['network']['branch'])} branches " + f"({n_trafo} transformers), {len(row['network']['gen'])} gens, " + f"{len(row['network']['load'])} loads, {n_shunt} shunts") + for br in row["network"]["branch"].values(): + if br["transformer"]: + print(f" sample transformer: tap={br['tap']}, shift={br['shift']} rad") + for sh in row["network"]["shunt"].values(): + print(f" sample shunt: gs={sh['gs']} pu, bs={sh['bs']} pu") diff --git a/lightsim2grid/tests/pf_delta_case14.json b/lightsim2grid/tests/pf_delta_case14.json new file mode 100644 index 00000000..0fbf5e40 --- /dev/null +++ b/lightsim2grid/tests/pf_delta_case14.json @@ -0,0 +1,754 @@ +{ + "network": { + "baseMVA": 100.0, + "bus": { + "1": { + "bus_i": 1, + "bus_type": 3, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "2": { + "bus_i": 2, + "bus_type": 2, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "3": { + "bus_i": 3, + "bus_type": 2, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "4": { + "bus_i": 4, + "bus_type": 1, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "5": { + "bus_i": 5, + "bus_type": 1, + "base_kv": 135.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "6": { + "bus_i": 6, + "bus_type": 2, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "7": { + "bus_i": 7, + "bus_type": 1, + "base_kv": 14.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "8": { + "bus_i": 8, + "bus_type": 2, + "base_kv": 12.0, + "vmax": 2.0, + "vmin": 0.0 + }, + "9": { + "bus_i": 9, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "10": { + "bus_i": 10, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "11": { + "bus_i": 11, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "12": { + "bus_i": 12, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "13": { + "bus_i": 13, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + }, + "14": { + "bus_i": 14, + "bus_type": 1, + "base_kv": 0.208, + "vmax": 2.0, + "vmin": 0.0 + } + }, + "gen": { + "1": { + "gen_bus": 1, + "pg": 232.3932723516125, + "qg": -16.54930054271304, + "qmax": 0.0, + "qmin": 0.0, + "vg": 1.06, + "mbase": 1.0, + "gen_status": 1, + "pmax": 1000000000.0, + "pmin": -1000000000.0 + }, + "2": { + "gen_bus": 2, + "pg": 40.0, + "qg": 43.557100131702356, + "qmax": 50.0, + "qmin": -40.0, + "vg": 1.045, + "mbase": NaN, + "gen_status": 1, + "pmax": 140.0, + "pmin": 0.0 + }, + "3": { + "gen_bus": 3, + "pg": 0.0, + "qg": 25.07534849503837, + "qmax": 40.0, + "qmin": 0.0, + "vg": 1.01, + "mbase": NaN, + "gen_status": 1, + "pmax": 100.0, + "pmin": 0.0 + }, + "4": { + "gen_bus": 6, + "pg": 0.0, + "qg": 12.730944403419336, + "qmax": 24.0, + "qmin": -6.0, + "vg": 1.07, + "mbase": NaN, + "gen_status": 1, + "pmax": 100.0, + "pmin": 0.0 + }, + "5": { + "gen_bus": 8, + "pg": 0.0, + "qg": 17.623451366097335, + "qmax": 24.0, + "qmin": -6.0, + "vg": 1.09, + "mbase": NaN, + "gen_status": 1, + "pmax": 100.0, + "pmin": 0.0 + } + }, + "branch": { + "1": { + "f_bus": 1, + "t_bus": 2, + "br_r": 0.019379999999999998, + "br_x": 0.05916999999999999, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0264, + "b_to": 0.0264, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "2": { + "f_bus": 1, + "t_bus": 5, + "br_r": 0.05403, + "br_x": 0.22304, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0246, + "b_to": 0.0246, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "3": { + "f_bus": 2, + "t_bus": 3, + "br_r": 0.046990000000000004, + "br_x": 0.19797, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.021900000000000003, + "b_to": 0.021900000000000003, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "4": { + "f_bus": 2, + "t_bus": 4, + "br_r": 0.058109999999999995, + "br_x": 0.17632, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.017000000000000005, + "b_to": 0.017000000000000005, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "5": { + "f_bus": 2, + "t_bus": 5, + "br_r": 0.05695, + "br_x": 0.17388, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.017300000000000003, + "b_to": 0.017300000000000003, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "6": { + "f_bus": 3, + "t_bus": 4, + "br_r": 0.06701, + "br_x": 0.17103, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.006400000000000001, + "b_to": 0.006400000000000001, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "7": { + "f_bus": 4, + "t_bus": 5, + "br_r": 0.01335, + "br_x": 0.04211, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9899.999999999998, + "br_status": 1 + }, + "8": { + "f_bus": 6, + "t_bus": 11, + "br_r": 0.09498, + "br_x": 0.19890000000000002, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "9": { + "f_bus": 6, + "t_bus": 12, + "br_r": 0.12291, + "br_x": 0.25581, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "10": { + "f_bus": 6, + "t_bus": 13, + "br_r": 0.06615, + "br_x": 0.13027, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "11": { + "f_bus": 9, + "t_bus": 10, + "br_r": 0.031810000000000005, + "br_x": 0.08449999999999999, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "12": { + "f_bus": 9, + "t_bus": 14, + "br_r": 0.12711, + "br_x": 0.27038, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "13": { + "f_bus": 10, + "t_bus": 11, + "br_r": 0.08205, + "br_x": 0.19207000000000002, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "14": { + "f_bus": 12, + "t_bus": 13, + "br_r": 0.22092, + "br_x": 0.19988, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "15": { + "f_bus": 13, + "t_bus": 14, + "br_r": 0.17093, + "br_x": 0.34802, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": 0.0, + "b_to": 0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "16": { + "f_bus": 4, + "t_bus": 7, + "br_r": 0.0, + "br_x": 0.20912, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 0.9780000000000001, + "shift": 0.0, + "transformer": true, + "rate_a": 9900.0, + "br_status": 1 + }, + "17": { + "f_bus": 4, + "t_bus": 9, + "br_r": 0.0, + "br_x": 0.55618, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 0.9690000000000001, + "shift": 0.0, + "transformer": true, + "rate_a": 9900.0, + "br_status": 1 + }, + "18": { + "f_bus": 5, + "t_bus": 6, + "br_r": 0.0, + "br_x": 0.25202, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 0.932, + "shift": 0.0, + "transformer": true, + "rate_a": 9900.0, + "br_status": 1 + }, + "19": { + "f_bus": 7, + "t_bus": 8, + "br_r": 0.0, + "br_x": 0.17614999999999997, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + }, + "20": { + "f_bus": 7, + "t_bus": 9, + "br_r": 0.0, + "br_x": 0.11001, + "g_fr": 0.0, + "g_to": 0.0, + "b_fr": -0.0, + "b_to": -0.0, + "tap": 1.0, + "shift": 0.0, + "transformer": false, + "rate_a": 9900.0, + "br_status": 1 + } + }, + "load": { + "1": { + "load_bus": 2, + "pd": 21.7, + "qd": 12.7, + "status": 1 + }, + "2": { + "load_bus": 3, + "pd": 94.2, + "qd": 19.0, + "status": 1 + }, + "3": { + "load_bus": 4, + "pd": 47.79999999999998, + "qd": -3.8999999999999986, + "status": 1 + }, + "4": { + "load_bus": 5, + "pd": 7.599999999999994, + "qd": 1.6000000000000014, + "status": 1 + }, + "5": { + "load_bus": 6, + "pd": 11.199999999999989, + "qd": 7.5000000000000036, + "status": 1 + }, + "6": { + "load_bus": 9, + "pd": 29.5, + "qd": 16.6, + "status": 1 + }, + "7": { + "load_bus": 10, + "pd": 9.0, + "qd": 5.799999999999997, + "status": 1 + }, + "8": { + "load_bus": 11, + "pd": 3.5, + "qd": 1.7999999999999972, + "status": 1 + }, + "9": { + "load_bus": 12, + "pd": 6.099999999999994, + "qd": 1.6000000000000014, + "status": 1 + }, + "10": { + "load_bus": 13, + "pd": 13.5, + "qd": 5.799999999999997, + "status": 1 + }, + "11": { + "load_bus": 14, + "pd": 14.899999999999977, + "qd": 5.0, + "status": 1 + } + }, + "shunt": { + "1": { + "shunt_bus": 9, + "gs": 0.0, + "bs": 0.19, + "status": 1 + } + } + }, + "solution": { + "solution": { + "bus": { + "1": { + "vm": 1.06, + "va": 0.0 + }, + "2": { + "vm": 1.045, + "va": -0.08696258579893273 + }, + "3": { + "vm": 1.0099999999999998, + "va": -0.22209489156210976 + }, + "4": { + "vm": 1.017670853697962, + "va": -0.17999407948922763 + }, + "5": { + "vm": 1.0195138598245423, + "va": -0.15313263861103937 + }, + "6": { + "vm": 1.0700000000000003, + "va": -0.24820233853475715 + }, + "7": { + "vm": 1.0615195324941464, + "va": -0.2331694843597481 + }, + "8": { + "vm": 1.09, + "va": -0.23316948435974816 + }, + "9": { + "vm": 1.0559317206401924, + "va": -0.2607263819753595 + }, + "10": { + "vm": 1.0509846250025665, + "va": -0.2634973917979791 + }, + "11": { + "vm": 1.056906518541777, + "va": -0.2581450528581688 + }, + "12": { + "vm": 1.055188563197416, + "va": -0.2631185865373742 + }, + "13": { + "vm": 1.0503817136292164, + "va": -0.26452692440256464 + }, + "14": { + "vm": 1.035529945856189, + "va": -0.279839888122914 + } + }, + "gen": { + "1": { + "pg": 232.3932723516125, + "qg": -16.54930054271304 + }, + "2": { + "pg": 40.0, + "qg": 43.557100131702356 + }, + "3": { + "pg": 0.0, + "qg": 25.07534849503837 + }, + "4": { + "pg": 0.0, + "qg": 12.730944403419336 + }, + "5": { + "pg": 0.0, + "qg": 17.623451366097335 + } + }, + "branch": { + "1": { + "pf": 156.8828905276527, + "qf": -20.404291683138474, + "pt": -152.58529019170547, + "qt": 27.67624972637868 + }, + "2": { + "pf": 75.51038182395982, + "qf": 3.8549911404258097, + "pt": -72.7475092314731, + "qt": 2.2293587111759536 + }, + "3": { + "pf": 73.23757923676087, + "qf": 3.5602029509070876, + "pt": -70.91430990261782, + "qt": 1.602232873051274 + }, + "4": { + "pf": 56.1314959376533, + "qf": -1.5503504015192866, + "pt": -54.454838105687045, + "qt": 3.0206874669465256 + }, + "5": { + "pf": 41.516215017043365, + "qf": 1.1709978559361658, + "pt": -40.61246166394656, + "qt": -2.099033965205479 + }, + "6": { + "pf": -23.285690096364732, + "qf": 4.4731156219870005, + "pt": 23.659135052973696, + "qt": -4.835652496761387 + }, + "7": { + "pf": -61.15823044177681, + "qf": 15.823641935196205, + "pt": 61.672650034965805, + "qt": -14.201004551736396 + }, + "8": { + "pf": 7.353276982534694, + "qf": 3.5604729704122, + "pt": -7.297903658998023, + "qt": -3.444514304888471 + }, + "9": { + "pf": 7.786067015260372, + "qf": 2.503414236925035, + "pt": -7.714257771106834, + "qt": -2.3539591661666264 + }, + "10": { + "pf": 17.747976862065833, + "qf": 7.2165753882075, + "pt": -17.535891466953235, + "qt": -6.798913038678878 + }, + "11": { + "pf": 5.227552469422822, + "qf": 4.219137798621632, + "pt": -5.214677618549477, + "qt": -4.184937078760028 + }, + "12": { + "pf": 9.426381029986103, + "qf": 3.6100062385294085, + "pt": -9.310226938642742, + "qt": -3.362930924097671 + }, + "13": { + "pf": -3.7853223813161305, + "qf": -1.6150629212243621, + "pt": 3.7979036591170114, + "qt": 1.6445143048589141 + }, + "14": { + "pf": 1.614257771112855, + "qf": 0.7539591661614763, + "pt": -1.6079595127943314, + "qt": -0.7482607419685264 + }, + "15": { + "pf": 5.643850979844714, + "qf": 1.7471737806199834, + "pt": -5.589773061258252, + "qt": -1.637069075615241 + }, + "16": { + "pf": 28.074175916296966, + "qf": -9.681065714800072, + "pt": -28.074175916296962, + "qt": 11.384279940557457 + }, + "17": { + "pf": 16.079757583068666, + "qf": -0.4276111815086662, + "pt": -16.079757583068666, + "qt": 1.7323220059159545 + }, + "18": { + "pf": 44.087320858828775, + "qf": 12.470679811899931, + "pt": -44.0873208588288, + "qt": -8.04951819212526 + }, + "19": { + "pf": 2.8943414276472364e-14, + "qf": -17.162970509241255, + "pt": -2.931151117721153e-14, + "qt": 17.623451366097335 + }, + "20": { + "pf": 28.074175915923504, + "qf": 5.778690568988576, + "pt": -28.074175915923504, + "qt": -4.976621868457215 + } + } + }, + "termination_status": "SOLVED_PF", + "objective": 0.0 + }, + "parent_seed": -1 +} \ No newline at end of file diff --git a/lightsim2grid/tests/test_LSGrid_pf_delta.py b/lightsim2grid/tests/test_LSGrid_pf_delta.py new file mode 100644 index 00000000..7b61962c --- /dev/null +++ b/lightsim2grid/tests/test_LSGrid_pf_delta.py @@ -0,0 +1,156 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import copy +import json +import os +import unittest + +import numpy as np + +from lightsim2grid.network import init_from_pf_delta +from lightsim2grid.network.from_pf_delta._pf_delta_to_mpc import network_to_mpc +from lightsim2grid.network.from_matpower._aux_add_branch import get_branch_split + +_FIXTURE_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") + + +def _branch_split_keys(network): + """Reproduces, from the row's `network` dict alone, which branch keys end up as + lines vs. transformers and in what order -- by reusing `network_to_mpc` (the exact + function `init_from_pf_delta` uses) and `from_matpower`'s own classification, so + this never drifts out of sync with the loader itself.""" + branch_keys = sorted(network["branch"], key=int) + is_trafo = get_branch_split(network_to_mpc(network)["branch"]) + line_keys = [k for k, t in zip(branch_keys, is_trafo) if not t] + trafo_keys = [k for k, t in zip(branch_keys, is_trafo) if t] + return line_keys, trafo_keys + + +def validate_against_row(model, row, tol=1e-5): + """Compares `model`'s CURRENT (already solved) AC state against the PFΔ row's own + solved `solution`. Does not run any powerflow itself. Returns a dict with the max + absolute error for each of "vm", "va", "pf", "qf", "pt", "qt" -- restricted to the + entries actually present in the solution (inactive elements are absent, not zero).""" + network = row["network"] + solution = row["solution"]["solution"] + + bus_keys = sorted(network["bus"], key=int) + line_keys, trafo_keys = _branch_split_keys(network) + + vm = model.get_Vm() + va = model.get_Va() + errors = {"vm": 0.0, "va": 0.0} + for ls_id, bus_key in enumerate(bus_keys): + bus_sol = solution["bus"].get(bus_key) + if bus_sol is None: + continue + errors["vm"] = max(errors["vm"], abs(vm[ls_id] - bus_sol["vm"])) + errors["va"] = max(errors["va"], abs(va[ls_id] - bus_sol["va"])) + + por, qor, _, _ = model.get_line_res1() + pex, qex, _, _ = model.get_line_res2() + phv, qhv, _, _ = model.get_trafo_res1() + plv, qlv, _, _ = model.get_trafo_res2() + + errors.update({"pf": 0.0, "qf": 0.0, "pt": 0.0, "qt": 0.0}) + for ls_id, branch_key in enumerate(line_keys): + branch_sol = solution["branch"].get(branch_key) + if branch_sol is None: + continue + errors["pf"] = max(errors["pf"], abs(por[ls_id] - branch_sol["pf"])) + errors["qf"] = max(errors["qf"], abs(qor[ls_id] - branch_sol["qf"])) + errors["pt"] = max(errors["pt"], abs(pex[ls_id] - branch_sol["pt"])) + errors["qt"] = max(errors["qt"], abs(qex[ls_id] - branch_sol["qt"])) + for ls_id, branch_key in enumerate(trafo_keys): + branch_sol = solution["branch"].get(branch_key) + if branch_sol is None: + continue + errors["pf"] = max(errors["pf"], abs(phv[ls_id] - branch_sol["pf"])) + errors["qf"] = max(errors["qf"], abs(qhv[ls_id] - branch_sol["qf"])) + errors["pt"] = max(errors["pt"], abs(plv[ls_id] - branch_sol["pt"])) + errors["qt"] = max(errors["qt"], abs(qlv[ls_id] - branch_sol["qt"])) + + return errors + + +class BaseTests: + def setUp(self): + with open(_FIXTURE_PATH, "r") as f: + self.row = json.load(f) + self.model = init_from_pf_delta(self.row) + + def test_counts(self): + network = self.row["network"] + line_keys, trafo_keys = _branch_split_keys(network) + assert len(self.model.get_lines()) == len(line_keys) + assert len(self.model.get_trafos()) == len(trafo_keys) + assert len(self.model.get_generators()) == len(network["gen"]) + assert len(self.model.get_loads()) == len(network["load"]) + assert len(self.model.get_shunts()) == len(network.get("shunt", {})) + + def test_fixture_exercises_taps_and_shunt(self): + # sanity check on the fixture itself: if these were all trivial (tap==1, + # shunt absent) the AC powerflow test below would not actually be exercising + # the tap-ratio / shunt-sign conversions + network = self.row["network"] + _, trafo_keys = _branch_split_keys(network) + assert len(trafo_keys) > 0, "fixture has no transformers, tap conversion untested" + assert any(network["branch"][k]["tap"] != 1.0 for k in trafo_keys) + assert len(network.get("shunt", {})) > 0, "fixture has no shunt, sign convention untested" + + def test_ac_pf_matches_solution(self): + nb_bus = len(self.row["network"]["bus"]) + V0 = np.full(nb_bus, 1.0, dtype=complex) + Vfinal = self.model.ac_pf(V0, 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + errors = validate_against_row(self.model, self.row, tol=1e-5) + for name, err in errors.items(): + assert err <= 1e-5, f"error too large for {name}: {err}" + + def test_accepts_path(self): + model = init_from_pf_delta(_FIXTURE_PATH) + nb_bus = len(self.row["network"]["bus"]) + Vfinal = model.ac_pf(np.full(nb_bus, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + def test_missing_network_key_raises(self): + with self.assertRaises(RuntimeError): + init_from_pf_delta(self.row["network"]) + + def test_nonzero_g_fr_raises(self): + row = copy.deepcopy(self.row) + first_branch_key = next(iter(row["network"]["branch"])) + row["network"]["branch"][first_branch_key]["g_fr"] = 0.01 + with self.assertRaises(RuntimeError): + init_from_pf_delta(row) + + def test_shift_is_converted_from_radians_to_degrees(self): + # case14 has no phase shifters (shift == 0 everywhere), so this exercises the + # rad -> deg conversion directly on a small synthetic network instead + network = { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 10.0, "qmin": -10.0, + "vg": 1.0, "pmax": 100.0, "pmin": 0.0, "gen_status": 1}}, + "load": {}, "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, + "tap": 1.05, "shift": np.pi / 6, "br_status": 1}}, + } + mpc = network_to_mpc(network) + np.testing.assert_allclose(mpc["branch"][0, 9], 30.0) # SHIFT column, degrees + + +class TestLSGridPFDelta(BaseTests, unittest.TestCase): + pass + + +if __name__ == "__main__": + unittest.main() From 4239224f422e27bafb208e0adbcac0d590bb5084 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:35:59 +0000 Subject: [PATCH 027/166] Adapt to matpower loader review changes; use pi trafo model for fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - from_matpower.init dropped its n_sub parameter (there is always exactly one substation per matpower/PowerModels bus); mirror that in init_from_pf_delta and drop the pass-through argument. - debug_gen_pf_delta_fixture.py now solves with pandapower's trafo_model="pi" instead of its default "t" (T-equivalent) model: the two only coincide when a transformer has no iron losses / series resistance, which happens to be true for case14's transformers but is not true for MATPOWER-derived cases in general, so "pi" is the representative choice for a PFΔ-schema fixture. The committed pf_delta_case14.json fixture is byte-identical after regenerating with this flag (case14's transformers have zero iron loss / resistance), so no fixture update was needed, but the script now matches MATPOWER's convention explicitly rather than by coincidence. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- lightsim2grid/network/from_pf_delta/initLSGrid.py | 15 +++++++++------ lightsim2grid/tests/debug_gen_pf_delta_fixture.py | 8 +++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lightsim2grid/network/from_pf_delta/initLSGrid.py b/lightsim2grid/network/from_pf_delta/initLSGrid.py index 4127d896..170ed8a5 100644 --- a/lightsim2grid/network/from_pf_delta/initLSGrid.py +++ b/lightsim2grid/network/from_pf_delta/initLSGrid.py @@ -27,7 +27,6 @@ def init(row: Union[dict, str, "os.PathLike"], - n_sub: Optional[int] = None, # number of voltage levels n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level ) -> LSGrid: """ @@ -39,11 +38,15 @@ def init(row: Union[dict, str, "os.PathLike"], Either a PFΔ row already parsed into a dict (with a top-level ``"network"`` key), or a path (str or `os.PathLike`) to a ``.json`` file containing that same structure. - n_sub: - number of voltage levels / substations. If not provided, defaults to one - substation per PFΔ bus (`n_busbar_per_sub` is then forced to 1). n_busbar_per_sub: - max number of buses allowed per substation / voltage level. + There is always exactly one substation / voltage level per PFΔ bus (PFΔ has no + notion of several busbar sections within a bus, so this is not configurable). + This parameter only controls how many buses / busbar sections lightsim2grid + allocates *per substation*, which is useful if you intend to perform grid2op-like + topology actions on the resulting grid afterwards. Defaults to 1 (no extra busbar + section). Any extra busbar section is deactivated, since nothing in the base + PFΔ row is ever connected to it. Passed through directly to + `lightsim2grid.network.init_from_matpower`. Returns ------- @@ -60,4 +63,4 @@ def init(row: Union[dict, str, "os.PathLike"], f"got a dict with keys {sorted(row.keys())}.") mpc = network_to_mpc(row["network"]) - return _init_from_matpower(mpc, n_sub=n_sub, n_busbar_per_sub=n_busbar_per_sub) + return _init_from_matpower(mpc, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/tests/debug_gen_pf_delta_fixture.py b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py index c5c3cfe4..5ca607b8 100644 --- a/lightsim2grid/tests/debug_gen_pf_delta_fixture.py +++ b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py @@ -119,7 +119,13 @@ def build_pf_delta_row(net) -> dict: if __name__ == "__main__": net = pn.case14() - pp.runpp(net, numba=False) + # trafo_model="pi": pandapower defaults to the more physically-accurate "t" + # (T-equivalent) transformer model, which does not in general produce the same + # per-unit branch parameters as MATPOWER/PowerModels' single-pi-model convention + # (they only happen to coincide when a transformer has no iron losses / no series + # resistance, as is the case for plain case14). Forcing "pi" here keeps this + # fixture representative of what a real MATPOWER-derived PFΔ case looks like. + pp.runpp(net, numba=False, trafo_model="pi") row = build_pf_delta_row(net) out_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") From 5de56a58b3b2dc38d09b8bee890929b5394bd9f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:35:59 +0000 Subject: [PATCH 028/166] Adapt to matpower loader review changes; use pi trafo model for fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - from_matpower.init dropped its n_sub parameter (there is always exactly one substation per matpower/PowerModels bus); mirror that in init_from_pf_delta and drop the pass-through argument. - debug_gen_pf_delta_fixture.py now solves with pandapower's trafo_model="pi" instead of its default "t" (T-equivalent) model: the two only coincide when a transformer has no iron losses / series resistance, which happens to be true for case14's transformers but is not true for MATPOWER-derived cases in general, so "pi" is the representative choice for a PFΔ-schema fixture. The committed pf_delta_case14.json fixture is byte-identical after regenerating with this flag (case14's transformers have zero iron loss / resistance), so no fixture update was needed, but the script now matches MATPOWER's convention explicitly rather than by coincidence. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- lightsim2grid/network/from_pf_delta/initLSGrid.py | 15 +++++++++------ lightsim2grid/tests/debug_gen_pf_delta_fixture.py | 8 +++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lightsim2grid/network/from_pf_delta/initLSGrid.py b/lightsim2grid/network/from_pf_delta/initLSGrid.py index 4127d896..170ed8a5 100644 --- a/lightsim2grid/network/from_pf_delta/initLSGrid.py +++ b/lightsim2grid/network/from_pf_delta/initLSGrid.py @@ -27,7 +27,6 @@ def init(row: Union[dict, str, "os.PathLike"], - n_sub: Optional[int] = None, # number of voltage levels n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level ) -> LSGrid: """ @@ -39,11 +38,15 @@ def init(row: Union[dict, str, "os.PathLike"], Either a PFΔ row already parsed into a dict (with a top-level ``"network"`` key), or a path (str or `os.PathLike`) to a ``.json`` file containing that same structure. - n_sub: - number of voltage levels / substations. If not provided, defaults to one - substation per PFΔ bus (`n_busbar_per_sub` is then forced to 1). n_busbar_per_sub: - max number of buses allowed per substation / voltage level. + There is always exactly one substation / voltage level per PFΔ bus (PFΔ has no + notion of several busbar sections within a bus, so this is not configurable). + This parameter only controls how many buses / busbar sections lightsim2grid + allocates *per substation*, which is useful if you intend to perform grid2op-like + topology actions on the resulting grid afterwards. Defaults to 1 (no extra busbar + section). Any extra busbar section is deactivated, since nothing in the base + PFΔ row is ever connected to it. Passed through directly to + `lightsim2grid.network.init_from_matpower`. Returns ------- @@ -60,4 +63,4 @@ def init(row: Union[dict, str, "os.PathLike"], f"got a dict with keys {sorted(row.keys())}.") mpc = network_to_mpc(row["network"]) - return _init_from_matpower(mpc, n_sub=n_sub, n_busbar_per_sub=n_busbar_per_sub) + return _init_from_matpower(mpc, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/tests/debug_gen_pf_delta_fixture.py b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py index c5c3cfe4..5ca607b8 100644 --- a/lightsim2grid/tests/debug_gen_pf_delta_fixture.py +++ b/lightsim2grid/tests/debug_gen_pf_delta_fixture.py @@ -119,7 +119,13 @@ def build_pf_delta_row(net) -> dict: if __name__ == "__main__": net = pn.case14() - pp.runpp(net, numba=False) + # trafo_model="pi": pandapower defaults to the more physically-accurate "t" + # (T-equivalent) transformer model, which does not in general produce the same + # per-unit branch parameters as MATPOWER/PowerModels' single-pi-model convention + # (they only happen to coincide when a transformer has no iron losses / no series + # resistance, as is the case for plain case14). Forcing "pi" here keeps this + # fixture representative of what a real MATPOWER-derived PFΔ case looks like. + pp.runpp(net, numba=False, trafo_model="pi") row = build_pf_delta_row(net) out_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") From 29b276623e64b1ce1f94c193c40f19c22bb023a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:42:04 +0000 Subject: [PATCH 029/166] Convert PowerModels dcline entries instead of rejecting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_matpower gained mpc.dcline (HVDC) support; mirror that here rather than unconditionally rejecting a PFΔ row containing a "dcline" entry. The translation only has to reproduce the columns from_matpower's own converter actually reads (f_bus, t_bus, br_status, pf, loss0, loss1, vf, vt, qminf/qmaxf, qmint/qmaxt) -- pt/qf/qt/pmin/pmax are parsed by PowerModels.jl but unused downstream. "pf" keeps matpower's own sign in the PowerModels dict (only pt/qf/qt get sign-flipped by PowerModels.jl's parser), so it is passed straight through unchanged; from_matpower's own converter is what negates it to match lightsim2grid's "received at side 1" convention. None of PFΔ's own pglib-derived cases (case14/30/57/118/500/2000) actually contain a dcline, so this is verified with a synthetic 3-bus network in test_LSGrid_pf_delta.py (mirroring test_init_from_matpower.py's own dcline tests): conversion, the matpower loss formula, and the deactivated-dcline path. storage remains rejected (from_matpower has no storage container). Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 12 ++-- .../network/from_pf_delta/_pf_delta_to_mpc.py | 40 +++++++++++-- lightsim2grid/tests/test_LSGrid_pf_delta.py | 58 +++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c985b0e4..df8ca0e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -324,11 +324,13 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. with a solved power-flow state attached). Accepts either a parsed row (dict) or a path to its `.json` file. Implemented as a thin translation layer on top of `init_from_matpower`: the PFΔ row's PowerModels dict is converted into a raw - MATPOWER-shaped `bus`/`gen`/`branch` array triple and delegated to it, reusing its - bus-id remapping, line/transformer splitting, generator/load/shunt conversion and - slack-bus handling. Tested in `test_LSGrid_pf_delta.py`, including an end-to-end - check that lightsim2grid's own AC powerflow reproduces a row's solved `vm`/`va`/ - `pf`/`qf`/`pt`/`qt` to solver tolerance. + MATPOWER-shaped `bus`/`gen`/`branch` (and, if present, `dcline`) array set and + delegated to it, reusing its bus-id remapping, line/transformer splitting, + generator/load/shunt/HVDC conversion and slack-bus handling. Tested in + `test_LSGrid_pf_delta.py`, including an end-to-end check that lightsim2grid's own AC + powerflow reproduces a row's solved `vm`/`va`/`pf`/`qf`/`pt`/`qt` to solver tolerance, + and a synthetic-network check of the `"dcline"` translation (PFΔ's own pglib-derived + cases never contain one, but the schema and `from_matpower` both support it). [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py index 09a36994..b0eeda8b 100644 --- a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py +++ b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py @@ -12,7 +12,9 @@ BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, BASE_KV, ZONE, VMAX, VMIN, GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, - MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, + DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, + DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1, + MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, ) @@ -37,10 +39,9 @@ def network_to_mpc(network: dict) -> dict: by tests that need to map a built `LSGrid`'s element positions back to the original PFΔ row's bus / gen / branch keys. """ - for unsupported in ("dcline", "storage"): - if network.get(unsupported): - raise RuntimeError(f'"{unsupported}" is present in this PFΔ row but is not ' - f"supported by this loader.") + if network.get("storage"): + raise RuntimeError('"storage" is present in this PFΔ row but is not supported ' + "by this loader (from_matpower has no storage container either).") if network.get("multinetwork"): raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") @@ -126,4 +127,31 @@ def network_to_mpc(network: dict) -> dict: branch[i, SHIFT] = np.rad2deg(br.get("shift", 0.0)) branch[i, BR_STATUS] = br.get("br_status", 1) - return {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} + mpc = {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} + + if network.get("dcline"): + dcline_keys = sorted(network["dcline"], key=int) + dcline = np.zeros((len(dcline_keys), MIN_DCLINE_COLS)) + for i, k in enumerate(dcline_keys): + dc = network["dcline"][k] + dcline[i, DC_F_BUS] = int(dc["f_bus"]) + dcline[i, DC_T_BUS] = int(dc["t_bus"]) + dcline[i, DC_BR_STATUS] = dc.get("br_status", 1) + # PowerModels keeps "pf" at matpower's own value/sign (only "pt"/"qf"/"qt" + # get sign-flipped by PowerModels.jl's parser); from_matpower's own dcline + # converter is the one that negates it to match lightsim2grid's "received + # at side 1" convention, so it must be passed through here unchanged. + dcline[i, DC_PF] = dc["pf"] + dcline[i, DC_VF] = dc.get("vf", 1.0) + dcline[i, DC_VT] = dc.get("vt", 1.0) + dcline[i, DC_QMINF] = dc["qminf"] + dcline[i, DC_QMAXF] = dc["qmaxf"] + dcline[i, DC_QMINT] = dc["qmint"] + dcline[i, DC_QMAXT] = dc["qmaxt"] + dcline[i, DC_LOSS0] = dc["loss0"] + dcline[i, DC_LOSS1] = dc["loss1"] + # DC_PT / DC_QF / DC_QT / DC_PMIN / DC_PMAX are not read by + # from_matpower's dcline converter (see _aux_add_dc_line.py) -- left at 0.0 + mpc["dcline"] = dcline + + return mpc diff --git a/lightsim2grid/tests/test_LSGrid_pf_delta.py b/lightsim2grid/tests/test_LSGrid_pf_delta.py index 7b61962c..ad7a9f9b 100644 --- a/lightsim2grid/tests/test_LSGrid_pf_delta.py +++ b/lightsim2grid/tests/test_LSGrid_pf_delta.py @@ -148,6 +148,64 @@ def test_shift_is_converted_from_radians_to_degrees(self): np.testing.assert_allclose(mpc["branch"][0, 9], 30.0) # SHIFT column, degrees +class TestPFDeltaDCLine(unittest.TestCase): + """PFΔ's own pglib-derived cases (case14/30/57/118/500/2000) never contain a + `"dcline"` entry, but PowerModels' schema supports one and `from_matpower` now + converts it (`model.init_dclines`), so this exercises that translation path + directly rather than leaving it silently unsupported.""" + + @staticmethod + def _network_with_dcline(): + return { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}, + "3": {"bus_i": 3, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 100.0, "qmin": -100.0, + "vg": 1.0, "pmax": 200.0, "pmin": 0.0, "gen_status": 1}}, + "load": {"1": {"load_bus": 2, "pd": 10.0, "qd": 2.0, "status": 1}, + "2": {"load_bus": 3, "pd": 5.0, "qd": 1.0, "status": 1}}, + "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, "br_status": 1}, + "2": {"f_bus": 2, "t_bus": 3, "br_r": 0.01, "br_x": 0.1, "br_status": 1}}, + # PowerModels keeps "pf" at matpower's own sign, but negates "pt"/"qf"/"qt"; + # only f_bus/t_bus/br_status/pf/loss0/loss1/vf/vt/qmin*/qmax* are actually + # consumed by from_matpower's converter (see _aux_add_dc_line.py) + "dcline": {"1": {"f_bus": 2, "t_bus": 3, "pf": 10.0, "pt": -9.0, "qf": 0.0, "qt": 0.0, + "vf": 1.0, "vt": 1.0, "qminf": -10.0, "qmaxf": 10.0, + "qmint": -10.0, "qmaxt": 10.0, "loss0": 0.5, "loss1": 0.05, + "br_status": 1}}, + } + + def test_no_dcline_key_at_all(self): + network = self._network_with_dcline() + del network["dcline"] + model = init_from_pf_delta({"network": network}) + assert len(model.get_dclines()) == 0 + + def test_dcline_is_converted_and_matches_matpower_loss_formula(self): + model = init_from_pf_delta({"network": self._network_with_dcline()}) + dclines = model.get_dclines() + assert len(dclines) == 1 + # matpower's PF (10 MW, "from" -> "to") maps to lightsim2grid's "power received + # at side 1" convention, so it must be negated (same as the pandapower loader) + assert abs(dclines[0].target_p1_mw - (-10.0)) <= 1e-8 + + Vfinal = model.ac_pf(np.full(3, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + dclines = model.get_dclines() + # PT = PF - (LOSS0 + LOSS1 * PF) = 10 - (0.5 + 0.05 * 10) = 9.0 + assert abs(dclines[0].res_p1_mw - (-10.0)) <= 1e-4 + assert abs(dclines[0].res_p2_mw - 9.0) <= 1e-4 + + def test_deactivated_dcline(self): + network = self._network_with_dcline() + network["dcline"]["1"]["br_status"] = 0 + model = init_from_pf_delta({"network": network}) + Vfinal = model.ac_pf(np.full(3, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + class TestLSGridPFDelta(BaseTests, unittest.TestCase): pass From 21b72d4e8b9eaf88134e3e53f356a53f2ee35a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:42:04 +0000 Subject: [PATCH 030/166] Convert PowerModels dcline entries instead of rejecting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_matpower gained mpc.dcline (HVDC) support; mirror that here rather than unconditionally rejecting a PFΔ row containing a "dcline" entry. The translation only has to reproduce the columns from_matpower's own converter actually reads (f_bus, t_bus, br_status, pf, loss0, loss1, vf, vt, qminf/qmaxf, qmint/qmaxt) -- pt/qf/qt/pmin/pmax are parsed by PowerModels.jl but unused downstream. "pf" keeps matpower's own sign in the PowerModels dict (only pt/qf/qt get sign-flipped by PowerModels.jl's parser), so it is passed straight through unchanged; from_matpower's own converter is what negates it to match lightsim2grid's "received at side 1" convention. None of PFΔ's own pglib-derived cases (case14/30/57/118/500/2000) actually contain a dcline, so this is verified with a synthetic 3-bus network in test_LSGrid_pf_delta.py (mirroring test_init_from_matpower.py's own dcline tests): conversion, the matpower loss formula, and the deactivated-dcline path. storage remains rejected (from_matpower has no storage container). Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 12 ++-- .../network/from_pf_delta/_pf_delta_to_mpc.py | 40 +++++++++++-- lightsim2grid/tests/test_LSGrid_pf_delta.py | 58 +++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c985b0e4..df8ca0e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -324,11 +324,13 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. with a solved power-flow state attached). Accepts either a parsed row (dict) or a path to its `.json` file. Implemented as a thin translation layer on top of `init_from_matpower`: the PFΔ row's PowerModels dict is converted into a raw - MATPOWER-shaped `bus`/`gen`/`branch` array triple and delegated to it, reusing its - bus-id remapping, line/transformer splitting, generator/load/shunt conversion and - slack-bus handling. Tested in `test_LSGrid_pf_delta.py`, including an end-to-end - check that lightsim2grid's own AC powerflow reproduces a row's solved `vm`/`va`/ - `pf`/`qf`/`pt`/`qt` to solver tolerance. + MATPOWER-shaped `bus`/`gen`/`branch` (and, if present, `dcline`) array set and + delegated to it, reusing its bus-id remapping, line/transformer splitting, + generator/load/shunt/HVDC conversion and slack-bus handling. Tested in + `test_LSGrid_pf_delta.py`, including an end-to-end check that lightsim2grid's own AC + powerflow reproduces a row's solved `vm`/`va`/`pf`/`qf`/`pt`/`qt` to solver tolerance, + and a synthetic-network check of the `"dcline"` translation (PFΔ's own pglib-derived + cases never contain one, but the schema and `from_matpower` both support it). [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py index 09a36994..b0eeda8b 100644 --- a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py +++ b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py @@ -12,7 +12,9 @@ BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, BASE_KV, ZONE, VMAX, VMIN, GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, - MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, + DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, + DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1, + MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, ) @@ -37,10 +39,9 @@ def network_to_mpc(network: dict) -> dict: by tests that need to map a built `LSGrid`'s element positions back to the original PFΔ row's bus / gen / branch keys. """ - for unsupported in ("dcline", "storage"): - if network.get(unsupported): - raise RuntimeError(f'"{unsupported}" is present in this PFΔ row but is not ' - f"supported by this loader.") + if network.get("storage"): + raise RuntimeError('"storage" is present in this PFΔ row but is not supported ' + "by this loader (from_matpower has no storage container either).") if network.get("multinetwork"): raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") @@ -126,4 +127,31 @@ def network_to_mpc(network: dict) -> dict: branch[i, SHIFT] = np.rad2deg(br.get("shift", 0.0)) branch[i, BR_STATUS] = br.get("br_status", 1) - return {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} + mpc = {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} + + if network.get("dcline"): + dcline_keys = sorted(network["dcline"], key=int) + dcline = np.zeros((len(dcline_keys), MIN_DCLINE_COLS)) + for i, k in enumerate(dcline_keys): + dc = network["dcline"][k] + dcline[i, DC_F_BUS] = int(dc["f_bus"]) + dcline[i, DC_T_BUS] = int(dc["t_bus"]) + dcline[i, DC_BR_STATUS] = dc.get("br_status", 1) + # PowerModels keeps "pf" at matpower's own value/sign (only "pt"/"qf"/"qt" + # get sign-flipped by PowerModels.jl's parser); from_matpower's own dcline + # converter is the one that negates it to match lightsim2grid's "received + # at side 1" convention, so it must be passed through here unchanged. + dcline[i, DC_PF] = dc["pf"] + dcline[i, DC_VF] = dc.get("vf", 1.0) + dcline[i, DC_VT] = dc.get("vt", 1.0) + dcline[i, DC_QMINF] = dc["qminf"] + dcline[i, DC_QMAXF] = dc["qmaxf"] + dcline[i, DC_QMINT] = dc["qmint"] + dcline[i, DC_QMAXT] = dc["qmaxt"] + dcline[i, DC_LOSS0] = dc["loss0"] + dcline[i, DC_LOSS1] = dc["loss1"] + # DC_PT / DC_QF / DC_QT / DC_PMIN / DC_PMAX are not read by + # from_matpower's dcline converter (see _aux_add_dc_line.py) -- left at 0.0 + mpc["dcline"] = dcline + + return mpc diff --git a/lightsim2grid/tests/test_LSGrid_pf_delta.py b/lightsim2grid/tests/test_LSGrid_pf_delta.py index 7b61962c..ad7a9f9b 100644 --- a/lightsim2grid/tests/test_LSGrid_pf_delta.py +++ b/lightsim2grid/tests/test_LSGrid_pf_delta.py @@ -148,6 +148,64 @@ def test_shift_is_converted_from_radians_to_degrees(self): np.testing.assert_allclose(mpc["branch"][0, 9], 30.0) # SHIFT column, degrees +class TestPFDeltaDCLine(unittest.TestCase): + """PFΔ's own pglib-derived cases (case14/30/57/118/500/2000) never contain a + `"dcline"` entry, but PowerModels' schema supports one and `from_matpower` now + converts it (`model.init_dclines`), so this exercises that translation path + directly rather than leaving it silently unsupported.""" + + @staticmethod + def _network_with_dcline(): + return { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}, + "3": {"bus_i": 3, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 100.0, "qmin": -100.0, + "vg": 1.0, "pmax": 200.0, "pmin": 0.0, "gen_status": 1}}, + "load": {"1": {"load_bus": 2, "pd": 10.0, "qd": 2.0, "status": 1}, + "2": {"load_bus": 3, "pd": 5.0, "qd": 1.0, "status": 1}}, + "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, "br_status": 1}, + "2": {"f_bus": 2, "t_bus": 3, "br_r": 0.01, "br_x": 0.1, "br_status": 1}}, + # PowerModels keeps "pf" at matpower's own sign, but negates "pt"/"qf"/"qt"; + # only f_bus/t_bus/br_status/pf/loss0/loss1/vf/vt/qmin*/qmax* are actually + # consumed by from_matpower's converter (see _aux_add_dc_line.py) + "dcline": {"1": {"f_bus": 2, "t_bus": 3, "pf": 10.0, "pt": -9.0, "qf": 0.0, "qt": 0.0, + "vf": 1.0, "vt": 1.0, "qminf": -10.0, "qmaxf": 10.0, + "qmint": -10.0, "qmaxt": 10.0, "loss0": 0.5, "loss1": 0.05, + "br_status": 1}}, + } + + def test_no_dcline_key_at_all(self): + network = self._network_with_dcline() + del network["dcline"] + model = init_from_pf_delta({"network": network}) + assert len(model.get_dclines()) == 0 + + def test_dcline_is_converted_and_matches_matpower_loss_formula(self): + model = init_from_pf_delta({"network": self._network_with_dcline()}) + dclines = model.get_dclines() + assert len(dclines) == 1 + # matpower's PF (10 MW, "from" -> "to") maps to lightsim2grid's "power received + # at side 1" convention, so it must be negated (same as the pandapower loader) + assert abs(dclines[0].target_p1_mw - (-10.0)) <= 1e-8 + + Vfinal = model.ac_pf(np.full(3, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + dclines = model.get_dclines() + # PT = PF - (LOSS0 + LOSS1 * PF) = 10 - (0.5 + 0.05 * 10) = 9.0 + assert abs(dclines[0].res_p1_mw - (-10.0)) <= 1e-4 + assert abs(dclines[0].res_p2_mw - 9.0) <= 1e-4 + + def test_deactivated_dcline(self): + network = self._network_with_dcline() + network["dcline"]["1"]["br_status"] = 0 + model = init_from_pf_delta({"network": network}) + Vfinal = model.ac_pf(np.full(3, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + class TestLSGridPFDelta(BaseTests, unittest.TestCase): pass From 98e85b222ce8ac3acf07f2c33401c986ab0e32d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 02:58:03 +0000 Subject: [PATCH 031/166] Invert loader dependency: from_matpower now delegates to from_powermodels Per review feedback: PowerModels.jl's network data format is strictly richer than raw MATPOWER (separate load/shunt/storage tables, independent per-side line charging, no "tap == 0" line/transformer sentinel to work around), so building the grid from that richer representation should be the canonical path, with the poorer raw MATPOWER matrix format converted up to it -- not the other way around as this PR originally had it. - New lightsim2grid/network/from_powermodels/ package: a feature-complete, dict-based engine (init(network) -> LSGrid) covering bus, branch (line/transformer split, now with independent per-side g_fr/b_fr and g_to/b_to for lines -- from_matpower's raw mpc.branch format could only represent a single, forced-symmetric charging value), gen, load, shunt, **storage** (new: from_matpower has no storage table at all, even though lightsim2grid itself supports it) and dcline (HVDC), including distributed-slack handling. Renamed from "from_pf_delta" since the format is PowerModels, not PFDelta-specific, per review. - from_matpower/initLSGrid.py now only parses the raw mpc matrices and converts them into a PowerModels-style dict (_mpc_to_powermodels.py, the verified inverse of PowerModels.jl's own matpower parser), then delegates to from_powermodels.init. Its own public behavior/signature is unchanged -- verified against its existing 19-test suite, which passes unmodified. - from_powermodels/_from_pfdelta.py holds the PFDelta-row-specific bit (unwrapping row["network"]): init_from_pf_delta stays the public entry point name for that, now implemented as a thin wrapper around init_from_powermodels. - Also exposes lightsim2grid.network.init_from_powermodels directly, for anyone with a bare PowerModels dict (not a PFDelta row). Test suite grown accordingly (test_LSGrid_pf_delta.py): storage conversion/deactivation, asymmetric line conductance, and a direct check that shift round-trips through the degrees/radians init_trafo boundary (via the newly exposed TrafoInfo.shift_rad), replacing the now-obsolete "reject non-zero g_fr" test since lines support it directly now. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 33 ++-- lightsim2grid/network/__init__.py | 4 +- .../network/from_matpower/_aux_add_branch.py | 76 --------- .../network/from_matpower/_aux_add_dc_line.py | 70 -------- .../network/from_matpower/_aux_add_gen.py | 55 ------ .../network/from_matpower/_aux_add_load.py | 42 ----- .../network/from_matpower/_aux_add_shunt.py | 52 ------ .../network/from_matpower/_aux_add_slack.py | 79 --------- .../network/from_matpower/_aux_check_legit.py | 32 ++-- .../from_matpower/_mp_bus_to_ls_bus.py | 47 ------ .../from_matpower/_mpc_to_powermodels.py | 117 +++++++++++++ .../network/from_matpower/initLSGrid.py | 96 ++--------- .../network/from_pf_delta/_pf_delta_to_mpc.py | 157 ------------------ .../network/from_pf_delta/initLSGrid.py | 66 -------- .../__init__.py | 3 +- .../from_powermodels/_aux_add_branch.py | 114 +++++++++++++ .../from_powermodels/_aux_add_dc_line.py | 72 ++++++++ .../network/from_powermodels/_aux_add_gen.py | 54 ++++++ .../network/from_powermodels/_aux_add_load.py | 44 +++++ .../from_powermodels/_aux_add_shunt.py | 56 +++++++ .../from_powermodels/_aux_add_slack.py | 78 +++++++++ .../from_powermodels/_aux_add_storage.py | 55 ++++++ .../from_powermodels/_aux_check_legit.py | 31 ++++ .../network/from_powermodels/_bus_remap.py | 48 ++++++ .../network/from_powermodels/_from_pfdelta.py | 53 ++++++ .../network/from_powermodels/_my_const.py | 11 ++ .../network/from_powermodels/initLSGrid.py | 138 +++++++++++++++ lightsim2grid/tests/test_LSGrid_pf_delta.py | 138 ++++++++++----- 28 files changed, 1023 insertions(+), 798 deletions(-) delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_branch.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_dc_line.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_gen.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_load.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_shunt.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_slack.py delete mode 100644 lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py create mode 100644 lightsim2grid/network/from_matpower/_mpc_to_powermodels.py delete mode 100644 lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py delete mode 100644 lightsim2grid/network/from_pf_delta/initLSGrid.py rename lightsim2grid/network/{from_pf_delta => from_powermodels}/__init__.py (85%) create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_branch.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_dc_line.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_gen.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_load.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_shunt.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_slack.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_storage.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_check_legit.py create mode 100644 lightsim2grid/network/from_powermodels/_bus_remap.py create mode 100644 lightsim2grid/network/from_powermodels/_from_pfdelta.py create mode 100644 lightsim2grid/network/from_powermodels/_my_const.py create mode 100644 lightsim2grid/network/from_powermodels/initLSGrid.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index df8ca0e0..57f81461 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -319,18 +319,27 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. Grids built from pandapower never populate these (getters return an empty array / NaN per element). Not wired into `LightSimBackend.thermal_limit_a`, which keeps its existing behaviour. -- [ADDED] `lightsim2grid.network.init_from_pf_delta`: builds an `LSGrid` from one row of - the PFΔ benchmark dataset (arXiv:2510.22048, a PowerModels.jl/MATPOWER-format dict - with a solved power-flow state attached). Accepts either a parsed row (dict) or a - path to its `.json` file. Implemented as a thin translation layer on top of - `init_from_matpower`: the PFΔ row's PowerModels dict is converted into a raw - MATPOWER-shaped `bus`/`gen`/`branch` (and, if present, `dcline`) array set and - delegated to it, reusing its bus-id remapping, line/transformer splitting, - generator/load/shunt/HVDC conversion and slack-bus handling. Tested in - `test_LSGrid_pf_delta.py`, including an end-to-end check that lightsim2grid's own AC - powerflow reproduces a row's solved `vm`/`va`/`pf`/`qf`/`pt`/`qt` to solver tolerance, - and a synthetic-network check of the `"dcline"` translation (PFΔ's own pglib-derived - cases never contain one, but the schema and `from_matpower` both support it). +- [ADDED] `lightsim2grid.network.init_from_powermodels`: a native, feature-complete + loader that builds an `LSGrid` directly from a PowerModels.jl network data + dictionary (see https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/): + bus, branch (line/transformer split), gen, load, shunt, **storage** and dcline + (HVDC), including distributed-slack handling. Since PowerModels' dict is strictly + richer than raw MATPOWER (separate load/shunt/storage tables, independent per-side + line charging, no `tap == 0` sentinel to work around), this is now the shared engine + other loaders build on: `init_from_matpower` converts its raw `bus`/`gen`/`branch`/ + `dcline` matrices into a PowerModels-style dict and delegates here (its own public + behavior is unchanged; verified against its existing test suite), rather than the + other way around. +- [ADDED] `lightsim2grid.network.init_from_pf_delta`: builds an `LSGrid` from one row + of the PFΔ benchmark dataset (arXiv:2510.22048, a PowerModels.jl-format dict with a + solved power-flow state attached). Accepts either a parsed row (dict) or a path to + its `.json` file; a thin wrapper unwrapping the row's `"network"` key and calling + `init_from_powermodels` directly, since PFΔ rows already are PowerModels dicts. + Tested in `test_LSGrid_pf_delta.py`, including an end-to-end check that + lightsim2grid's own AC powerflow reproduces a row's solved `vm`/`va`/`pf`/`qf`/`pt`/ + `qt` to solver tolerance, and synthetic-network checks of the `"storage"` and + `"dcline"` conversions (PFΔ's own pglib-derived cases never contain either, but the + schema and `init_from_powermodels` both support them). [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index da006533..4c5ea4f3 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -43,7 +43,9 @@ # a ".m"/".mat" file pass -from lightsim2grid.network.from_pf_delta import init as init_from_pf_delta # noqa +from lightsim2grid.network.from_powermodels import init as init_from_powermodels # noqa +from lightsim2grid.network.from_powermodels import init_from_pfdelta as init_from_pf_delta # noqa +__all__.append("init_from_powermodels") __all__.append("init_from_pf_delta") try: diff --git a/lightsim2grid/network/from_matpower/_aux_add_branch.py b/lightsim2grid/network/from_matpower/_aux_add_branch.py deleted file mode 100644 index 20bbca73..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_branch.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import F_BUS, T_BUS, BR_R, BR_X, BR_B, TAP, SHIFT, BR_STATUS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def get_branch_split(branch): - """A branch row is a transformer if and only if its `TAP` column is non zero, - this is matpower's own convention (see e.g. `makeYbus.m`). `TAP == 0` means - "plain line, ratio 1.0". - """ - return branch[:, TAP] != 0. - - -def _aux_add_branch(model, branch, is_trafo, mp_to_ls, isolated_ls_bus): - """ - Add the lines and transformers of `mpc.branch` into the lightsim2grid "model". - - Parameters - ---------- - model - branch: numpy array - raw `mpc.branch` matrix - is_trafo: numpy bool array - for each row of `branch`, whether it should be treated as a transformer - (see `get_branch_split`) - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - is_line = ~is_trafo - from_bus = mp_bus_to_ls(branch[:, F_BUS], mp_to_ls) - to_bus = mp_bus_to_ls(branch[:, T_BUS], mp_to_ls) - - # powerlines - line_r = branch[is_line, BR_R] - line_x = branch[is_line, BR_X] - line_h = 1j * branch[is_line, BR_B] - model.init_powerlines(line_r, line_x, line_h, from_bus[is_line], to_bus[is_line]) - - line_status = branch[is_line, BR_STATUS] != 0 - if isolated_ls_bus.size: - line_status &= ~np.isin(from_bus[is_line], isolated_ls_bus) - line_status &= ~np.isin(to_bus[is_line], isolated_ls_bus) - for line_id, is_ok in enumerate(line_status): - if not is_ok: - model.deactivate_powerline(line_id) - - # transformers - n_trafo = int(is_trafo.sum()) - trafo_r = branch[is_trafo, BR_R] - trafo_x = branch[is_trafo, BR_X] - trafo_b = 1j * branch[is_trafo, BR_B] - trafo_ratio = branch[is_trafo, TAP] - trafo_shift_degree = branch[is_trafo, SHIFT] - trafo_tap_hv = [True] * n_trafo - model.init_trafo(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, - from_bus[is_trafo], to_bus[is_trafo], True) - - trafo_status = branch[is_trafo, BR_STATUS] != 0 - if isolated_ls_bus.size: - trafo_status &= ~np.isin(from_bus[is_trafo], isolated_ls_bus) - trafo_status &= ~np.isin(to_bus[is_trafo], isolated_ls_bus) - for trafo_id, is_ok in enumerate(trafo_status): - if not is_ok: - model.deactivate_trafo(trafo_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_dc_line.py b/lightsim2grid/network/from_matpower/_aux_add_dc_line.py deleted file mode 100644 index 57f90130..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_dc_line.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import (DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, - DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1) -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_dc_line(model, dcline, mp_to_ls, isolated_ls_bus): - """ - Add the HVDC lines described by `mpc.dcline` into the lightsim2grid "model", using - the same simplified (station-loss-free, resistance-free) dc line model already used - by lightsim2grid's pandapower loader (`model.init_dclines`). - - Note - ---- - Matpower's `PF` is the MW flow "from" -> "to" measured at the `F_BUS` end (positive - means the `F_BUS` side sends power into the line), which is the same convention as - pandapower's dcline `p_mw`. `model.init_dclines` instead expects the power *received* - at side 1 (the `F_BUS` side) when positive, so `PF` must be negated, exactly as - lightsim2grid's pandapower loader already does for pandapower's `p_mw`. - - Matpower's loss model is `PT = PF - (LOSS0 + LOSS1 * PF)` with `LOSS1` a fraction of - `PF`, while lightsim2grid/pandapower's `loss_percent` expresses that same coefficient - as a percentage (`LOSS1 * 100`) and `loss_mw` is `LOSS0` directly. - - `PMIN` / `PMAX` (limits on `PF`) have no equivalent in `model.init_dclines` (this - simplified model does not enforce them), consistent with how the pandapower loader - already ignores pandapower's own dcline power limits. - - Parameters - ---------- - model - dcline: numpy array - raw `mpc.dcline` matrix (can have 0 rows: most matpower cases have no HVDC line) - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - if dcline.shape[0] == 0: - return - - from_bus = mp_bus_to_ls(dcline[:, DC_F_BUS], mp_to_ls) - to_bus = mp_bus_to_ls(dcline[:, DC_T_BUS], mp_to_ls) - - p_mw = -dcline[:, DC_PF] - loss_percent = 100. * dcline[:, DC_LOSS1] - loss_mw = dcline[:, DC_LOSS0] - - model.init_dclines(from_bus, to_bus, p_mw, loss_percent, loss_mw, - dcline[:, DC_VF], dcline[:, DC_VT], - dcline[:, DC_QMINF], dcline[:, DC_QMAXF], - dcline[:, DC_QMINT], dcline[:, DC_QMAXT]) - - dc_status = dcline[:, DC_BR_STATUS] != 0 - if isolated_ls_bus.size: - dc_status &= ~np.isin(from_bus, isolated_ls_bus) - dc_status &= ~np.isin(to_bus, isolated_ls_bus) - for dc_id, is_ok in enumerate(dc_status): - if not is_ok: - model.deactivate_dcline(dc_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_gen.py b/lightsim2grid/network/from_matpower/_aux_add_gen.py deleted file mode 100644 index 20aae605..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_gen.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import GEN_BUS, PG, QG, QMAX, QMIN, VG, GEN_STATUS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_gen(model, gen, mp_to_ls, isolated_ls_bus): - """ - Add the generators of `mpc.gen` into the lightsim2grid "model". - - Note - ---- - Several rows of `mpc.gen` can share the same `GEN_BUS`: they are all kept as - independent generators (no aggregation), which is the entire point of this - native matpower loader -- lightsim2grid's `LSGrid` has no uniqueness constraint - on a generator's bus id (`from_pypowsybl` already relies on this for grids with - several generators on the same bus). - - Matpower has no column equivalent to lightsim2grid's `voltage_regulator_on`: every - generator is assumed to regulate voltage (standard powerflow convention), which is - the modeling assumption made here. - - Parameters - ---------- - model - gen: numpy array - raw `mpc.gen` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - if gen.shape[0] == 0: - return - - gen_bus = mp_bus_to_ls(gen[:, GEN_BUS], mp_to_ls) - voltage_regulator_on = [True] * gen.shape[0] - model.init_generators_full(gen[:, PG], gen[:, VG], gen[:, QG], voltage_regulator_on, - gen[:, QMIN], gen[:, QMAX], gen_bus) - - gen_status = gen[:, GEN_STATUS] > 0 - if isolated_ls_bus.size: - gen_status &= ~np.isin(gen_bus, isolated_ls_bus) - for gen_id, is_ok in enumerate(gen_status): - if not is_ok: - model.deactivate_gen(gen_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_load.py b/lightsim2grid/network/from_matpower/_aux_add_load.py deleted file mode 100644 index ebea5582..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_load.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import BUS_I, PD, QD -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_load(model, bus, mp_to_ls, isolated_ls_bus): - """ - Add the loads described by the `PD` / `QD` columns of `mpc.bus` into the - lightsim2grid "model". Matpower has no separate load table: a load is only - created for buses with a non zero `PD` or `QD`. - - Parameters - ---------- - model - bus: numpy array - raw `mpc.bus` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - has_load = (bus[:, PD] != 0.) | (bus[:, QD] != 0.) - if not np.any(has_load): - return - - load_bus = mp_bus_to_ls(bus[has_load, BUS_I], mp_to_ls) - model.init_loads(bus[has_load, PD], bus[has_load, QD], load_bus) - - if isolated_ls_bus.size: - for load_id, is_isolated in enumerate(np.isin(load_bus, isolated_ls_bus)): - if is_isolated: - model.deactivate_load(load_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_shunt.py b/lightsim2grid/network/from_matpower/_aux_add_shunt.py deleted file mode 100644 index 2e8fc549..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_shunt.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import BUS_I, GS, BS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_shunt(model, bus, mp_to_ls, isolated_ls_bus): - """ - Add the shunts described by the `GS` / `BS` columns of `mpc.bus` into the - lightsim2grid "model". Matpower has no separate shunt table: a shunt is only - created for buses with a non zero `GS` or `BS`. - - Note - ---- - Matpower's `GS` is "shunt conductance (MW **demanded** at V = 1.0 p.u.)" (load - convention, positive = consumed) while `BS` is "shunt susceptance (MVAr - **injected** at V = 1.0 p.u.)" (generator convention, positive = supplied) -- the - opposite sign convention from lightsim2grid/pandapower's `q_mvar` (load - convention, positive = consumed). `BS` must therefore be negated. This matches - pandapower's own matpower/ppc importer (`pandapower.converter.pypower.from_ppc`), - which does `q_mvar=-ppc["bus"][is_shunt, BS]`. - - Parameters - ---------- - model - bus: numpy array - raw `mpc.bus` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - has_shunt = (bus[:, GS] != 0.) | (bus[:, BS] != 0.) - if not np.any(has_shunt): - return - - shunt_bus = mp_bus_to_ls(bus[has_shunt, BUS_I], mp_to_ls) - model.init_shunt(bus[has_shunt, GS], -bus[has_shunt, BS], shunt_bus) - - if isolated_ls_bus.size: - for sh_id, is_isolated in enumerate(np.isin(shunt_bus, isolated_ls_bus)): - if is_isolated: - model.deactivate_shunt(sh_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_slack.py b/lightsim2grid/network/from_matpower/_aux_add_slack.py deleted file mode 100644 index fe08fa7d..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_slack.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import warnings -import numpy as np - -from ._my_const import BUS_I, BUS_TYPE, REF, GEN_BUS, GEN_STATUS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus): - """ - Resolve the slack bus(es) from `mpc.bus[:, BUS_TYPE] == 3` (the matpower reference - bus) and assign every in-service generator connected to it as a (possibly - distributed) slack generator, with equal weight. - - Note - ---- - Unlike pandapower (which has a separate `ext_grid` table to fall back on), matpower - has no fallback: if the reference bus has no in-service generator connected to it, - there is no slack source and a `RuntimeError` is raised. - - Several generators can be connected to the reference bus (this is precisely the - multi-generator-per-bus case this loader is designed to support): they are all - assigned as slack, each with weight 1.0 (equal-weight distributed slack), consistent - with the `add_gen_slackbus` weighting convention already used by the pandapower - loader. Matpower has no column to derive a different weighting from. - - Parameters - ---------- - model - bus: numpy array - raw `mpc.bus` matrix - gen: numpy array - raw `mpc.gen` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - ref_mask = bus[:, BUS_TYPE] == REF - n_ref = int(ref_mask.sum()) - if n_ref == 0: - warnings.warn("No bus with `BUS_TYPE == 3` (reference bus) found. lightsim2grid " - "could not assign any slack bus, you will need to do it manually " - "with `model.add_gen_slackbus(...)`.") - return - if n_ref > 1: - warnings.warn(f"{n_ref} buses found with `BUS_TYPE == 3` (reference bus), matpower " - f"normally expects a single one. All of them will be considered, " - f"with equal weights.") - - ref_bus_mp = bus[ref_mask, BUS_I] - ref_bus_ls = mp_bus_to_ls(ref_bus_mp, mp_to_ls) - - if gen.shape[0] == 0: - raise RuntimeError("No generator found at all, so no slack bus can be assigned.") - - gen_bus_ls = mp_bus_to_ls(gen[:, GEN_BUS], mp_to_ls) - gen_in_service = gen[:, GEN_STATUS] > 0 - if isolated_ls_bus.size: - gen_in_service = gen_in_service & ~np.isin(gen_bus_ls, isolated_ls_bus) - - is_slack_gen = np.isin(gen_bus_ls, ref_bus_ls) & gen_in_service - slack_gen_ids = np.where(is_slack_gen)[0] - if slack_gen_ids.size == 0: - raise RuntimeError(f"Could not find any in-service generator connected to the reference " - f"bus(es) {ref_bus_mp.tolist()}. Matpower has no separate slack-bus " - f"fallback (unlike pandapower's `ext_grid`): lightsim2grid needs at " - f"least one in-service generator at the reference bus to use as slack.") - - for gen_id in slack_gen_ids: - model.add_gen_slackbus(int(gen_id), 1.0) diff --git a/lightsim2grid/network/from_matpower/_aux_check_legit.py b/lightsim2grid/network/from_matpower/_aux_check_legit.py index f10fafe6..2f6aae01 100644 --- a/lightsim2grid/network/from_matpower/_aux_check_legit.py +++ b/lightsim2grid/network/from_matpower/_aux_check_legit.py @@ -6,25 +6,26 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -import warnings -import numpy as np - -from ._my_const import (MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, - TAP, SHIFT, BUS_TYPE, NONE) +from ._my_const import MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS def _aux_check_legit(bus, gen, branch, dcline): """ - Check that the raw matpower matrices can be handled by lightsim2grid. + Check that the raw matpower matrices have enough columns to be interpreted at all. + + Semantic checks that don't depend on the raw-array representation (isolated buses, + "weird" tap/shift combinations) are done once the matrices are converted to a + PowerModels-style dict, so they also cover the `from_powermodels`/ + `init_from_pfdelta` entry points -- see + `lightsim2grid.network.from_matpower._mpc_to_powermodels` (the `TAP == 0` / `SHIFT + != 0` warning) and `lightsim2grid.network.from_powermodels._aux_check_legit` (the + isolated-bus warning). Parameters ---------- bus, gen, branch, dcline: numpy arrays The raw matpower matrices, as returned by `_parse_matpower_source.load_matpower_data` - Returns - ------- - """ if bus.shape[1] < MIN_BUS_COLS: raise RuntimeError(f"`mpc.bus` should have at least {MIN_BUS_COLS} columns, found {bus.shape[1]}.") @@ -34,16 +35,3 @@ def _aux_check_legit(bus, gen, branch, dcline): raise RuntimeError(f"`mpc.branch` should have at least {MIN_BRANCH_COLS} columns, found {branch.shape[1]}.") if dcline.shape[0] and dcline.shape[1] < MIN_DCLINE_COLS: raise RuntimeError(f"`mpc.dcline` should have at least {MIN_DCLINE_COLS} columns, found {dcline.shape[1]}.") - - if branch.shape[0]: - is_line = branch[:, TAP] == 0. - weird_shift = is_line & (branch[:, SHIFT] != 0.) - if np.any(weird_shift): - warnings.warn(f"{weird_shift.sum()} branch(es) have `TAP == 0` (so are treated as a plain " - f"powerline by lightsim2grid) but a non-zero `SHIFT`, which is not physically " - f"meaningful for a plain line. The `SHIFT` will be ignored for these branches.") - - if bus.shape[0] and np.any(bus[:, BUS_TYPE] == NONE): - n_isolated = int((bus[:, BUS_TYPE] == NONE).sum()) - warnings.warn(f"{n_isolated} bus(es) have `BUS_TYPE == {NONE}` (isolated). They will be deactivated, " - f"along with any load, shunt, generator or branch side still connected to them.") diff --git a/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py b/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py deleted file mode 100644 index 88b172a6..00000000 --- a/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - - -def mp_bus_to_ls(mp_bus_id, mp_to_ls_converter): - """Map matpower bus numbers (arbitrary, not necessarily contiguous / 0-based) - to lightsim2grid contiguous 0-based bus ids, given the dict built by - `build_bus_remap`. - """ - return np.array([mp_to_ls_converter[mp_id] for mp_id in mp_bus_id], dtype=int) - - -def build_bus_remap(bus_i): - """ - Build the mapping from matpower bus number (`bus[:, BUS_I]`, arbitrary integers, - not necessarily contiguous / 0-based / sorted) to a contiguous 0-based lightsim2grid - bus id. - - Parameters - ---------- - bus_i: numpy array - The `BUS_I` column of `mpc.bus` (one entry per bus, in the original file order) - - Returns - ------- - mp_to_ls: dict - mapping from matpower bus number to lightsim2grid bus id - ls_to_orig: numpy array - for each lightsim2grid bus id, the original matpower bus number (useful to - keep on the model, mirroring `model._ls_to_orig` in the pandapower loader) - """ - bus_i = np.asarray(bus_i).astype(int) - if np.unique(bus_i).shape[0] != bus_i.shape[0]: - raise RuntimeError("Duplicated bus numbers found in `mpc.bus[:, BUS_I]`, " - "lightsim2grid cannot handle this.") - # buses keep the order they appear in mpc.bus (no need to sort: matpower bus - # numbers are opaque identifiers, not meaningful for ordering) - ls_to_orig = bus_i - mp_to_ls = {mp_id: ls_id for ls_id, mp_id in enumerate(bus_i)} - return mp_to_ls, ls_to_orig diff --git a/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py b/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py new file mode 100644 index 00000000..c69c74d0 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings + +import numpy as np + +from ._my_const import ( + BUS_I, BUS_TYPE, PD, QD, GS, BS, BASE_KV, VMAX, VMIN, + GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, + F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, TAP, SHIFT, BR_STATUS, + DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, + DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1, +) + + +def mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) -> dict: + """ + Convert raw MATPOWER-shaped `bus`/`gen`/`branch`/`dcline` matrices into a + PowerModels.jl-style network data dictionary (see + https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/), consumable by + `lightsim2grid.network.from_powermodels.init`. This is the (verified) inverse of + the conversion PowerModels.jl itself performs when parsing a matpower `.m` file + (`PowerModels.jl/src/io/matpower.jl`): loads/shunts are split off the bus rows, + `tap == 0` (matpower's "this is a plain line" sentinel) becomes `tap = 1.0` with + `transformer = False`, and angles are converted from degrees to radians. + """ + network = {"baseMVA": float(baseMVA), "bus": {}, "gen": {}, "branch": {}, "load": {}, "shunt": {}} + + for i in range(bus.shape[0]): + bus_i = int(bus[i, BUS_I]) + key = str(i + 1) + network["bus"][key] = { + "bus_i": bus_i, + "bus_type": int(bus[i, BUS_TYPE]), + "base_kv": float(bus[i, BASE_KV]), + "vmax": float(bus[i, VMAX]), + "vmin": float(bus[i, VMIN]), + } + pd, qd = float(bus[i, PD]), float(bus[i, QD]) + if pd != 0. or qd != 0.: + network["load"][key] = {"load_bus": bus_i, "pd": pd, "qd": qd, "status": 1} + gs, bs = float(bus[i, GS]), float(bus[i, BS]) + if gs != 0. or bs != 0.: + # matpower's GS/BS are MW/MVAr "at V=1pu"; PowerModels' gs/bs are per-unit + network["shunt"][key] = {"shunt_bus": bus_i, "gs": gs / baseMVA, "bs": bs / baseMVA, "status": 1} + + for i in range(gen.shape[0]): + key = str(i + 1) + network["gen"][key] = { + "gen_bus": int(gen[i, GEN_BUS]), + "pg": float(gen[i, PG]), + "qg": float(gen[i, QG]), + "qmax": float(gen[i, QMAX]), + "qmin": float(gen[i, QMIN]), + "vg": float(gen[i, VG]), + "mbase": float(gen[i, MBASE]), + "gen_status": int(gen[i, GEN_STATUS]), + "pmax": float(gen[i, PMAX]), + "pmin": float(gen[i, PMIN]), + } + + n_weird_shift = 0 + for i in range(branch.shape[0]): + key = str(i + 1) + tap = float(branch[i, TAP]) + transformer = tap != 0. + shift_degree = float(branch[i, SHIFT]) + if not transformer and shift_degree != 0.: + # matpower's own convention: a non-transformer (TAP == 0) branch has no + # meaningful phase shift; `from_matpower`'s legacy array-based loader + # silently dropped it too (it never passed SHIFT to `init_powerlines`) + n_weird_shift += 1 + shift_degree = 0. + network["branch"][key] = { + "f_bus": int(branch[i, F_BUS]), + "t_bus": int(branch[i, T_BUS]), + "br_r": float(branch[i, BR_R]), + "br_x": float(branch[i, BR_X]), + "g_fr": 0., "g_to": 0., # matpower has no line-conductance concept + "b_fr": float(branch[i, BR_B]) / 2., "b_to": float(branch[i, BR_B]) / 2., + "tap": tap if transformer else 1.0, + "shift": np.deg2rad(shift_degree), + "transformer": transformer, + "rate_a": float(branch[i, RATE_A]), + "br_status": int(branch[i, BR_STATUS]), + } + if n_weird_shift: + warnings.warn(f"{n_weird_shift} branch(es) have `TAP == 0` (so are treated as a plain " + "powerline) but a non-zero `SHIFT`, which is not physically meaningful " + "for a plain line. The `SHIFT` will be ignored for these branches.") + + if dcline.shape[0]: + network["dcline"] = {} + for i in range(dcline.shape[0]): + key = str(i + 1) + network["dcline"][key] = { + "f_bus": int(dcline[i, DC_F_BUS]), + "t_bus": int(dcline[i, DC_T_BUS]), + "br_status": int(dcline[i, DC_BR_STATUS]), + "pf": float(dcline[i, DC_PF]), # matpower keeps "pf"'s own sign, no flip needed + "vf": float(dcline[i, DC_VF]), + "vt": float(dcline[i, DC_VT]), + "qminf": float(dcline[i, DC_QMINF]), + "qmaxf": float(dcline[i, DC_QMAXF]), + "qmint": float(dcline[i, DC_QMINT]), + "qmaxt": float(dcline[i, DC_QMAXT]), + "loss0": float(dcline[i, DC_LOSS0]), + "loss1": float(dcline[i, DC_LOSS1]), + } + + return network diff --git a/lightsim2grid/network/from_matpower/initLSGrid.py b/lightsim2grid/network/from_matpower/initLSGrid.py index 332dfd1c..243cc46c 100644 --- a/lightsim2grid/network/from_matpower/initLSGrid.py +++ b/lightsim2grid/network/from_matpower/initLSGrid.py @@ -10,23 +10,25 @@ Natively parse a MATPOWER case (".m" file, ".mat" file, or already-parsed mpc dict / object) and initialize a LSGrid c++ object from it, without ever going through a pandapower or pypowsybl network. + +MATPOWER's raw matrix format is a strict subset of PowerModels.jl's network data +dictionary (no separate load/shunt/storage/dcline tables to begin with, a single +combined line-charging value, a "TAP == 0" sentinel for plain lines, angles in +degrees, ...), so this loader's only real job is to translate the raw matrices into +that richer dict and hand off to the shared, feature-complete +`lightsim2grid.network.from_powermodels` engine -- which is also what +`init_from_pfdelta` (PFΔ dataset rows) uses directly, since those are already +PowerModels dicts. """ from typing import Optional, Union import os -import numpy as np from ...lightsim2grid_cpp import LSGrid +from ..from_powermodels import init as _init_from_powermodels from ._parse_matpower_source import load_matpower_data -from ._mp_bus_to_ls_bus import build_bus_remap, mp_bus_to_ls from ._aux_check_legit import _aux_check_legit -from ._aux_add_branch import get_branch_split, _aux_add_branch -from ._aux_add_load import _aux_add_load -from ._aux_add_shunt import _aux_add_shunt -from ._aux_add_gen import _aux_add_gen -from ._aux_add_slack import _aux_add_slack -from ._aux_add_dc_line import _aux_add_dc_line -from ._my_const import BUS_I, BUS_TYPE, BASE_KV, NONE +from ._mpc_to_powermodels import mpc_to_powermodels def init(source: Union[str, "os.PathLike", dict], @@ -36,8 +38,8 @@ def init(source: Union[str, "os.PathLike", dict], Convert a MATPOWER case into a LSGrid. Unlike `lightsim2grid.network.init_from_pandapower`, this never constructs a - pandapower network: it reads MATPOWER's raw `bus` / `gen` / `branch` matrices - directly and initializes the `LSGrid` from them. In particular, several + pandapower network: it reads MATPOWER's raw `bus` / `gen` / `branch` / `dcline` + matrices directly and initializes the `LSGrid` from them. In particular, several generators connected to the same bus are all kept as independent generators (no aggregation). @@ -77,73 +79,5 @@ def init(source: Union[str, "os.PathLike", dict], bus, gen, branch, dcline, baseMVA = load_matpower_data(source) _aux_check_legit(bus, gen, branch, dcline) - mp_to_ls, ls_to_orig = build_bus_remap(bus[:, BUS_I]) - - is_trafo = get_branch_split(branch) - nb_line = int((~is_trafo).sum()) - nb_trafo = int(is_trafo.sum()) - - model = LSGrid() - model.set_sn_mva(baseMVA) - - n_sub = bus.shape[0] - if n_busbar_per_sub is None: - n_busbar_per_sub = 1 - - try: - tmp = int(n_busbar_per_sub) - except ValueError as exc_: - raise RuntimeError("Impossible to convert n_busbar_per_sub to int") from exc_ - if tmp != n_busbar_per_sub: - raise RuntimeError(f"n_busbar_per_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") - n_busbar_per_sub = tmp - if n_busbar_per_sub <= 0: - raise RuntimeError(f"You need to provide a grid with at least 1 busbar per " - f"substation / voltage level, provided n_busbar_per_sub={n_busbar_per_sub}") - - # lightsim2grid lays out global bus ids busbar-section-major: ids [0, n_sub) are - # busbar section 1 of every substation (one per matpower bus, in matpower order), - # ids [n_sub, 2*n_sub) are section 2, etc. `np.tile` replicates `bus[:, BASE_KV]` - # to match that layout. - vn_kv = np.tile(bus[:, BASE_KV], n_busbar_per_sub) - model.init_bus(n_sub, n_busbar_per_sub, vn_kv, nb_line, nb_trafo) - if n_busbar_per_sub > 1: - # extra busbar sections have no corresponding matpower bus at all - ls_to_orig = np.concatenate((ls_to_orig, - np.full(n_sub * (n_busbar_per_sub - 1), -1, dtype=int))) - model._ls_to_orig = ls_to_orig - - if n_busbar_per_sub > 1: - # matpower has no data for these extra busbar sections: nothing is connected - # to them in the base case, so deactivate them until a topology action moves - # something there - for ls_bus_id in range(n_sub, n_sub * n_busbar_per_sub): - model.deactivate_bus(ls_bus_id) - - isolated_mask = bus[:, BUS_TYPE] == NONE - if np.any(isolated_mask): - isolated_ls_bus = mp_bus_to_ls(bus[isolated_mask, BUS_I], mp_to_ls) - for ls_bus_id in isolated_ls_bus: - model.deactivate_bus(int(ls_bus_id)) - else: - isolated_ls_bus = np.array([], dtype=int) - - # init the powerlines and transformers - _aux_add_branch(model, branch, is_trafo, mp_to_ls, isolated_ls_bus) - - # init the shunts - _aux_add_shunt(model, bus, mp_to_ls, isolated_ls_bus) - - # init the loads - _aux_add_load(model, bus, mp_to_ls, isolated_ls_bus) - - # init the generators - _aux_add_gen(model, gen, mp_to_ls, isolated_ls_bus) - - # deal with the slack bus(es) - _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus) - - # init the HVDC lines (mpc.dcline), if any - _aux_add_dc_line(model, dcline, mp_to_ls, isolated_ls_bus) - - return model + network = mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) + return _init_from_powermodels(network, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py deleted file mode 100644 index b0eeda8b..00000000 --- a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ..from_matpower._my_const import ( - BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, BASE_KV, ZONE, VMAX, VMIN, - GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, - F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, - DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, - DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1, - MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, -) - - -def _is_transformer(branch): - """A PowerModels branch is a transformer if either its own `transformer` flag says - so, or its `tap`/`shift` are away from the "plain line" neutral (`tap == 1.0`, - `shift == 0.0`) -- PowerModels normalizes a matpower `TAP == 0` (its own "this is a - plain line" sentinel) to `tap = 1.0`, so this loader cannot just test `tap != 0` - like `from_matpower.get_branch_split` does on a raw mpc branch matrix. - """ - return bool(branch.get("transformer", False)) or branch.get("tap", 1.0) != 1.0 or branch.get("shift", 0.0) != 0.0 - - -def network_to_mpc(network: dict) -> dict: - """ - Convert a PFΔ / PowerModels.jl ``network`` dict into a raw-MATPOWER-shaped dict - (``{"bus", "gen", "branch"}`` numpy arrays in matpower's own column layout, plus - ``"baseMVA"``) consumable by :func:`lightsim2grid.network.from_matpower.init`. - - Row order is ``sorted(network[], key=int)`` for bus / gen / branch: this is a - deterministic, documented contract (not just an implementation detail), relied upon - by tests that need to map a built `LSGrid`'s element positions back to the original - PFΔ row's bus / gen / branch keys. - """ - if network.get("storage"): - raise RuntimeError('"storage" is present in this PFΔ row but is not supported ' - "by this loader (from_matpower has no storage container either).") - if network.get("multinetwork"): - raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") - - base_mva = float(network["baseMVA"]) - - bus_keys = sorted(network["bus"], key=int) - gen_keys = sorted(network["gen"], key=int) - branch_keys = sorted(network["branch"], key=int) - - # raw matpower embeds PD / QD / GS / BS directly on the bus row; PowerModels splits - # them into separate "load" / "shunt" dicts, so they need to be aggregated back - # (only active elements contribute -- matpower has no separate "load/shunt present - # but off" column, so an inactive one is equivalent to 0 demand/admittance here) - pd = {int(bus["bus_i"]): 0.0 for bus in network["bus"].values()} - qd, gs, bs = dict(pd), dict(pd), dict(pd) - for load in network.get("load", {}).values(): - if load.get("status", 1) == 0: - continue - b = int(load["load_bus"]) - pd[b] += load["pd"] - qd[b] += load["qd"] - for shunt in network.get("shunt", {}).values(): - if shunt.get("status", 1) == 0: - continue - b = int(shunt["shunt_bus"]) - gs[b] += shunt["gs"] * base_mva - bs[b] += shunt["bs"] * base_mva - - bus = np.zeros((len(bus_keys), MIN_BUS_COLS)) - for i, k in enumerate(bus_keys): - b = network["bus"][k] - bus_i = int(b["bus_i"]) - bus[i, BUS_I] = bus_i - bus[i, BUS_TYPE] = b["bus_type"] - bus[i, PD] = pd[bus_i] - bus[i, QD] = qd[bus_i] - bus[i, GS] = gs[bus_i] - bus[i, BS] = bs[bus_i] - bus[i, BUS_AREA] = 1.0 - bus[i, VM] = 1.0 - bus[i, VA] = 0.0 - bus[i, BASE_KV] = b.get("base_kv", 1.0) - bus[i, ZONE] = 1.0 - bus[i, VMAX] = b["vmax"] - bus[i, VMIN] = b["vmin"] - - gen = np.zeros((len(gen_keys), MIN_GEN_COLS)) - for i, k in enumerate(gen_keys): - g = network["gen"][k] - gen[i, GEN_BUS] = int(g["gen_bus"]) - gen[i, PG] = g["pg"] - gen[i, QG] = g["qg"] - gen[i, QMAX] = g["qmax"] - gen[i, QMIN] = g["qmin"] - gen[i, VG] = g["vg"] - gen[i, MBASE] = g.get("mbase", base_mva) - gen[i, GEN_STATUS] = g.get("gen_status", 1) - gen[i, PMAX] = g["pmax"] - gen[i, PMIN] = g["pmin"] - - branch = np.zeros((len(branch_keys), MIN_BRANCH_COLS)) - for i, k in enumerate(branch_keys): - br = network["branch"][k] - if br.get("g_fr", 0.0) != 0.0 or br.get("g_to", 0.0) != 0.0: - raise RuntimeError(f'branch "{k}" has a non-zero "g_fr"/"g_to" (line ' - f"conductance); the raw MATPOWER format this loader " - f"delegates to cannot represent that (only susceptance).") - is_trafo = _is_transformer(br) - branch[i, F_BUS] = int(br["f_bus"]) - branch[i, T_BUS] = int(br["t_bus"]) - branch[i, BR_R] = br["br_r"] - branch[i, BR_X] = br["br_x"] - branch[i, BR_B] = br.get("b_fr", 0.0) + br.get("b_to", 0.0) - branch[i, RATE_A] = br.get("rate_a", 0.0) - branch[i, RATE_B] = br.get("rate_b", 0.0) - branch[i, RATE_C] = br.get("rate_c", 0.0) - # matpower's own "this is a plain line" sentinel is TAP == 0, which is what - # `from_matpower.get_branch_split` tests for; PowerModels never uses 0 (it - # normalizes a matpower TAP==0 to tap=1.0), so it has to be re-encoded here - branch[i, TAP] = br.get("tap", 1.0) if is_trafo else 0.0 - # PowerModels stores shift/angles in radians (converted from matpower's - # degrees at parse time); matpower's raw SHIFT column is in degrees - branch[i, SHIFT] = np.rad2deg(br.get("shift", 0.0)) - branch[i, BR_STATUS] = br.get("br_status", 1) - - mpc = {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} - - if network.get("dcline"): - dcline_keys = sorted(network["dcline"], key=int) - dcline = np.zeros((len(dcline_keys), MIN_DCLINE_COLS)) - for i, k in enumerate(dcline_keys): - dc = network["dcline"][k] - dcline[i, DC_F_BUS] = int(dc["f_bus"]) - dcline[i, DC_T_BUS] = int(dc["t_bus"]) - dcline[i, DC_BR_STATUS] = dc.get("br_status", 1) - # PowerModels keeps "pf" at matpower's own value/sign (only "pt"/"qf"/"qt" - # get sign-flipped by PowerModels.jl's parser); from_matpower's own dcline - # converter is the one that negates it to match lightsim2grid's "received - # at side 1" convention, so it must be passed through here unchanged. - dcline[i, DC_PF] = dc["pf"] - dcline[i, DC_VF] = dc.get("vf", 1.0) - dcline[i, DC_VT] = dc.get("vt", 1.0) - dcline[i, DC_QMINF] = dc["qminf"] - dcline[i, DC_QMAXF] = dc["qmaxf"] - dcline[i, DC_QMINT] = dc["qmint"] - dcline[i, DC_QMAXT] = dc["qmaxt"] - dcline[i, DC_LOSS0] = dc["loss0"] - dcline[i, DC_LOSS1] = dc["loss1"] - # DC_PT / DC_QF / DC_QT / DC_PMIN / DC_PMAX are not read by - # from_matpower's dcline converter (see _aux_add_dc_line.py) -- left at 0.0 - mpc["dcline"] = dcline - - return mpc diff --git a/lightsim2grid/network/from_pf_delta/initLSGrid.py b/lightsim2grid/network/from_pf_delta/initLSGrid.py deleted file mode 100644 index 170ed8a5..00000000 --- a/lightsim2grid/network/from_pf_delta/initLSGrid.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -""" -Build a LSGrid from a single PFΔ (arXiv:2510.22048) dataset row: a PowerModels.jl-format -dict (MATPOWER-derived, per-unit) with a solved AC power-flow state attached. - -This loader translates the row's PowerModels ``network`` dict into a raw-MATPOWER-shaped -dict and delegates the actual `LSGrid` construction to -`lightsim2grid.network.from_matpower`, which already implements bus-id remapping, -line/transformer splitting, generator/load/shunt conversion and (possibly distributed) -slack-bus handling. -""" - -from typing import Optional, Union -import os -import json - -from ...lightsim2grid_cpp import LSGrid -from ..from_matpower import init as _init_from_matpower -from ._pf_delta_to_mpc import network_to_mpc - - -def init(row: Union[dict, str, "os.PathLike"], - n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level - ) -> LSGrid: - """ - Convert a PFΔ dataset row into a LSGrid. - - Parameters - ---------- - row: - Either a PFΔ row already parsed into a dict (with a top-level ``"network"`` - key), or a path (str or `os.PathLike`) to a ``.json`` file containing that same - structure. - n_busbar_per_sub: - There is always exactly one substation / voltage level per PFΔ bus (PFΔ has no - notion of several busbar sections within a bus, so this is not configurable). - This parameter only controls how many buses / busbar sections lightsim2grid - allocates *per substation*, which is useful if you intend to perform grid2op-like - topology actions on the resulting grid afterwards. Defaults to 1 (no extra busbar - section). Any extra busbar section is deactivated, since nothing in the base - PFΔ row is ever connected to it. Passed through directly to - `lightsim2grid.network.init_from_matpower`. - - Returns - ------- - model: :class:`lightsim2grid.network.LSGrid` - The initialized network - - """ - if isinstance(row, (str, os.PathLike)): - with open(row, "r") as f: - row = json.load(f) - - if "network" not in row: - raise RuntimeError('Expected a PFΔ row (a dict with a top-level "network" key), ' - f"got a dict with keys {sorted(row.keys())}.") - - mpc = network_to_mpc(row["network"]) - return _init_from_matpower(mpc, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/network/from_pf_delta/__init__.py b/lightsim2grid/network/from_powermodels/__init__.py similarity index 85% rename from lightsim2grid/network/from_pf_delta/__init__.py rename to lightsim2grid/network/from_powermodels/__init__.py index df92c6dc..f536bbd2 100644 --- a/lightsim2grid/network/from_pf_delta/__init__.py +++ b/lightsim2grid/network/from_powermodels/__init__.py @@ -6,6 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__all__ = ["init"] +__all__ = ["init", "init_from_pfdelta"] from .initLSGrid import init +from ._from_pfdelta import init_from_pfdelta diff --git a/lightsim2grid/network/from_powermodels/_aux_add_branch.py b/lightsim2grid/network/from_powermodels/_aux_add_branch.py new file mode 100644 index 00000000..6fab05e0 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_branch.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def is_transformer(branch: dict) -> bool: + """A PowerModels branch is a transformer if its own `"transformer"` flag says so, + or its `"tap"` / `"shift"` are away from the "plain line" neutral (`tap == 1.0`, + `shift == 0.0`) -- PowerModels normalizes a matpower `TAP == 0` (its own "this is a + plain line" sentinel) to `tap = 1.0`, so `tap != 0` (matpower's own test) does not + apply here. + """ + return bool(branch.get("transformer", False)) or branch.get("tap", 1.0) != 1.0 or branch.get("shift", 0.0) != 0.0 + + +def classify_branches(network: dict): + """Returns `(line_keys, trafo_keys)`, each a list of `network["branch"]` string + keys, sorted by `int(key)` (this order is a documented, deterministic contract: + it is what `_aux_add_branch` below feeds `init_powerlines_full`/`init_trafo` in, + and callers -- e.g. a validation helper matching lightsim2grid's own solved + results back to the original PowerModels branch keys -- can reproduce it + independently from `network` alone). + """ + line_keys, trafo_keys = [], [] + for k in sorted(network["branch"], key=int): + (trafo_keys if is_transformer(network["branch"][k]) else line_keys).append(k) + return line_keys, trafo_keys + + +def _aux_add_branch(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the lines and transformers of `network["branch"]` into the lightsim2grid "model". + + Note + ---- + Unlike lightsim2grid's `from_matpower` (which has to route through matpower's raw + `mpc.branch` matrix, whose single `BR_B` column cannot represent asymmetric line + charging), powerlines here keep `g_fr`/`b_fr` and `g_to`/`b_to` exactly as given by + PowerModels, independently per side (`LineContainer`'s two-sided `init` overload + supports this directly). Transformers are still limited to a single, symmetrically + split charging admittance -- `TrafoContainer` has no "asymmetric" overload -- so an + asymmetric transformer's `g_fr`/`g_to` (or `b_fr`/`b_to`) is summed and re-split + 50/50, with a warning (this never loses information for MATPOWER-derived data, + where PowerModels always sets `b_fr == b_to` and `g_fr == g_to == 0`). + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + branch = network["branch"] + line_keys, trafo_keys = classify_branches(network) + + f_bus_line = pm_bus_to_ls(np.array([int(branch[k]["f_bus"]) for k in line_keys]), pm_to_ls) + t_bus_line = pm_bus_to_ls(np.array([int(branch[k]["t_bus"]) for k in line_keys]), pm_to_ls) + r = np.array([branch[k]["br_r"] for k in line_keys]) + x = np.array([branch[k]["br_x"] for k in line_keys]) + h_or = np.array([complex(branch[k].get("g_fr", 0.), branch[k].get("b_fr", 0.)) for k in line_keys]) + h_ex = np.array([complex(branch[k].get("g_to", 0.), branch[k].get("b_to", 0.)) for k in line_keys]) + model.init_powerlines_full(r, x, h_or, h_ex, f_bus_line, t_bus_line) + + line_status = np.array([branch[k].get("br_status", 1) for k in line_keys]) != 0 + if isolated_ls_bus.size: + line_status &= ~np.isin(f_bus_line, isolated_ls_bus) + line_status &= ~np.isin(t_bus_line, isolated_ls_bus) + for line_id, is_ok in enumerate(line_status): + if not is_ok: + model.deactivate_powerline(line_id) + + f_bus_trafo = pm_bus_to_ls(np.array([int(branch[k]["f_bus"]) for k in trafo_keys]), pm_to_ls) + t_bus_trafo = pm_bus_to_ls(np.array([int(branch[k]["t_bus"]) for k in trafo_keys]), pm_to_ls) + trafo_r = np.array([branch[k]["br_r"] for k in trafo_keys]) + trafo_x = np.array([branch[k]["br_x"] for k in trafo_keys]) + asymmetric = [k for k in trafo_keys + if branch[k].get("g_fr", 0.) != branch[k].get("g_to", 0.) + or branch[k].get("b_fr", 0.) != branch[k].get("b_to", 0.)] + if asymmetric: + warnings.warn(f"{len(asymmetric)} transformer(s) have an asymmetric charging " + f"admittance (\"g_fr\"/\"b_fr\" != \"g_to\"/\"b_to\"), which " + "lightsim2grid's transformer model cannot represent (only a single, " + "symmetrically-split value): the total charging admittance is kept, " + "but re-split 50/50 between the two sides.") + trafo_b = np.array([complex(branch[k].get("g_fr", 0.) + branch[k].get("g_to", 0.), + branch[k].get("b_fr", 0.) + branch[k].get("b_to", 0.)) + for k in trafo_keys]) + trafo_ratio = np.array([branch[k].get("tap", 1.0) for k in trafo_keys]) + # PowerModels stores angles in radians; `init_trafo` expects degrees + trafo_shift_degree = np.rad2deg([branch[k].get("shift", 0.0) for k in trafo_keys]) + trafo_tap_hv = [True] * len(trafo_keys) # PowerModels always applies tap/shift on the "from" side + model.init_trafo(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, + f_bus_trafo, t_bus_trafo, True) + + trafo_status = np.array([branch[k].get("br_status", 1) for k in trafo_keys]) != 0 + if isolated_ls_bus.size: + trafo_status &= ~np.isin(f_bus_trafo, isolated_ls_bus) + trafo_status &= ~np.isin(t_bus_trafo, isolated_ls_bus) + for trafo_id, is_ok in enumerate(trafo_status): + if not is_ok: + model.deactivate_trafo(trafo_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_dc_line.py b/lightsim2grid/network/from_powermodels/_aux_add_dc_line.py new file mode 100644 index 00000000..ebb030e5 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_dc_line.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_dc_line(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the HVDC lines of `network["dcline"]` into the lightsim2grid "model", using + the same simplified (station-loss-free, resistance-free) dc line model already + used by lightsim2grid's pandapower and matpower loaders (`model.init_dclines`). + + Note + ---- + PowerModels.jl keeps `"pf"` at matpower's own value/sign when parsing a matpower + case (only `"pt"`/`"qf"`/`"qt"` get sign-flipped by PowerModels.jl's parser, see + `PowerModels.jl/src/io/matpower.jl`'s `_mp2pm_dcline!`), so the same sign + convention as lightsim2grid's `from_matpower` loader applies: `"pf"` is the MW + flow "from" -> "to" measured at the `"f_bus"` end (positive means the `"f_bus"` + side sends power into the line), while `model.init_dclines` expects the power + *received* at side 1 (the `"f_bus"` side) when positive, so `"pf"` must be negated. + + The loss model is `pt = pf - (loss0 + loss1 * pf)` with `loss1` a fraction of + `pf`, while lightsim2grid/pandapower's `loss_percent` expresses that same + coefficient as a percentage (`loss1 * 100`) and `loss_mw` is `loss0` directly. + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + dcline = network.get("dcline", {}) + if not dcline: + return + + dc_keys = sorted(dcline, key=int) + f_bus = pm_bus_to_ls(np.array([int(dcline[k]["f_bus"]) for k in dc_keys]), pm_to_ls) + t_bus = pm_bus_to_ls(np.array([int(dcline[k]["t_bus"]) for k in dc_keys]), pm_to_ls) + + p_mw = -np.array([dcline[k]["pf"] for k in dc_keys]) + loss_percent = 100. * np.array([dcline[k]["loss1"] for k in dc_keys]) + loss_mw = np.array([dcline[k]["loss0"] for k in dc_keys]) + vf = np.array([dcline[k].get("vf", 1.0) for k in dc_keys]) + vt = np.array([dcline[k].get("vt", 1.0) for k in dc_keys]) + qminf = np.array([dcline[k]["qminf"] for k in dc_keys]) + qmaxf = np.array([dcline[k]["qmaxf"] for k in dc_keys]) + qmint = np.array([dcline[k]["qmint"] for k in dc_keys]) + qmaxt = np.array([dcline[k]["qmaxt"] for k in dc_keys]) + + model.init_dclines(f_bus, t_bus, p_mw, loss_percent, loss_mw, vf, vt, + qminf, qmaxf, qmint, qmaxt) + + status = np.array([dcline[k].get("br_status", 1) for k in dc_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(f_bus, isolated_ls_bus) + status &= ~np.isin(t_bus, isolated_ls_bus) + for dc_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_dcline(dc_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_gen.py b/lightsim2grid/network/from_powermodels/_aux_add_gen.py new file mode 100644 index 00000000..4e2d4313 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_gen.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_gen(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the generators of `network["gen"]` into the lightsim2grid "model". Several + generators can share the same `"gen_bus"`: they are all kept as independent + generators (no aggregation), matching lightsim2grid's own `GeneratorContainer` + (no uniqueness constraint on a generator's bus id). + + PowerModels has no field equivalent to lightsim2grid's `voltage_regulator_on`: + every generator is assumed to regulate voltage (standard powerflow convention). + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + gen = network.get("gen", {}) + if not gen: + return + + gen_keys = sorted(gen, key=int) + gen_bus = pm_bus_to_ls(np.array([int(gen[k]["gen_bus"]) for k in gen_keys]), pm_to_ls) + pg = np.array([gen[k]["pg"] for k in gen_keys]) + vg = np.array([gen[k]["vg"] for k in gen_keys]) + qg = np.array([gen[k].get("qg", 0.0) for k in gen_keys]) + qmin = np.array([gen[k]["qmin"] for k in gen_keys]) + qmax = np.array([gen[k]["qmax"] for k in gen_keys]) + voltage_regulator_on = [True] * len(gen_keys) + model.init_generators_full(pg, vg, qg, voltage_regulator_on, qmin, qmax, gen_bus) + + gen_status = np.array([gen[k].get("gen_status", 1) for k in gen_keys]) > 0 + if isolated_ls_bus.size: + gen_status &= ~np.isin(gen_bus, isolated_ls_bus) + for gen_id, is_ok in enumerate(gen_status): + if not is_ok: + model.deactivate_gen(gen_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_load.py b/lightsim2grid/network/from_powermodels/_aux_add_load.py new file mode 100644 index 00000000..7a6fdbfb --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_load.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_load(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the loads of `network["load"]` into the lightsim2grid "model". + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + load = network.get("load", {}) + if not load: + return + + load_keys = sorted(load, key=int) + load_bus = pm_bus_to_ls(np.array([int(load[k]["load_bus"]) for k in load_keys]), pm_to_ls) + pd = np.array([load[k]["pd"] for k in load_keys]) + qd = np.array([load[k]["qd"] for k in load_keys]) + model.init_loads(pd, qd, load_bus) + + status = np.array([load[k].get("status", 1) for k in load_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(load_bus, isolated_ls_bus) + for load_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_load(load_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_shunt.py b/lightsim2grid/network/from_powermodels/_aux_add_shunt.py new file mode 100644 index 00000000..21c786d3 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_shunt.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_shunt(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the shunts of `network["shunt"]` into the lightsim2grid "model". + + Note + ---- + PowerModels' shunt admittance is `Y = gs + j*bs` (per-unit, standard convention, + see https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/ and + PowerModels.jl's own `src/core/data.jl`: `p_delta += gs*vm^2; q_delta -= bs*vm^2`). + lightsim2grid's `ShuntContainer` instead computes `Y_pu = (p_mw - j*q_mvar)/sn_mva` + (see its own `// TODO check the sign here for p_mw, it is suspicious!` comment). + Equating the two gives `p_mw = gs*sn_mva`, `q_mvar = -bs*sn_mva` -- the same sign + flip lightsim2grid's `from_matpower` loader already applies to matpower's `BS` + (verified independently, twice, against that loader's own docstring and code). + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + shunt = network.get("shunt", {}) + if not shunt: + return + + sn_mva = float(network["baseMVA"]) + shunt_keys = sorted(shunt, key=int) + shunt_bus = pm_bus_to_ls(np.array([int(shunt[k]["shunt_bus"]) for k in shunt_keys]), pm_to_ls) + p_mw = np.array([shunt[k]["gs"] for k in shunt_keys]) * sn_mva + q_mvar = -np.array([shunt[k]["bs"] for k in shunt_keys]) * sn_mva + model.init_shunt(p_mw, q_mvar, shunt_bus) + + status = np.array([shunt[k].get("status", 1) for k in shunt_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(shunt_bus, isolated_ls_bus) + for sh_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_shunt(sh_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_slack.py b/lightsim2grid/network/from_powermodels/_aux_add_slack.py new file mode 100644 index 00000000..5f177d2a --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_slack.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings +import numpy as np + +from ._my_const import REF +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_slack(model, network, pm_to_ls, isolated_ls_bus): + """ + Resolve the slack bus(es) from `network["bus"][k]["bus_type"] == 3` (the + PowerModels/matpower reference bus) and assign every in-service generator + connected to it as a (possibly distributed) slack generator, with equal weight. + + Note + ---- + Unlike pandapower (which has a separate `ext_grid` table to fall back on), + PowerModels/matpower has no fallback: if the reference bus has no in-service + generator connected to it, there is no slack source and a `RuntimeError` is raised. + + Several generators can be connected to the reference bus: they are all assigned as + slack, each with weight 1.0 (equal-weight distributed slack), consistent with the + `add_gen_slackbus` weighting convention already used by the pandapower loader. + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + bus = network["bus"] + ref_keys = [k for k, b in bus.items() if b.get("bus_type") == REF] + if len(ref_keys) == 0: + warnings.warn("No bus with `bus_type == 3` (reference bus) found. lightsim2grid " + "could not assign any slack bus, you will need to do it manually " + "with `model.add_gen_slackbus(...)`.") + return + if len(ref_keys) > 1: + warnings.warn(f"{len(ref_keys)} buses found with `bus_type == 3` (reference bus), " + "PowerModels/matpower normally expects a single one. All of them will " + "be considered, with equal weights.") + + ref_bus_pm = np.array([int(bus[k]["bus_i"]) for k in ref_keys]) + ref_bus_ls = pm_bus_to_ls(ref_bus_pm, pm_to_ls) + + gen = network.get("gen", {}) + if not gen: + raise RuntimeError("No generator found at all, so no slack bus can be assigned.") + + gen_keys = sorted(gen, key=int) + gen_bus_ls = pm_bus_to_ls(np.array([int(gen[k]["gen_bus"]) for k in gen_keys]), pm_to_ls) + gen_in_service = np.array([gen[k].get("gen_status", 1) for k in gen_keys]) > 0 + if isolated_ls_bus.size: + gen_in_service = gen_in_service & ~np.isin(gen_bus_ls, isolated_ls_bus) + + is_slack_gen = np.isin(gen_bus_ls, ref_bus_ls) & gen_in_service + slack_gen_ids = np.where(is_slack_gen)[0] + if slack_gen_ids.size == 0: + raise RuntimeError(f"Could not find any in-service generator connected to the reference " + f"bus(es) {ref_bus_pm.tolist()}. PowerModels/matpower has no separate " + "slack-bus fallback (unlike pandapower's `ext_grid`): lightsim2grid " + "needs at least one in-service generator at the reference bus to use " + "as slack.") + + for gen_id in slack_gen_ids: + model.add_gen_slackbus(int(gen_id), 1.0) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_storage.py b/lightsim2grid/network/from_powermodels/_aux_add_storage.py new file mode 100644 index 00000000..39f5fff2 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_storage.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_storage(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the storage units of `network["storage"]` into the lightsim2grid "model". + + Note + ---- + PowerModels' `"ps"`/`"qs"` are "Active/Reactive power withdrawn" (same load-like + sign convention as `"pd"`/`"qd"`), matching lightsim2grid's own `init_storages` + convention exactly (`LoadContainer`'s doc: "positive storage: the unit is + charging, power is taken from the grid"), so no sign conversion is needed -- + unlike shunts. Energy/rating/efficiency/inverter-impedance fields have no + equivalent in lightsim2grid's (loss-free, rating-free) storage model, matching + how `from_pandapower`'s own storage loader already ignores pandapower's own + storage ratings. + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + storage = network.get("storage", {}) + if not storage: + return + + storage_keys = sorted(storage, key=int) + storage_bus = pm_bus_to_ls(np.array([int(storage[k]["storage_bus"]) for k in storage_keys]), pm_to_ls) + ps = np.array([storage[k]["ps"] for k in storage_keys]) + qs = np.array([storage[k].get("qs", 0.0) for k in storage_keys]) + model.init_storages(ps, qs, storage_bus) + + status = np.array([storage[k].get("status", 1) for k in storage_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(storage_bus, isolated_ls_bus) + for st_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_storage(st_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_check_legit.py b/lightsim2grid/network/from_powermodels/_aux_check_legit.py new file mode 100644 index 00000000..652e1015 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_check_legit.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings + +from ._my_const import NONE + + +def _aux_check_legit(network): + """ + Check that this PowerModels network data dictionary can be handled by lightsim2grid. + + Parameters + ---------- + network: dict + The PowerModels network data dictionary + + """ + if network.get("multinetwork"): + raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") + + n_isolated = sum(1 for bus in network["bus"].values() if bus.get("bus_type") == NONE) + if n_isolated: + warnings.warn(f"{n_isolated} bus(es) have `bus_type == {NONE}` (isolated). They will be " + "deactivated, along with any load, shunt, generator, storage, dcline or " + "branch side still connected to them.") diff --git a/lightsim2grid/network/from_powermodels/_bus_remap.py b/lightsim2grid/network/from_powermodels/_bus_remap.py new file mode 100644 index 00000000..93ab1865 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_bus_remap.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + + +def pm_bus_to_ls(pm_bus_id, pm_to_ls_converter): + """Map PowerModels bus numbers (``"bus_i"``, arbitrary integers, not necessarily + contiguous / 0-based) to lightsim2grid contiguous 0-based bus ids, given the dict + built by `build_bus_remap`. + """ + return np.array([pm_to_ls_converter[pm_id] for pm_id in pm_bus_id], dtype=int) + + +def build_bus_remap(bus_keys, network): + """ + Build the mapping from PowerModels bus number (``network["bus"][k]["bus_i"]``, + arbitrary integers, not necessarily contiguous / 0-based / sorted) to a contiguous + 0-based lightsim2grid bus id. + + Parameters + ---------- + bus_keys: list of str + ``network["bus"]`` dict keys, in the order lightsim2grid buses should be laid + out (see `_aux_check_legit`/`initLSGrid.init`: ``sorted(network["bus"], key=int)``) + network: dict + The PowerModels network data dictionary + + Returns + ------- + pm_to_ls: dict + mapping from PowerModels bus number (``bus_i``) to lightsim2grid bus id + ls_to_orig: numpy array + for each lightsim2grid bus id, the original PowerModels bus number (useful to + keep on the model, mirroring `model._ls_to_orig` in the pandapower loader) + """ + bus_i = np.array([int(network["bus"][k]["bus_i"]) for k in bus_keys], dtype=int) + if np.unique(bus_i).shape[0] != bus_i.shape[0]: + raise RuntimeError('Duplicated "bus_i" values found in this PowerModels network, ' + "lightsim2grid cannot handle this.") + ls_to_orig = bus_i + pm_to_ls = {pm_id: ls_id for ls_id, pm_id in enumerate(bus_i)} + return pm_to_ls, ls_to_orig diff --git a/lightsim2grid/network/from_powermodels/_from_pfdelta.py b/lightsim2grid/network/from_powermodels/_from_pfdelta.py new file mode 100644 index 00000000..8df9145a --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_from_pfdelta.py @@ -0,0 +1,53 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Build a LSGrid from a single PFΔ (arXiv:2510.22048) dataset row: a dict wrapping a +PowerModels.jl network data dictionary (under a `"network"` key) alongside its solved +AC power-flow state (under a `"solution"` key). The network data itself is plain +PowerModels.jl, so this is a thin wrapper around `lightsim2grid.network.from_powermodels.init`. +""" + +from typing import Optional, Union +import os +import json + +from ...lightsim2grid_cpp import LSGrid +from .initLSGrid import init as _init_from_powermodels + + +def init_from_pfdelta(row: Union[dict, str, "os.PathLike"], + n_busbar_per_sub: Optional[int] = None, + ) -> LSGrid: + """ + Convert a PFΔ dataset row into a LSGrid. + + Parameters + ---------- + row: + Either a PFΔ row already parsed into a dict (with a top-level `"network"` + key), or a path (str or `os.PathLike`) to a `.json` file containing that same + structure. + n_busbar_per_sub: + Passed through directly to `lightsim2grid.network.init_from_powermodels`. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + if isinstance(row, (str, os.PathLike)): + with open(row, "r") as f: + row = json.load(f) + + if "network" not in row: + raise RuntimeError('Expected a PFΔ row (a dict with a top-level "network" key), ' + f"got a dict with keys {sorted(row.keys())}.") + + return _init_from_powermodels(row["network"], n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/network/from_powermodels/_my_const.py b/lightsim2grid/network/from_powermodels/_my_const.py new file mode 100644 index 00000000..f3896b28 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_my_const.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +# bus type constants, matching PowerModels.jl / MATPOWER's convention +# (https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/) +PQ, PV, REF, NONE = 1, 2, 3, 4 diff --git a/lightsim2grid/network/from_powermodels/initLSGrid.py b/lightsim2grid/network/from_powermodels/initLSGrid.py new file mode 100644 index 00000000..a2917f81 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/initLSGrid.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Natively parse a PowerModels.jl network data dictionary (see +https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/) and initialize a +LSGrid c++ object from it directly (bus, branch, gen, load, shunt, storage and dcline), +without ever going through a raw MATPOWER matrix or a pandapower/pypowsybl network. + +This is the shared, feature-complete engine other loaders build on: `from_matpower` +converts its raw `bus`/`gen`/`branch`/`dcline` matrices into a PowerModels-style dict +and delegates here, and `from_powermodels.init_from_pfdelta` (PFΔ dataset rows, see +arXiv:2510.22048) unwraps its row wrapper and delegates here directly, since PFΔ rows +already are PowerModels dicts. +""" + +from typing import Optional +import numpy as np + +from ...lightsim2grid_cpp import LSGrid +from ._bus_remap import build_bus_remap, pm_bus_to_ls +from ._aux_check_legit import _aux_check_legit +from ._aux_add_branch import _aux_add_branch, classify_branches +from ._aux_add_load import _aux_add_load +from ._aux_add_shunt import _aux_add_shunt +from ._aux_add_storage import _aux_add_storage +from ._aux_add_gen import _aux_add_gen +from ._aux_add_slack import _aux_add_slack +from ._aux_add_dc_line import _aux_add_dc_line +from ._my_const import NONE + + +def init(network: dict, + n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level + ) -> LSGrid: + """ + Convert a PowerModels.jl network data dictionary into a LSGrid. + + Parameters + ---------- + network: dict + A PowerModels network data dictionary (the top-level dict with `"bus"` / + `"branch"` / `"gen"` / ... keys -- *not* a full PFΔ dataset row, which wraps + this dict under a `"network"` key; use `init_from_pfdelta` for that). + n_busbar_per_sub: + There is always exactly one substation / voltage level per PowerModels bus + (PowerModels has no notion of several busbar sections within a bus, so this is + not configurable). This parameter only controls how many buses / busbar + sections lightsim2grid allocates *per substation*, which is useful if you + intend to perform grid2op-like topology actions on the resulting grid + afterwards. Defaults to 1 (no extra busbar section). Any extra busbar section + is deactivated, since nothing in the base network is ever connected to it. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + _aux_check_legit(network) + + bus_keys = sorted(network["bus"], key=int) + pm_to_ls, ls_to_orig = build_bus_remap(bus_keys, network) + + line_keys, trafo_keys = classify_branches(network) + nb_line, nb_trafo = len(line_keys), len(trafo_keys) + + model = LSGrid() + model.set_sn_mva(float(network["baseMVA"])) + + n_sub = len(bus_keys) + if n_busbar_per_sub is None: + n_busbar_per_sub = 1 + try: + tmp = int(n_busbar_per_sub) + except ValueError as exc_: + raise RuntimeError("Impossible to convert n_busbar_per_sub to int") from exc_ + if tmp != n_busbar_per_sub: + raise RuntimeError(f"n_busbar_per_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") + n_busbar_per_sub = tmp + if n_busbar_per_sub <= 0: + raise RuntimeError(f"You need to provide a grid with at least 1 busbar per " + f"substation / voltage level, provided n_busbar_per_sub={n_busbar_per_sub}") + + # lightsim2grid lays out global bus ids busbar-section-major: ids [0, n_sub) are + # busbar section 1 of every substation (one per PowerModels bus, in `bus_keys` + # order), ids [n_sub, 2*n_sub) are section 2, etc. + base_kv = np.array([network["bus"][k].get("base_kv", 1.0) for k in bus_keys]) + vn_kv = np.tile(base_kv, n_busbar_per_sub) + model.init_bus(n_sub, n_busbar_per_sub, vn_kv, nb_line, nb_trafo) + if n_busbar_per_sub > 1: + # extra busbar sections have no corresponding PowerModels bus at all + ls_to_orig = np.concatenate((ls_to_orig, + np.full(n_sub * (n_busbar_per_sub - 1), -1, dtype=int))) + model._ls_to_orig = ls_to_orig + + if n_busbar_per_sub > 1: + # nothing is connected to these extra busbar sections in the base case, so + # deactivate them until a topology action moves something there + for ls_bus_id in range(n_sub, n_sub * n_busbar_per_sub): + model.deactivate_bus(ls_bus_id) + + isolated_keys = [k for k in bus_keys if network["bus"][k].get("bus_type") == NONE] + if isolated_keys: + isolated_bus_i = np.array([int(network["bus"][k]["bus_i"]) for k in isolated_keys]) + isolated_ls_bus = pm_bus_to_ls(isolated_bus_i, pm_to_ls) + for ls_bus_id in isolated_ls_bus: + model.deactivate_bus(int(ls_bus_id)) + else: + isolated_ls_bus = np.array([], dtype=int) + + # init the powerlines and transformers + _aux_add_branch(model, network, pm_to_ls, isolated_ls_bus) + + # init the shunts + _aux_add_shunt(model, network, pm_to_ls, isolated_ls_bus) + + # init the loads + _aux_add_load(model, network, pm_to_ls, isolated_ls_bus) + + # init the storage units + _aux_add_storage(model, network, pm_to_ls, isolated_ls_bus) + + # init the generators + _aux_add_gen(model, network, pm_to_ls, isolated_ls_bus) + + # deal with the slack bus(es) + _aux_add_slack(model, network, pm_to_ls, isolated_ls_bus) + + # init the HVDC lines, if any + _aux_add_dc_line(model, network, pm_to_ls, isolated_ls_bus) + + return model diff --git a/lightsim2grid/tests/test_LSGrid_pf_delta.py b/lightsim2grid/tests/test_LSGrid_pf_delta.py index ad7a9f9b..817ec891 100644 --- a/lightsim2grid/tests/test_LSGrid_pf_delta.py +++ b/lightsim2grid/tests/test_LSGrid_pf_delta.py @@ -6,32 +6,18 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -import copy import json import os import unittest import numpy as np -from lightsim2grid.network import init_from_pf_delta -from lightsim2grid.network.from_pf_delta._pf_delta_to_mpc import network_to_mpc -from lightsim2grid.network.from_matpower._aux_add_branch import get_branch_split +from lightsim2grid.network import init_from_pf_delta, init_from_powermodels +from lightsim2grid.network.from_powermodels._aux_add_branch import classify_branches _FIXTURE_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") -def _branch_split_keys(network): - """Reproduces, from the row's `network` dict alone, which branch keys end up as - lines vs. transformers and in what order -- by reusing `network_to_mpc` (the exact - function `init_from_pf_delta` uses) and `from_matpower`'s own classification, so - this never drifts out of sync with the loader itself.""" - branch_keys = sorted(network["branch"], key=int) - is_trafo = get_branch_split(network_to_mpc(network)["branch"]) - line_keys = [k for k, t in zip(branch_keys, is_trafo) if not t] - trafo_keys = [k for k, t in zip(branch_keys, is_trafo) if t] - return line_keys, trafo_keys - - def validate_against_row(model, row, tol=1e-5): """Compares `model`'s CURRENT (already solved) AC state against the PFΔ row's own solved `solution`. Does not run any powerflow itself. Returns a dict with the max @@ -41,7 +27,7 @@ def validate_against_row(model, row, tol=1e-5): solution = row["solution"]["solution"] bus_keys = sorted(network["bus"], key=int) - line_keys, trafo_keys = _branch_split_keys(network) + line_keys, trafo_keys = classify_branches(network) vm = model.get_Vm() va = model.get_Va() @@ -87,7 +73,7 @@ def setUp(self): def test_counts(self): network = self.row["network"] - line_keys, trafo_keys = _branch_split_keys(network) + line_keys, trafo_keys = classify_branches(network) assert len(self.model.get_lines()) == len(line_keys) assert len(self.model.get_trafos()) == len(trafo_keys) assert len(self.model.get_generators()) == len(network["gen"]) @@ -99,7 +85,7 @@ def test_fixture_exercises_taps_and_shunt(self): # shunt absent) the AC powerflow test below would not actually be exercising # the tap-ratio / shunt-sign conversions network = self.row["network"] - _, trafo_keys = _branch_split_keys(network) + _, trafo_keys = classify_branches(network) assert len(trafo_keys) > 0, "fixture has no transformers, tap conversion untested" assert any(network["branch"][k]["tap"] != 1.0 for k in trafo_keys) assert len(network.get("shunt", {})) > 0, "fixture has no shunt, sign convention untested" @@ -120,20 +106,59 @@ def test_accepts_path(self): Vfinal = model.ac_pf(np.full(nb_bus, 1.0, dtype=complex), 10, 1e-8) assert Vfinal.shape[0] > 0, "powerflow diverged" + def test_init_from_powermodels_on_bare_network_dict(self): + # init_from_pf_delta is a thin wrapper unwrapping row["network"] and calling + # init_from_powermodels directly -- both should behave identically + model = init_from_powermodels(self.row["network"]) + assert len(model.get_lines()) == len(self.model.get_lines()) + assert len(model.get_trafos()) == len(self.model.get_trafos()) + nb_bus = len(self.row["network"]["bus"]) + Vfinal = model.ac_pf(np.full(nb_bus, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + def test_missing_network_key_raises(self): with self.assertRaises(RuntimeError): init_from_pf_delta(self.row["network"]) - def test_nonzero_g_fr_raises(self): - row = copy.deepcopy(self.row) - first_branch_key = next(iter(row["network"]["branch"])) - row["network"]["branch"][first_branch_key]["g_fr"] = 0.01 - with self.assertRaises(RuntimeError): - init_from_pf_delta(row) - def test_shift_is_converted_from_radians_to_degrees(self): - # case14 has no phase shifters (shift == 0 everywhere), so this exercises the - # rad -> deg conversion directly on a small synthetic network instead +class TestLSGridPFDelta(BaseTests, unittest.TestCase): + pass + + +class TestPFDeltaBranchConductance(unittest.TestCase): + """Unlike `init_from_matpower` (which routes through matpower's raw `mpc.branch`, + whose single `BR_B` column cannot represent line conductance at all), + `init_from_powermodels` builds lines directly from `"g_fr"`/`"g_to"`, so an + asymmetric, non-zero line conductance should convert and solve without issue.""" + + @staticmethod + def _network(g_fr=0.0, g_to=0.0): + return { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 10.0, "qmin": -10.0, + "vg": 1.0, "pmax": 100.0, "pmin": 0.0, "gen_status": 1}}, + "load": {"1": {"load_bus": 2, "pd": 10.0, "qd": 2.0, "status": 1}}, + "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, + "g_fr": g_fr, "g_to": g_to, "br_status": 1}}, + } + + def test_asymmetric_line_conductance_converts_and_solves(self): + model = init_from_powermodels(self._network(g_fr=0.001, g_to=0.002)) + assert len(model.get_lines()) == 1 + Vfinal = model.ac_pf(np.full(2, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + +class TestPFDeltaShiftConversion(unittest.TestCase): + """case14 has no phase shifters (`shift == 0` everywhere in the real pglib case), + so this exercises the radians -> degrees -> radians round trip through the + `init_trafo` API boundary directly on a small synthetic network instead.""" + + def test_shift_round_trips_through_init_trafo(self): + shift_rad = np.pi / 6 network = { "baseMVA": 100.0, "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, @@ -142,15 +167,58 @@ def test_shift_is_converted_from_radians_to_degrees(self): "vg": 1.0, "pmax": 100.0, "pmin": 0.0, "gen_status": 1}}, "load": {}, "shunt": {}, "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, - "tap": 1.05, "shift": np.pi / 6, "br_status": 1}}, + "tap": 1.05, "shift": shift_rad, "br_status": 1}}, } - mpc = network_to_mpc(network) - np.testing.assert_allclose(mpc["branch"][0, 9], 30.0) # SHIFT column, degrees + model = init_from_powermodels(network) + trafos = model.get_trafos() + assert len(trafos) == 1 + assert abs(trafos[0].shift_rad - shift_rad) <= 1e-8 + + +class TestPFDeltaStorage(unittest.TestCase): + """PFΔ's own pglib-derived cases never contain a `"storage"` entry, but + PowerModels' schema supports one and lightsim2grid itself supports storage + (unlike `from_matpower`'s raw mpc format, which has no storage table at all), so + this exercises that conversion path directly.""" + + @staticmethod + def _network_with_storage(status=1): + return { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 100.0, "qmin": -100.0, + "vg": 1.0, "pmax": 200.0, "pmin": 0.0, "gen_status": 1}}, + "load": {"1": {"load_bus": 2, "pd": 10.0, "qd": 2.0, "status": 1}}, + "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, "br_status": 1}}, + "storage": {"1": {"storage_bus": 2, "ps": 5.0, "qs": 1.0, "status": status}}, + } + + def test_no_storage_key_at_all(self): + network = self._network_with_storage() + del network["storage"] + model = init_from_powermodels(network) + assert len(model.get_storages()) == 0 + + def test_storage_is_converted(self): + model = init_from_powermodels(self._network_with_storage()) + storages = model.get_storages() + assert len(storages) == 1 + # PowerModels' "ps"/"qs" are load-convention (withdrawn=positive), matching + # lightsim2grid's own init_storages convention directly -- no sign flip + Vfinal = model.ac_pf(np.full(2, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + def test_deactivated_storage(self): + model = init_from_powermodels(self._network_with_storage(status=0)) + Vfinal = model.ac_pf(np.full(2, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" class TestPFDeltaDCLine(unittest.TestCase): """PFΔ's own pglib-derived cases (case14/30/57/118/500/2000) never contain a - `"dcline"` entry, but PowerModels' schema supports one and `from_matpower` now + `"dcline"` entry, but PowerModels' schema supports one and lightsim2grid now converts it (`model.init_dclines`), so this exercises that translation path directly rather than leaving it silently unsupported.""" @@ -170,7 +238,7 @@ def _network_with_dcline(): "2": {"f_bus": 2, "t_bus": 3, "br_r": 0.01, "br_x": 0.1, "br_status": 1}}, # PowerModels keeps "pf" at matpower's own sign, but negates "pt"/"qf"/"qt"; # only f_bus/t_bus/br_status/pf/loss0/loss1/vf/vt/qmin*/qmax* are actually - # consumed by from_matpower's converter (see _aux_add_dc_line.py) + # consumed by the converter (see from_powermodels/_aux_add_dc_line.py) "dcline": {"1": {"f_bus": 2, "t_bus": 3, "pf": 10.0, "pt": -9.0, "qf": 0.0, "qt": 0.0, "vf": 1.0, "vt": 1.0, "qminf": -10.0, "qmaxf": 10.0, "qmint": -10.0, "qmaxt": 10.0, "loss0": 0.5, "loss1": 0.05, @@ -206,9 +274,5 @@ def test_deactivated_dcline(self): assert Vfinal.shape[0] > 0, "powerflow diverged" -class TestLSGridPFDelta(BaseTests, unittest.TestCase): - pass - - if __name__ == "__main__": unittest.main() From 9db8aa6a18c04e1aa4be732f499798f32fc4e39b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 02:58:03 +0000 Subject: [PATCH 032/166] Invert loader dependency: from_matpower now delegates to from_powermodels Per review feedback: PowerModels.jl's network data format is strictly richer than raw MATPOWER (separate load/shunt/storage tables, independent per-side line charging, no "tap == 0" line/transformer sentinel to work around), so building the grid from that richer representation should be the canonical path, with the poorer raw MATPOWER matrix format converted up to it -- not the other way around as this PR originally had it. - New lightsim2grid/network/from_powermodels/ package: a feature-complete, dict-based engine (init(network) -> LSGrid) covering bus, branch (line/transformer split, now with independent per-side g_fr/b_fr and g_to/b_to for lines -- from_matpower's raw mpc.branch format could only represent a single, forced-symmetric charging value), gen, load, shunt, **storage** (new: from_matpower has no storage table at all, even though lightsim2grid itself supports it) and dcline (HVDC), including distributed-slack handling. Renamed from "from_pf_delta" since the format is PowerModels, not PFDelta-specific, per review. - from_matpower/initLSGrid.py now only parses the raw mpc matrices and converts them into a PowerModels-style dict (_mpc_to_powermodels.py, the verified inverse of PowerModels.jl's own matpower parser), then delegates to from_powermodels.init. Its own public behavior/signature is unchanged -- verified against its existing 19-test suite, which passes unmodified. - from_powermodels/_from_pfdelta.py holds the PFDelta-row-specific bit (unwrapping row["network"]): init_from_pf_delta stays the public entry point name for that, now implemented as a thin wrapper around init_from_powermodels. - Also exposes lightsim2grid.network.init_from_powermodels directly, for anyone with a bare PowerModels dict (not a PFDelta row). Test suite grown accordingly (test_LSGrid_pf_delta.py): storage conversion/deactivation, asymmetric line conductance, and a direct check that shift round-trips through the degrees/radians init_trafo boundary (via the newly exposed TrafoInfo.shift_rad), replacing the now-obsolete "reject non-zero g_fr" test since lines support it directly now. Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 33 ++-- lightsim2grid/network/__init__.py | 4 +- .../network/from_matpower/_aux_add_branch.py | 76 --------- .../network/from_matpower/_aux_add_dc_line.py | 70 -------- .../network/from_matpower/_aux_add_gen.py | 55 ------ .../network/from_matpower/_aux_add_load.py | 42 ----- .../network/from_matpower/_aux_add_shunt.py | 52 ------ .../network/from_matpower/_aux_add_slack.py | 79 --------- .../network/from_matpower/_aux_check_legit.py | 32 ++-- .../from_matpower/_mp_bus_to_ls_bus.py | 47 ------ .../from_matpower/_mpc_to_powermodels.py | 117 +++++++++++++ .../network/from_matpower/initLSGrid.py | 96 ++--------- .../network/from_pf_delta/_pf_delta_to_mpc.py | 157 ------------------ .../network/from_pf_delta/initLSGrid.py | 66 -------- .../__init__.py | 3 +- .../from_powermodels/_aux_add_branch.py | 114 +++++++++++++ .../from_powermodels/_aux_add_dc_line.py | 72 ++++++++ .../network/from_powermodels/_aux_add_gen.py | 54 ++++++ .../network/from_powermodels/_aux_add_load.py | 44 +++++ .../from_powermodels/_aux_add_shunt.py | 56 +++++++ .../from_powermodels/_aux_add_slack.py | 78 +++++++++ .../from_powermodels/_aux_add_storage.py | 55 ++++++ .../from_powermodels/_aux_check_legit.py | 31 ++++ .../network/from_powermodels/_bus_remap.py | 48 ++++++ .../network/from_powermodels/_from_pfdelta.py | 53 ++++++ .../network/from_powermodels/_my_const.py | 11 ++ .../network/from_powermodels/initLSGrid.py | 138 +++++++++++++++ lightsim2grid/tests/test_LSGrid_pf_delta.py | 138 ++++++++++----- 28 files changed, 1023 insertions(+), 798 deletions(-) delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_branch.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_dc_line.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_gen.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_load.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_shunt.py delete mode 100644 lightsim2grid/network/from_matpower/_aux_add_slack.py delete mode 100644 lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py create mode 100644 lightsim2grid/network/from_matpower/_mpc_to_powermodels.py delete mode 100644 lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py delete mode 100644 lightsim2grid/network/from_pf_delta/initLSGrid.py rename lightsim2grid/network/{from_pf_delta => from_powermodels}/__init__.py (85%) create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_branch.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_dc_line.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_gen.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_load.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_shunt.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_slack.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_add_storage.py create mode 100644 lightsim2grid/network/from_powermodels/_aux_check_legit.py create mode 100644 lightsim2grid/network/from_powermodels/_bus_remap.py create mode 100644 lightsim2grid/network/from_powermodels/_from_pfdelta.py create mode 100644 lightsim2grid/network/from_powermodels/_my_const.py create mode 100644 lightsim2grid/network/from_powermodels/initLSGrid.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index df8ca0e0..57f81461 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -319,18 +319,27 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. Grids built from pandapower never populate these (getters return an empty array / NaN per element). Not wired into `LightSimBackend.thermal_limit_a`, which keeps its existing behaviour. -- [ADDED] `lightsim2grid.network.init_from_pf_delta`: builds an `LSGrid` from one row of - the PFΔ benchmark dataset (arXiv:2510.22048, a PowerModels.jl/MATPOWER-format dict - with a solved power-flow state attached). Accepts either a parsed row (dict) or a - path to its `.json` file. Implemented as a thin translation layer on top of - `init_from_matpower`: the PFΔ row's PowerModels dict is converted into a raw - MATPOWER-shaped `bus`/`gen`/`branch` (and, if present, `dcline`) array set and - delegated to it, reusing its bus-id remapping, line/transformer splitting, - generator/load/shunt/HVDC conversion and slack-bus handling. Tested in - `test_LSGrid_pf_delta.py`, including an end-to-end check that lightsim2grid's own AC - powerflow reproduces a row's solved `vm`/`va`/`pf`/`qf`/`pt`/`qt` to solver tolerance, - and a synthetic-network check of the `"dcline"` translation (PFΔ's own pglib-derived - cases never contain one, but the schema and `from_matpower` both support it). +- [ADDED] `lightsim2grid.network.init_from_powermodels`: a native, feature-complete + loader that builds an `LSGrid` directly from a PowerModels.jl network data + dictionary (see https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/): + bus, branch (line/transformer split), gen, load, shunt, **storage** and dcline + (HVDC), including distributed-slack handling. Since PowerModels' dict is strictly + richer than raw MATPOWER (separate load/shunt/storage tables, independent per-side + line charging, no `tap == 0` sentinel to work around), this is now the shared engine + other loaders build on: `init_from_matpower` converts its raw `bus`/`gen`/`branch`/ + `dcline` matrices into a PowerModels-style dict and delegates here (its own public + behavior is unchanged; verified against its existing test suite), rather than the + other way around. +- [ADDED] `lightsim2grid.network.init_from_pf_delta`: builds an `LSGrid` from one row + of the PFΔ benchmark dataset (arXiv:2510.22048, a PowerModels.jl-format dict with a + solved power-flow state attached). Accepts either a parsed row (dict) or a path to + its `.json` file; a thin wrapper unwrapping the row's `"network"` key and calling + `init_from_powermodels` directly, since PFΔ rows already are PowerModels dicts. + Tested in `test_LSGrid_pf_delta.py`, including an end-to-end check that + lightsim2grid's own AC powerflow reproduces a row's solved `vm`/`va`/`pf`/`qf`/`pt`/ + `qt` to solver tolerance, and synthetic-network checks of the `"storage"` and + `"dcline"` conversions (PFΔ's own pglib-derived cases never contain either, but the + schema and `init_from_powermodels` both support them). [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index da006533..4c5ea4f3 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -43,7 +43,9 @@ # a ".m"/".mat" file pass -from lightsim2grid.network.from_pf_delta import init as init_from_pf_delta # noqa +from lightsim2grid.network.from_powermodels import init as init_from_powermodels # noqa +from lightsim2grid.network.from_powermodels import init_from_pfdelta as init_from_pf_delta # noqa +__all__.append("init_from_powermodels") __all__.append("init_from_pf_delta") try: diff --git a/lightsim2grid/network/from_matpower/_aux_add_branch.py b/lightsim2grid/network/from_matpower/_aux_add_branch.py deleted file mode 100644 index 20bbca73..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_branch.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import F_BUS, T_BUS, BR_R, BR_X, BR_B, TAP, SHIFT, BR_STATUS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def get_branch_split(branch): - """A branch row is a transformer if and only if its `TAP` column is non zero, - this is matpower's own convention (see e.g. `makeYbus.m`). `TAP == 0` means - "plain line, ratio 1.0". - """ - return branch[:, TAP] != 0. - - -def _aux_add_branch(model, branch, is_trafo, mp_to_ls, isolated_ls_bus): - """ - Add the lines and transformers of `mpc.branch` into the lightsim2grid "model". - - Parameters - ---------- - model - branch: numpy array - raw `mpc.branch` matrix - is_trafo: numpy bool array - for each row of `branch`, whether it should be treated as a transformer - (see `get_branch_split`) - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - is_line = ~is_trafo - from_bus = mp_bus_to_ls(branch[:, F_BUS], mp_to_ls) - to_bus = mp_bus_to_ls(branch[:, T_BUS], mp_to_ls) - - # powerlines - line_r = branch[is_line, BR_R] - line_x = branch[is_line, BR_X] - line_h = 1j * branch[is_line, BR_B] - model.init_powerlines(line_r, line_x, line_h, from_bus[is_line], to_bus[is_line]) - - line_status = branch[is_line, BR_STATUS] != 0 - if isolated_ls_bus.size: - line_status &= ~np.isin(from_bus[is_line], isolated_ls_bus) - line_status &= ~np.isin(to_bus[is_line], isolated_ls_bus) - for line_id, is_ok in enumerate(line_status): - if not is_ok: - model.deactivate_powerline(line_id) - - # transformers - n_trafo = int(is_trafo.sum()) - trafo_r = branch[is_trafo, BR_R] - trafo_x = branch[is_trafo, BR_X] - trafo_b = 1j * branch[is_trafo, BR_B] - trafo_ratio = branch[is_trafo, TAP] - trafo_shift_degree = branch[is_trafo, SHIFT] - trafo_tap_hv = [True] * n_trafo - model.init_trafo(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, - from_bus[is_trafo], to_bus[is_trafo], True) - - trafo_status = branch[is_trafo, BR_STATUS] != 0 - if isolated_ls_bus.size: - trafo_status &= ~np.isin(from_bus[is_trafo], isolated_ls_bus) - trafo_status &= ~np.isin(to_bus[is_trafo], isolated_ls_bus) - for trafo_id, is_ok in enumerate(trafo_status): - if not is_ok: - model.deactivate_trafo(trafo_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_dc_line.py b/lightsim2grid/network/from_matpower/_aux_add_dc_line.py deleted file mode 100644 index 57f90130..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_dc_line.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import (DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, - DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1) -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_dc_line(model, dcline, mp_to_ls, isolated_ls_bus): - """ - Add the HVDC lines described by `mpc.dcline` into the lightsim2grid "model", using - the same simplified (station-loss-free, resistance-free) dc line model already used - by lightsim2grid's pandapower loader (`model.init_dclines`). - - Note - ---- - Matpower's `PF` is the MW flow "from" -> "to" measured at the `F_BUS` end (positive - means the `F_BUS` side sends power into the line), which is the same convention as - pandapower's dcline `p_mw`. `model.init_dclines` instead expects the power *received* - at side 1 (the `F_BUS` side) when positive, so `PF` must be negated, exactly as - lightsim2grid's pandapower loader already does for pandapower's `p_mw`. - - Matpower's loss model is `PT = PF - (LOSS0 + LOSS1 * PF)` with `LOSS1` a fraction of - `PF`, while lightsim2grid/pandapower's `loss_percent` expresses that same coefficient - as a percentage (`LOSS1 * 100`) and `loss_mw` is `LOSS0` directly. - - `PMIN` / `PMAX` (limits on `PF`) have no equivalent in `model.init_dclines` (this - simplified model does not enforce them), consistent with how the pandapower loader - already ignores pandapower's own dcline power limits. - - Parameters - ---------- - model - dcline: numpy array - raw `mpc.dcline` matrix (can have 0 rows: most matpower cases have no HVDC line) - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - if dcline.shape[0] == 0: - return - - from_bus = mp_bus_to_ls(dcline[:, DC_F_BUS], mp_to_ls) - to_bus = mp_bus_to_ls(dcline[:, DC_T_BUS], mp_to_ls) - - p_mw = -dcline[:, DC_PF] - loss_percent = 100. * dcline[:, DC_LOSS1] - loss_mw = dcline[:, DC_LOSS0] - - model.init_dclines(from_bus, to_bus, p_mw, loss_percent, loss_mw, - dcline[:, DC_VF], dcline[:, DC_VT], - dcline[:, DC_QMINF], dcline[:, DC_QMAXF], - dcline[:, DC_QMINT], dcline[:, DC_QMAXT]) - - dc_status = dcline[:, DC_BR_STATUS] != 0 - if isolated_ls_bus.size: - dc_status &= ~np.isin(from_bus, isolated_ls_bus) - dc_status &= ~np.isin(to_bus, isolated_ls_bus) - for dc_id, is_ok in enumerate(dc_status): - if not is_ok: - model.deactivate_dcline(dc_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_gen.py b/lightsim2grid/network/from_matpower/_aux_add_gen.py deleted file mode 100644 index 20aae605..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_gen.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import GEN_BUS, PG, QG, QMAX, QMIN, VG, GEN_STATUS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_gen(model, gen, mp_to_ls, isolated_ls_bus): - """ - Add the generators of `mpc.gen` into the lightsim2grid "model". - - Note - ---- - Several rows of `mpc.gen` can share the same `GEN_BUS`: they are all kept as - independent generators (no aggregation), which is the entire point of this - native matpower loader -- lightsim2grid's `LSGrid` has no uniqueness constraint - on a generator's bus id (`from_pypowsybl` already relies on this for grids with - several generators on the same bus). - - Matpower has no column equivalent to lightsim2grid's `voltage_regulator_on`: every - generator is assumed to regulate voltage (standard powerflow convention), which is - the modeling assumption made here. - - Parameters - ---------- - model - gen: numpy array - raw `mpc.gen` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - if gen.shape[0] == 0: - return - - gen_bus = mp_bus_to_ls(gen[:, GEN_BUS], mp_to_ls) - voltage_regulator_on = [True] * gen.shape[0] - model.init_generators_full(gen[:, PG], gen[:, VG], gen[:, QG], voltage_regulator_on, - gen[:, QMIN], gen[:, QMAX], gen_bus) - - gen_status = gen[:, GEN_STATUS] > 0 - if isolated_ls_bus.size: - gen_status &= ~np.isin(gen_bus, isolated_ls_bus) - for gen_id, is_ok in enumerate(gen_status): - if not is_ok: - model.deactivate_gen(gen_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_load.py b/lightsim2grid/network/from_matpower/_aux_add_load.py deleted file mode 100644 index ebea5582..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_load.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import BUS_I, PD, QD -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_load(model, bus, mp_to_ls, isolated_ls_bus): - """ - Add the loads described by the `PD` / `QD` columns of `mpc.bus` into the - lightsim2grid "model". Matpower has no separate load table: a load is only - created for buses with a non zero `PD` or `QD`. - - Parameters - ---------- - model - bus: numpy array - raw `mpc.bus` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - has_load = (bus[:, PD] != 0.) | (bus[:, QD] != 0.) - if not np.any(has_load): - return - - load_bus = mp_bus_to_ls(bus[has_load, BUS_I], mp_to_ls) - model.init_loads(bus[has_load, PD], bus[has_load, QD], load_bus) - - if isolated_ls_bus.size: - for load_id, is_isolated in enumerate(np.isin(load_bus, isolated_ls_bus)): - if is_isolated: - model.deactivate_load(load_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_shunt.py b/lightsim2grid/network/from_matpower/_aux_add_shunt.py deleted file mode 100644 index 2e8fc549..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_shunt.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ._my_const import BUS_I, GS, BS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_shunt(model, bus, mp_to_ls, isolated_ls_bus): - """ - Add the shunts described by the `GS` / `BS` columns of `mpc.bus` into the - lightsim2grid "model". Matpower has no separate shunt table: a shunt is only - created for buses with a non zero `GS` or `BS`. - - Note - ---- - Matpower's `GS` is "shunt conductance (MW **demanded** at V = 1.0 p.u.)" (load - convention, positive = consumed) while `BS` is "shunt susceptance (MVAr - **injected** at V = 1.0 p.u.)" (generator convention, positive = supplied) -- the - opposite sign convention from lightsim2grid/pandapower's `q_mvar` (load - convention, positive = consumed). `BS` must therefore be negated. This matches - pandapower's own matpower/ppc importer (`pandapower.converter.pypower.from_ppc`), - which does `q_mvar=-ppc["bus"][is_shunt, BS]`. - - Parameters - ---------- - model - bus: numpy array - raw `mpc.bus` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - has_shunt = (bus[:, GS] != 0.) | (bus[:, BS] != 0.) - if not np.any(has_shunt): - return - - shunt_bus = mp_bus_to_ls(bus[has_shunt, BUS_I], mp_to_ls) - model.init_shunt(bus[has_shunt, GS], -bus[has_shunt, BS], shunt_bus) - - if isolated_ls_bus.size: - for sh_id, is_isolated in enumerate(np.isin(shunt_bus, isolated_ls_bus)): - if is_isolated: - model.deactivate_shunt(sh_id) diff --git a/lightsim2grid/network/from_matpower/_aux_add_slack.py b/lightsim2grid/network/from_matpower/_aux_add_slack.py deleted file mode 100644 index fe08fa7d..00000000 --- a/lightsim2grid/network/from_matpower/_aux_add_slack.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import warnings -import numpy as np - -from ._my_const import BUS_I, BUS_TYPE, REF, GEN_BUS, GEN_STATUS -from ._mp_bus_to_ls_bus import mp_bus_to_ls - - -def _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus): - """ - Resolve the slack bus(es) from `mpc.bus[:, BUS_TYPE] == 3` (the matpower reference - bus) and assign every in-service generator connected to it as a (possibly - distributed) slack generator, with equal weight. - - Note - ---- - Unlike pandapower (which has a separate `ext_grid` table to fall back on), matpower - has no fallback: if the reference bus has no in-service generator connected to it, - there is no slack source and a `RuntimeError` is raised. - - Several generators can be connected to the reference bus (this is precisely the - multi-generator-per-bus case this loader is designed to support): they are all - assigned as slack, each with weight 1.0 (equal-weight distributed slack), consistent - with the `add_gen_slackbus` weighting convention already used by the pandapower - loader. Matpower has no column to derive a different weighting from. - - Parameters - ---------- - model - bus: numpy array - raw `mpc.bus` matrix - gen: numpy array - raw `mpc.gen` matrix - mp_to_ls: dict - matpower bus number -> lightsim2grid bus id - isolated_ls_bus: numpy array - lightsim2grid bus ids of isolated (`BUS_TYPE == 4`) buses - - """ - ref_mask = bus[:, BUS_TYPE] == REF - n_ref = int(ref_mask.sum()) - if n_ref == 0: - warnings.warn("No bus with `BUS_TYPE == 3` (reference bus) found. lightsim2grid " - "could not assign any slack bus, you will need to do it manually " - "with `model.add_gen_slackbus(...)`.") - return - if n_ref > 1: - warnings.warn(f"{n_ref} buses found with `BUS_TYPE == 3` (reference bus), matpower " - f"normally expects a single one. All of them will be considered, " - f"with equal weights.") - - ref_bus_mp = bus[ref_mask, BUS_I] - ref_bus_ls = mp_bus_to_ls(ref_bus_mp, mp_to_ls) - - if gen.shape[0] == 0: - raise RuntimeError("No generator found at all, so no slack bus can be assigned.") - - gen_bus_ls = mp_bus_to_ls(gen[:, GEN_BUS], mp_to_ls) - gen_in_service = gen[:, GEN_STATUS] > 0 - if isolated_ls_bus.size: - gen_in_service = gen_in_service & ~np.isin(gen_bus_ls, isolated_ls_bus) - - is_slack_gen = np.isin(gen_bus_ls, ref_bus_ls) & gen_in_service - slack_gen_ids = np.where(is_slack_gen)[0] - if slack_gen_ids.size == 0: - raise RuntimeError(f"Could not find any in-service generator connected to the reference " - f"bus(es) {ref_bus_mp.tolist()}. Matpower has no separate slack-bus " - f"fallback (unlike pandapower's `ext_grid`): lightsim2grid needs at " - f"least one in-service generator at the reference bus to use as slack.") - - for gen_id in slack_gen_ids: - model.add_gen_slackbus(int(gen_id), 1.0) diff --git a/lightsim2grid/network/from_matpower/_aux_check_legit.py b/lightsim2grid/network/from_matpower/_aux_check_legit.py index f10fafe6..2f6aae01 100644 --- a/lightsim2grid/network/from_matpower/_aux_check_legit.py +++ b/lightsim2grid/network/from_matpower/_aux_check_legit.py @@ -6,25 +6,26 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -import warnings -import numpy as np - -from ._my_const import (MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, - TAP, SHIFT, BUS_TYPE, NONE) +from ._my_const import MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS def _aux_check_legit(bus, gen, branch, dcline): """ - Check that the raw matpower matrices can be handled by lightsim2grid. + Check that the raw matpower matrices have enough columns to be interpreted at all. + + Semantic checks that don't depend on the raw-array representation (isolated buses, + "weird" tap/shift combinations) are done once the matrices are converted to a + PowerModels-style dict, so they also cover the `from_powermodels`/ + `init_from_pfdelta` entry points -- see + `lightsim2grid.network.from_matpower._mpc_to_powermodels` (the `TAP == 0` / `SHIFT + != 0` warning) and `lightsim2grid.network.from_powermodels._aux_check_legit` (the + isolated-bus warning). Parameters ---------- bus, gen, branch, dcline: numpy arrays The raw matpower matrices, as returned by `_parse_matpower_source.load_matpower_data` - Returns - ------- - """ if bus.shape[1] < MIN_BUS_COLS: raise RuntimeError(f"`mpc.bus` should have at least {MIN_BUS_COLS} columns, found {bus.shape[1]}.") @@ -34,16 +35,3 @@ def _aux_check_legit(bus, gen, branch, dcline): raise RuntimeError(f"`mpc.branch` should have at least {MIN_BRANCH_COLS} columns, found {branch.shape[1]}.") if dcline.shape[0] and dcline.shape[1] < MIN_DCLINE_COLS: raise RuntimeError(f"`mpc.dcline` should have at least {MIN_DCLINE_COLS} columns, found {dcline.shape[1]}.") - - if branch.shape[0]: - is_line = branch[:, TAP] == 0. - weird_shift = is_line & (branch[:, SHIFT] != 0.) - if np.any(weird_shift): - warnings.warn(f"{weird_shift.sum()} branch(es) have `TAP == 0` (so are treated as a plain " - f"powerline by lightsim2grid) but a non-zero `SHIFT`, which is not physically " - f"meaningful for a plain line. The `SHIFT` will be ignored for these branches.") - - if bus.shape[0] and np.any(bus[:, BUS_TYPE] == NONE): - n_isolated = int((bus[:, BUS_TYPE] == NONE).sum()) - warnings.warn(f"{n_isolated} bus(es) have `BUS_TYPE == {NONE}` (isolated). They will be deactivated, " - f"along with any load, shunt, generator or branch side still connected to them.") diff --git a/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py b/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py deleted file mode 100644 index 88b172a6..00000000 --- a/lightsim2grid/network/from_matpower/_mp_bus_to_ls_bus.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - - -def mp_bus_to_ls(mp_bus_id, mp_to_ls_converter): - """Map matpower bus numbers (arbitrary, not necessarily contiguous / 0-based) - to lightsim2grid contiguous 0-based bus ids, given the dict built by - `build_bus_remap`. - """ - return np.array([mp_to_ls_converter[mp_id] for mp_id in mp_bus_id], dtype=int) - - -def build_bus_remap(bus_i): - """ - Build the mapping from matpower bus number (`bus[:, BUS_I]`, arbitrary integers, - not necessarily contiguous / 0-based / sorted) to a contiguous 0-based lightsim2grid - bus id. - - Parameters - ---------- - bus_i: numpy array - The `BUS_I` column of `mpc.bus` (one entry per bus, in the original file order) - - Returns - ------- - mp_to_ls: dict - mapping from matpower bus number to lightsim2grid bus id - ls_to_orig: numpy array - for each lightsim2grid bus id, the original matpower bus number (useful to - keep on the model, mirroring `model._ls_to_orig` in the pandapower loader) - """ - bus_i = np.asarray(bus_i).astype(int) - if np.unique(bus_i).shape[0] != bus_i.shape[0]: - raise RuntimeError("Duplicated bus numbers found in `mpc.bus[:, BUS_I]`, " - "lightsim2grid cannot handle this.") - # buses keep the order they appear in mpc.bus (no need to sort: matpower bus - # numbers are opaque identifiers, not meaningful for ordering) - ls_to_orig = bus_i - mp_to_ls = {mp_id: ls_id for ls_id, mp_id in enumerate(bus_i)} - return mp_to_ls, ls_to_orig diff --git a/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py b/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py new file mode 100644 index 00000000..c69c74d0 --- /dev/null +++ b/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings + +import numpy as np + +from ._my_const import ( + BUS_I, BUS_TYPE, PD, QD, GS, BS, BASE_KV, VMAX, VMIN, + GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, + F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, TAP, SHIFT, BR_STATUS, + DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, + DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1, +) + + +def mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) -> dict: + """ + Convert raw MATPOWER-shaped `bus`/`gen`/`branch`/`dcline` matrices into a + PowerModels.jl-style network data dictionary (see + https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/), consumable by + `lightsim2grid.network.from_powermodels.init`. This is the (verified) inverse of + the conversion PowerModels.jl itself performs when parsing a matpower `.m` file + (`PowerModels.jl/src/io/matpower.jl`): loads/shunts are split off the bus rows, + `tap == 0` (matpower's "this is a plain line" sentinel) becomes `tap = 1.0` with + `transformer = False`, and angles are converted from degrees to radians. + """ + network = {"baseMVA": float(baseMVA), "bus": {}, "gen": {}, "branch": {}, "load": {}, "shunt": {}} + + for i in range(bus.shape[0]): + bus_i = int(bus[i, BUS_I]) + key = str(i + 1) + network["bus"][key] = { + "bus_i": bus_i, + "bus_type": int(bus[i, BUS_TYPE]), + "base_kv": float(bus[i, BASE_KV]), + "vmax": float(bus[i, VMAX]), + "vmin": float(bus[i, VMIN]), + } + pd, qd = float(bus[i, PD]), float(bus[i, QD]) + if pd != 0. or qd != 0.: + network["load"][key] = {"load_bus": bus_i, "pd": pd, "qd": qd, "status": 1} + gs, bs = float(bus[i, GS]), float(bus[i, BS]) + if gs != 0. or bs != 0.: + # matpower's GS/BS are MW/MVAr "at V=1pu"; PowerModels' gs/bs are per-unit + network["shunt"][key] = {"shunt_bus": bus_i, "gs": gs / baseMVA, "bs": bs / baseMVA, "status": 1} + + for i in range(gen.shape[0]): + key = str(i + 1) + network["gen"][key] = { + "gen_bus": int(gen[i, GEN_BUS]), + "pg": float(gen[i, PG]), + "qg": float(gen[i, QG]), + "qmax": float(gen[i, QMAX]), + "qmin": float(gen[i, QMIN]), + "vg": float(gen[i, VG]), + "mbase": float(gen[i, MBASE]), + "gen_status": int(gen[i, GEN_STATUS]), + "pmax": float(gen[i, PMAX]), + "pmin": float(gen[i, PMIN]), + } + + n_weird_shift = 0 + for i in range(branch.shape[0]): + key = str(i + 1) + tap = float(branch[i, TAP]) + transformer = tap != 0. + shift_degree = float(branch[i, SHIFT]) + if not transformer and shift_degree != 0.: + # matpower's own convention: a non-transformer (TAP == 0) branch has no + # meaningful phase shift; `from_matpower`'s legacy array-based loader + # silently dropped it too (it never passed SHIFT to `init_powerlines`) + n_weird_shift += 1 + shift_degree = 0. + network["branch"][key] = { + "f_bus": int(branch[i, F_BUS]), + "t_bus": int(branch[i, T_BUS]), + "br_r": float(branch[i, BR_R]), + "br_x": float(branch[i, BR_X]), + "g_fr": 0., "g_to": 0., # matpower has no line-conductance concept + "b_fr": float(branch[i, BR_B]) / 2., "b_to": float(branch[i, BR_B]) / 2., + "tap": tap if transformer else 1.0, + "shift": np.deg2rad(shift_degree), + "transformer": transformer, + "rate_a": float(branch[i, RATE_A]), + "br_status": int(branch[i, BR_STATUS]), + } + if n_weird_shift: + warnings.warn(f"{n_weird_shift} branch(es) have `TAP == 0` (so are treated as a plain " + "powerline) but a non-zero `SHIFT`, which is not physically meaningful " + "for a plain line. The `SHIFT` will be ignored for these branches.") + + if dcline.shape[0]: + network["dcline"] = {} + for i in range(dcline.shape[0]): + key = str(i + 1) + network["dcline"][key] = { + "f_bus": int(dcline[i, DC_F_BUS]), + "t_bus": int(dcline[i, DC_T_BUS]), + "br_status": int(dcline[i, DC_BR_STATUS]), + "pf": float(dcline[i, DC_PF]), # matpower keeps "pf"'s own sign, no flip needed + "vf": float(dcline[i, DC_VF]), + "vt": float(dcline[i, DC_VT]), + "qminf": float(dcline[i, DC_QMINF]), + "qmaxf": float(dcline[i, DC_QMAXF]), + "qmint": float(dcline[i, DC_QMINT]), + "qmaxt": float(dcline[i, DC_QMAXT]), + "loss0": float(dcline[i, DC_LOSS0]), + "loss1": float(dcline[i, DC_LOSS1]), + } + + return network diff --git a/lightsim2grid/network/from_matpower/initLSGrid.py b/lightsim2grid/network/from_matpower/initLSGrid.py index 332dfd1c..243cc46c 100644 --- a/lightsim2grid/network/from_matpower/initLSGrid.py +++ b/lightsim2grid/network/from_matpower/initLSGrid.py @@ -10,23 +10,25 @@ Natively parse a MATPOWER case (".m" file, ".mat" file, or already-parsed mpc dict / object) and initialize a LSGrid c++ object from it, without ever going through a pandapower or pypowsybl network. + +MATPOWER's raw matrix format is a strict subset of PowerModels.jl's network data +dictionary (no separate load/shunt/storage/dcline tables to begin with, a single +combined line-charging value, a "TAP == 0" sentinel for plain lines, angles in +degrees, ...), so this loader's only real job is to translate the raw matrices into +that richer dict and hand off to the shared, feature-complete +`lightsim2grid.network.from_powermodels` engine -- which is also what +`init_from_pfdelta` (PFΔ dataset rows) uses directly, since those are already +PowerModels dicts. """ from typing import Optional, Union import os -import numpy as np from ...lightsim2grid_cpp import LSGrid +from ..from_powermodels import init as _init_from_powermodels from ._parse_matpower_source import load_matpower_data -from ._mp_bus_to_ls_bus import build_bus_remap, mp_bus_to_ls from ._aux_check_legit import _aux_check_legit -from ._aux_add_branch import get_branch_split, _aux_add_branch -from ._aux_add_load import _aux_add_load -from ._aux_add_shunt import _aux_add_shunt -from ._aux_add_gen import _aux_add_gen -from ._aux_add_slack import _aux_add_slack -from ._aux_add_dc_line import _aux_add_dc_line -from ._my_const import BUS_I, BUS_TYPE, BASE_KV, NONE +from ._mpc_to_powermodels import mpc_to_powermodels def init(source: Union[str, "os.PathLike", dict], @@ -36,8 +38,8 @@ def init(source: Union[str, "os.PathLike", dict], Convert a MATPOWER case into a LSGrid. Unlike `lightsim2grid.network.init_from_pandapower`, this never constructs a - pandapower network: it reads MATPOWER's raw `bus` / `gen` / `branch` matrices - directly and initializes the `LSGrid` from them. In particular, several + pandapower network: it reads MATPOWER's raw `bus` / `gen` / `branch` / `dcline` + matrices directly and initializes the `LSGrid` from them. In particular, several generators connected to the same bus are all kept as independent generators (no aggregation). @@ -77,73 +79,5 @@ def init(source: Union[str, "os.PathLike", dict], bus, gen, branch, dcline, baseMVA = load_matpower_data(source) _aux_check_legit(bus, gen, branch, dcline) - mp_to_ls, ls_to_orig = build_bus_remap(bus[:, BUS_I]) - - is_trafo = get_branch_split(branch) - nb_line = int((~is_trafo).sum()) - nb_trafo = int(is_trafo.sum()) - - model = LSGrid() - model.set_sn_mva(baseMVA) - - n_sub = bus.shape[0] - if n_busbar_per_sub is None: - n_busbar_per_sub = 1 - - try: - tmp = int(n_busbar_per_sub) - except ValueError as exc_: - raise RuntimeError("Impossible to convert n_busbar_per_sub to int") from exc_ - if tmp != n_busbar_per_sub: - raise RuntimeError(f"n_busbar_per_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") - n_busbar_per_sub = tmp - if n_busbar_per_sub <= 0: - raise RuntimeError(f"You need to provide a grid with at least 1 busbar per " - f"substation / voltage level, provided n_busbar_per_sub={n_busbar_per_sub}") - - # lightsim2grid lays out global bus ids busbar-section-major: ids [0, n_sub) are - # busbar section 1 of every substation (one per matpower bus, in matpower order), - # ids [n_sub, 2*n_sub) are section 2, etc. `np.tile` replicates `bus[:, BASE_KV]` - # to match that layout. - vn_kv = np.tile(bus[:, BASE_KV], n_busbar_per_sub) - model.init_bus(n_sub, n_busbar_per_sub, vn_kv, nb_line, nb_trafo) - if n_busbar_per_sub > 1: - # extra busbar sections have no corresponding matpower bus at all - ls_to_orig = np.concatenate((ls_to_orig, - np.full(n_sub * (n_busbar_per_sub - 1), -1, dtype=int))) - model._ls_to_orig = ls_to_orig - - if n_busbar_per_sub > 1: - # matpower has no data for these extra busbar sections: nothing is connected - # to them in the base case, so deactivate them until a topology action moves - # something there - for ls_bus_id in range(n_sub, n_sub * n_busbar_per_sub): - model.deactivate_bus(ls_bus_id) - - isolated_mask = bus[:, BUS_TYPE] == NONE - if np.any(isolated_mask): - isolated_ls_bus = mp_bus_to_ls(bus[isolated_mask, BUS_I], mp_to_ls) - for ls_bus_id in isolated_ls_bus: - model.deactivate_bus(int(ls_bus_id)) - else: - isolated_ls_bus = np.array([], dtype=int) - - # init the powerlines and transformers - _aux_add_branch(model, branch, is_trafo, mp_to_ls, isolated_ls_bus) - - # init the shunts - _aux_add_shunt(model, bus, mp_to_ls, isolated_ls_bus) - - # init the loads - _aux_add_load(model, bus, mp_to_ls, isolated_ls_bus) - - # init the generators - _aux_add_gen(model, gen, mp_to_ls, isolated_ls_bus) - - # deal with the slack bus(es) - _aux_add_slack(model, bus, gen, mp_to_ls, isolated_ls_bus) - - # init the HVDC lines (mpc.dcline), if any - _aux_add_dc_line(model, dcline, mp_to_ls, isolated_ls_bus) - - return model + network = mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) + return _init_from_powermodels(network, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py b/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py deleted file mode 100644 index b0eeda8b..00000000 --- a/lightsim2grid/network/from_pf_delta/_pf_delta_to_mpc.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import numpy as np - -from ..from_matpower._my_const import ( - BUS_I, BUS_TYPE, PD, QD, GS, BS, BUS_AREA, VM, VA, BASE_KV, ZONE, VMAX, VMIN, - GEN_BUS, PG, QG, QMAX, QMIN, VG, MBASE, GEN_STATUS, PMAX, PMIN, - F_BUS, T_BUS, BR_R, BR_X, BR_B, RATE_A, RATE_B, RATE_C, TAP, SHIFT, BR_STATUS, - DC_F_BUS, DC_T_BUS, DC_BR_STATUS, DC_PF, DC_VF, DC_VT, - DC_QMINF, DC_QMAXF, DC_QMINT, DC_QMAXT, DC_LOSS0, DC_LOSS1, - MIN_BUS_COLS, MIN_GEN_COLS, MIN_BRANCH_COLS, MIN_DCLINE_COLS, -) - - -def _is_transformer(branch): - """A PowerModels branch is a transformer if either its own `transformer` flag says - so, or its `tap`/`shift` are away from the "plain line" neutral (`tap == 1.0`, - `shift == 0.0`) -- PowerModels normalizes a matpower `TAP == 0` (its own "this is a - plain line" sentinel) to `tap = 1.0`, so this loader cannot just test `tap != 0` - like `from_matpower.get_branch_split` does on a raw mpc branch matrix. - """ - return bool(branch.get("transformer", False)) or branch.get("tap", 1.0) != 1.0 or branch.get("shift", 0.0) != 0.0 - - -def network_to_mpc(network: dict) -> dict: - """ - Convert a PFΔ / PowerModels.jl ``network`` dict into a raw-MATPOWER-shaped dict - (``{"bus", "gen", "branch"}`` numpy arrays in matpower's own column layout, plus - ``"baseMVA"``) consumable by :func:`lightsim2grid.network.from_matpower.init`. - - Row order is ``sorted(network[], key=int)`` for bus / gen / branch: this is a - deterministic, documented contract (not just an implementation detail), relied upon - by tests that need to map a built `LSGrid`'s element positions back to the original - PFΔ row's bus / gen / branch keys. - """ - if network.get("storage"): - raise RuntimeError('"storage" is present in this PFΔ row but is not supported ' - "by this loader (from_matpower has no storage container either).") - if network.get("multinetwork"): - raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") - - base_mva = float(network["baseMVA"]) - - bus_keys = sorted(network["bus"], key=int) - gen_keys = sorted(network["gen"], key=int) - branch_keys = sorted(network["branch"], key=int) - - # raw matpower embeds PD / QD / GS / BS directly on the bus row; PowerModels splits - # them into separate "load" / "shunt" dicts, so they need to be aggregated back - # (only active elements contribute -- matpower has no separate "load/shunt present - # but off" column, so an inactive one is equivalent to 0 demand/admittance here) - pd = {int(bus["bus_i"]): 0.0 for bus in network["bus"].values()} - qd, gs, bs = dict(pd), dict(pd), dict(pd) - for load in network.get("load", {}).values(): - if load.get("status", 1) == 0: - continue - b = int(load["load_bus"]) - pd[b] += load["pd"] - qd[b] += load["qd"] - for shunt in network.get("shunt", {}).values(): - if shunt.get("status", 1) == 0: - continue - b = int(shunt["shunt_bus"]) - gs[b] += shunt["gs"] * base_mva - bs[b] += shunt["bs"] * base_mva - - bus = np.zeros((len(bus_keys), MIN_BUS_COLS)) - for i, k in enumerate(bus_keys): - b = network["bus"][k] - bus_i = int(b["bus_i"]) - bus[i, BUS_I] = bus_i - bus[i, BUS_TYPE] = b["bus_type"] - bus[i, PD] = pd[bus_i] - bus[i, QD] = qd[bus_i] - bus[i, GS] = gs[bus_i] - bus[i, BS] = bs[bus_i] - bus[i, BUS_AREA] = 1.0 - bus[i, VM] = 1.0 - bus[i, VA] = 0.0 - bus[i, BASE_KV] = b.get("base_kv", 1.0) - bus[i, ZONE] = 1.0 - bus[i, VMAX] = b["vmax"] - bus[i, VMIN] = b["vmin"] - - gen = np.zeros((len(gen_keys), MIN_GEN_COLS)) - for i, k in enumerate(gen_keys): - g = network["gen"][k] - gen[i, GEN_BUS] = int(g["gen_bus"]) - gen[i, PG] = g["pg"] - gen[i, QG] = g["qg"] - gen[i, QMAX] = g["qmax"] - gen[i, QMIN] = g["qmin"] - gen[i, VG] = g["vg"] - gen[i, MBASE] = g.get("mbase", base_mva) - gen[i, GEN_STATUS] = g.get("gen_status", 1) - gen[i, PMAX] = g["pmax"] - gen[i, PMIN] = g["pmin"] - - branch = np.zeros((len(branch_keys), MIN_BRANCH_COLS)) - for i, k in enumerate(branch_keys): - br = network["branch"][k] - if br.get("g_fr", 0.0) != 0.0 or br.get("g_to", 0.0) != 0.0: - raise RuntimeError(f'branch "{k}" has a non-zero "g_fr"/"g_to" (line ' - f"conductance); the raw MATPOWER format this loader " - f"delegates to cannot represent that (only susceptance).") - is_trafo = _is_transformer(br) - branch[i, F_BUS] = int(br["f_bus"]) - branch[i, T_BUS] = int(br["t_bus"]) - branch[i, BR_R] = br["br_r"] - branch[i, BR_X] = br["br_x"] - branch[i, BR_B] = br.get("b_fr", 0.0) + br.get("b_to", 0.0) - branch[i, RATE_A] = br.get("rate_a", 0.0) - branch[i, RATE_B] = br.get("rate_b", 0.0) - branch[i, RATE_C] = br.get("rate_c", 0.0) - # matpower's own "this is a plain line" sentinel is TAP == 0, which is what - # `from_matpower.get_branch_split` tests for; PowerModels never uses 0 (it - # normalizes a matpower TAP==0 to tap=1.0), so it has to be re-encoded here - branch[i, TAP] = br.get("tap", 1.0) if is_trafo else 0.0 - # PowerModels stores shift/angles in radians (converted from matpower's - # degrees at parse time); matpower's raw SHIFT column is in degrees - branch[i, SHIFT] = np.rad2deg(br.get("shift", 0.0)) - branch[i, BR_STATUS] = br.get("br_status", 1) - - mpc = {"bus": bus, "gen": gen, "branch": branch, "baseMVA": base_mva} - - if network.get("dcline"): - dcline_keys = sorted(network["dcline"], key=int) - dcline = np.zeros((len(dcline_keys), MIN_DCLINE_COLS)) - for i, k in enumerate(dcline_keys): - dc = network["dcline"][k] - dcline[i, DC_F_BUS] = int(dc["f_bus"]) - dcline[i, DC_T_BUS] = int(dc["t_bus"]) - dcline[i, DC_BR_STATUS] = dc.get("br_status", 1) - # PowerModels keeps "pf" at matpower's own value/sign (only "pt"/"qf"/"qt" - # get sign-flipped by PowerModels.jl's parser); from_matpower's own dcline - # converter is the one that negates it to match lightsim2grid's "received - # at side 1" convention, so it must be passed through here unchanged. - dcline[i, DC_PF] = dc["pf"] - dcline[i, DC_VF] = dc.get("vf", 1.0) - dcline[i, DC_VT] = dc.get("vt", 1.0) - dcline[i, DC_QMINF] = dc["qminf"] - dcline[i, DC_QMAXF] = dc["qmaxf"] - dcline[i, DC_QMINT] = dc["qmint"] - dcline[i, DC_QMAXT] = dc["qmaxt"] - dcline[i, DC_LOSS0] = dc["loss0"] - dcline[i, DC_LOSS1] = dc["loss1"] - # DC_PT / DC_QF / DC_QT / DC_PMIN / DC_PMAX are not read by - # from_matpower's dcline converter (see _aux_add_dc_line.py) -- left at 0.0 - mpc["dcline"] = dcline - - return mpc diff --git a/lightsim2grid/network/from_pf_delta/initLSGrid.py b/lightsim2grid/network/from_pf_delta/initLSGrid.py deleted file mode 100644 index 170ed8a5..00000000 --- a/lightsim2grid/network/from_pf_delta/initLSGrid.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) 2026, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -""" -Build a LSGrid from a single PFΔ (arXiv:2510.22048) dataset row: a PowerModels.jl-format -dict (MATPOWER-derived, per-unit) with a solved AC power-flow state attached. - -This loader translates the row's PowerModels ``network`` dict into a raw-MATPOWER-shaped -dict and delegates the actual `LSGrid` construction to -`lightsim2grid.network.from_matpower`, which already implements bus-id remapping, -line/transformer splitting, generator/load/shunt conversion and (possibly distributed) -slack-bus handling. -""" - -from typing import Optional, Union -import os -import json - -from ...lightsim2grid_cpp import LSGrid -from ..from_matpower import init as _init_from_matpower -from ._pf_delta_to_mpc import network_to_mpc - - -def init(row: Union[dict, str, "os.PathLike"], - n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level - ) -> LSGrid: - """ - Convert a PFΔ dataset row into a LSGrid. - - Parameters - ---------- - row: - Either a PFΔ row already parsed into a dict (with a top-level ``"network"`` - key), or a path (str or `os.PathLike`) to a ``.json`` file containing that same - structure. - n_busbar_per_sub: - There is always exactly one substation / voltage level per PFΔ bus (PFΔ has no - notion of several busbar sections within a bus, so this is not configurable). - This parameter only controls how many buses / busbar sections lightsim2grid - allocates *per substation*, which is useful if you intend to perform grid2op-like - topology actions on the resulting grid afterwards. Defaults to 1 (no extra busbar - section). Any extra busbar section is deactivated, since nothing in the base - PFΔ row is ever connected to it. Passed through directly to - `lightsim2grid.network.init_from_matpower`. - - Returns - ------- - model: :class:`lightsim2grid.network.LSGrid` - The initialized network - - """ - if isinstance(row, (str, os.PathLike)): - with open(row, "r") as f: - row = json.load(f) - - if "network" not in row: - raise RuntimeError('Expected a PFΔ row (a dict with a top-level "network" key), ' - f"got a dict with keys {sorted(row.keys())}.") - - mpc = network_to_mpc(row["network"]) - return _init_from_matpower(mpc, n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/network/from_pf_delta/__init__.py b/lightsim2grid/network/from_powermodels/__init__.py similarity index 85% rename from lightsim2grid/network/from_pf_delta/__init__.py rename to lightsim2grid/network/from_powermodels/__init__.py index df92c6dc..f536bbd2 100644 --- a/lightsim2grid/network/from_pf_delta/__init__.py +++ b/lightsim2grid/network/from_powermodels/__init__.py @@ -6,6 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__all__ = ["init"] +__all__ = ["init", "init_from_pfdelta"] from .initLSGrid import init +from ._from_pfdelta import init_from_pfdelta diff --git a/lightsim2grid/network/from_powermodels/_aux_add_branch.py b/lightsim2grid/network/from_powermodels/_aux_add_branch.py new file mode 100644 index 00000000..6fab05e0 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_branch.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def is_transformer(branch: dict) -> bool: + """A PowerModels branch is a transformer if its own `"transformer"` flag says so, + or its `"tap"` / `"shift"` are away from the "plain line" neutral (`tap == 1.0`, + `shift == 0.0`) -- PowerModels normalizes a matpower `TAP == 0` (its own "this is a + plain line" sentinel) to `tap = 1.0`, so `tap != 0` (matpower's own test) does not + apply here. + """ + return bool(branch.get("transformer", False)) or branch.get("tap", 1.0) != 1.0 or branch.get("shift", 0.0) != 0.0 + + +def classify_branches(network: dict): + """Returns `(line_keys, trafo_keys)`, each a list of `network["branch"]` string + keys, sorted by `int(key)` (this order is a documented, deterministic contract: + it is what `_aux_add_branch` below feeds `init_powerlines_full`/`init_trafo` in, + and callers -- e.g. a validation helper matching lightsim2grid's own solved + results back to the original PowerModels branch keys -- can reproduce it + independently from `network` alone). + """ + line_keys, trafo_keys = [], [] + for k in sorted(network["branch"], key=int): + (trafo_keys if is_transformer(network["branch"][k]) else line_keys).append(k) + return line_keys, trafo_keys + + +def _aux_add_branch(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the lines and transformers of `network["branch"]` into the lightsim2grid "model". + + Note + ---- + Unlike lightsim2grid's `from_matpower` (which has to route through matpower's raw + `mpc.branch` matrix, whose single `BR_B` column cannot represent asymmetric line + charging), powerlines here keep `g_fr`/`b_fr` and `g_to`/`b_to` exactly as given by + PowerModels, independently per side (`LineContainer`'s two-sided `init` overload + supports this directly). Transformers are still limited to a single, symmetrically + split charging admittance -- `TrafoContainer` has no "asymmetric" overload -- so an + asymmetric transformer's `g_fr`/`g_to` (or `b_fr`/`b_to`) is summed and re-split + 50/50, with a warning (this never loses information for MATPOWER-derived data, + where PowerModels always sets `b_fr == b_to` and `g_fr == g_to == 0`). + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + branch = network["branch"] + line_keys, trafo_keys = classify_branches(network) + + f_bus_line = pm_bus_to_ls(np.array([int(branch[k]["f_bus"]) for k in line_keys]), pm_to_ls) + t_bus_line = pm_bus_to_ls(np.array([int(branch[k]["t_bus"]) for k in line_keys]), pm_to_ls) + r = np.array([branch[k]["br_r"] for k in line_keys]) + x = np.array([branch[k]["br_x"] for k in line_keys]) + h_or = np.array([complex(branch[k].get("g_fr", 0.), branch[k].get("b_fr", 0.)) for k in line_keys]) + h_ex = np.array([complex(branch[k].get("g_to", 0.), branch[k].get("b_to", 0.)) for k in line_keys]) + model.init_powerlines_full(r, x, h_or, h_ex, f_bus_line, t_bus_line) + + line_status = np.array([branch[k].get("br_status", 1) for k in line_keys]) != 0 + if isolated_ls_bus.size: + line_status &= ~np.isin(f_bus_line, isolated_ls_bus) + line_status &= ~np.isin(t_bus_line, isolated_ls_bus) + for line_id, is_ok in enumerate(line_status): + if not is_ok: + model.deactivate_powerline(line_id) + + f_bus_trafo = pm_bus_to_ls(np.array([int(branch[k]["f_bus"]) for k in trafo_keys]), pm_to_ls) + t_bus_trafo = pm_bus_to_ls(np.array([int(branch[k]["t_bus"]) for k in trafo_keys]), pm_to_ls) + trafo_r = np.array([branch[k]["br_r"] for k in trafo_keys]) + trafo_x = np.array([branch[k]["br_x"] for k in trafo_keys]) + asymmetric = [k for k in trafo_keys + if branch[k].get("g_fr", 0.) != branch[k].get("g_to", 0.) + or branch[k].get("b_fr", 0.) != branch[k].get("b_to", 0.)] + if asymmetric: + warnings.warn(f"{len(asymmetric)} transformer(s) have an asymmetric charging " + f"admittance (\"g_fr\"/\"b_fr\" != \"g_to\"/\"b_to\"), which " + "lightsim2grid's transformer model cannot represent (only a single, " + "symmetrically-split value): the total charging admittance is kept, " + "but re-split 50/50 between the two sides.") + trafo_b = np.array([complex(branch[k].get("g_fr", 0.) + branch[k].get("g_to", 0.), + branch[k].get("b_fr", 0.) + branch[k].get("b_to", 0.)) + for k in trafo_keys]) + trafo_ratio = np.array([branch[k].get("tap", 1.0) for k in trafo_keys]) + # PowerModels stores angles in radians; `init_trafo` expects degrees + trafo_shift_degree = np.rad2deg([branch[k].get("shift", 0.0) for k in trafo_keys]) + trafo_tap_hv = [True] * len(trafo_keys) # PowerModels always applies tap/shift on the "from" side + model.init_trafo(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, + f_bus_trafo, t_bus_trafo, True) + + trafo_status = np.array([branch[k].get("br_status", 1) for k in trafo_keys]) != 0 + if isolated_ls_bus.size: + trafo_status &= ~np.isin(f_bus_trafo, isolated_ls_bus) + trafo_status &= ~np.isin(t_bus_trafo, isolated_ls_bus) + for trafo_id, is_ok in enumerate(trafo_status): + if not is_ok: + model.deactivate_trafo(trafo_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_dc_line.py b/lightsim2grid/network/from_powermodels/_aux_add_dc_line.py new file mode 100644 index 00000000..ebb030e5 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_dc_line.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_dc_line(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the HVDC lines of `network["dcline"]` into the lightsim2grid "model", using + the same simplified (station-loss-free, resistance-free) dc line model already + used by lightsim2grid's pandapower and matpower loaders (`model.init_dclines`). + + Note + ---- + PowerModels.jl keeps `"pf"` at matpower's own value/sign when parsing a matpower + case (only `"pt"`/`"qf"`/`"qt"` get sign-flipped by PowerModels.jl's parser, see + `PowerModels.jl/src/io/matpower.jl`'s `_mp2pm_dcline!`), so the same sign + convention as lightsim2grid's `from_matpower` loader applies: `"pf"` is the MW + flow "from" -> "to" measured at the `"f_bus"` end (positive means the `"f_bus"` + side sends power into the line), while `model.init_dclines` expects the power + *received* at side 1 (the `"f_bus"` side) when positive, so `"pf"` must be negated. + + The loss model is `pt = pf - (loss0 + loss1 * pf)` with `loss1` a fraction of + `pf`, while lightsim2grid/pandapower's `loss_percent` expresses that same + coefficient as a percentage (`loss1 * 100`) and `loss_mw` is `loss0` directly. + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + dcline = network.get("dcline", {}) + if not dcline: + return + + dc_keys = sorted(dcline, key=int) + f_bus = pm_bus_to_ls(np.array([int(dcline[k]["f_bus"]) for k in dc_keys]), pm_to_ls) + t_bus = pm_bus_to_ls(np.array([int(dcline[k]["t_bus"]) for k in dc_keys]), pm_to_ls) + + p_mw = -np.array([dcline[k]["pf"] for k in dc_keys]) + loss_percent = 100. * np.array([dcline[k]["loss1"] for k in dc_keys]) + loss_mw = np.array([dcline[k]["loss0"] for k in dc_keys]) + vf = np.array([dcline[k].get("vf", 1.0) for k in dc_keys]) + vt = np.array([dcline[k].get("vt", 1.0) for k in dc_keys]) + qminf = np.array([dcline[k]["qminf"] for k in dc_keys]) + qmaxf = np.array([dcline[k]["qmaxf"] for k in dc_keys]) + qmint = np.array([dcline[k]["qmint"] for k in dc_keys]) + qmaxt = np.array([dcline[k]["qmaxt"] for k in dc_keys]) + + model.init_dclines(f_bus, t_bus, p_mw, loss_percent, loss_mw, vf, vt, + qminf, qmaxf, qmint, qmaxt) + + status = np.array([dcline[k].get("br_status", 1) for k in dc_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(f_bus, isolated_ls_bus) + status &= ~np.isin(t_bus, isolated_ls_bus) + for dc_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_dcline(dc_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_gen.py b/lightsim2grid/network/from_powermodels/_aux_add_gen.py new file mode 100644 index 00000000..4e2d4313 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_gen.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_gen(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the generators of `network["gen"]` into the lightsim2grid "model". Several + generators can share the same `"gen_bus"`: they are all kept as independent + generators (no aggregation), matching lightsim2grid's own `GeneratorContainer` + (no uniqueness constraint on a generator's bus id). + + PowerModels has no field equivalent to lightsim2grid's `voltage_regulator_on`: + every generator is assumed to regulate voltage (standard powerflow convention). + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + gen = network.get("gen", {}) + if not gen: + return + + gen_keys = sorted(gen, key=int) + gen_bus = pm_bus_to_ls(np.array([int(gen[k]["gen_bus"]) for k in gen_keys]), pm_to_ls) + pg = np.array([gen[k]["pg"] for k in gen_keys]) + vg = np.array([gen[k]["vg"] for k in gen_keys]) + qg = np.array([gen[k].get("qg", 0.0) for k in gen_keys]) + qmin = np.array([gen[k]["qmin"] for k in gen_keys]) + qmax = np.array([gen[k]["qmax"] for k in gen_keys]) + voltage_regulator_on = [True] * len(gen_keys) + model.init_generators_full(pg, vg, qg, voltage_regulator_on, qmin, qmax, gen_bus) + + gen_status = np.array([gen[k].get("gen_status", 1) for k in gen_keys]) > 0 + if isolated_ls_bus.size: + gen_status &= ~np.isin(gen_bus, isolated_ls_bus) + for gen_id, is_ok in enumerate(gen_status): + if not is_ok: + model.deactivate_gen(gen_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_load.py b/lightsim2grid/network/from_powermodels/_aux_add_load.py new file mode 100644 index 00000000..7a6fdbfb --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_load.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_load(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the loads of `network["load"]` into the lightsim2grid "model". + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + load = network.get("load", {}) + if not load: + return + + load_keys = sorted(load, key=int) + load_bus = pm_bus_to_ls(np.array([int(load[k]["load_bus"]) for k in load_keys]), pm_to_ls) + pd = np.array([load[k]["pd"] for k in load_keys]) + qd = np.array([load[k]["qd"] for k in load_keys]) + model.init_loads(pd, qd, load_bus) + + status = np.array([load[k].get("status", 1) for k in load_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(load_bus, isolated_ls_bus) + for load_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_load(load_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_shunt.py b/lightsim2grid/network/from_powermodels/_aux_add_shunt.py new file mode 100644 index 00000000..21c786d3 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_shunt.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_shunt(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the shunts of `network["shunt"]` into the lightsim2grid "model". + + Note + ---- + PowerModels' shunt admittance is `Y = gs + j*bs` (per-unit, standard convention, + see https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/ and + PowerModels.jl's own `src/core/data.jl`: `p_delta += gs*vm^2; q_delta -= bs*vm^2`). + lightsim2grid's `ShuntContainer` instead computes `Y_pu = (p_mw - j*q_mvar)/sn_mva` + (see its own `// TODO check the sign here for p_mw, it is suspicious!` comment). + Equating the two gives `p_mw = gs*sn_mva`, `q_mvar = -bs*sn_mva` -- the same sign + flip lightsim2grid's `from_matpower` loader already applies to matpower's `BS` + (verified independently, twice, against that loader's own docstring and code). + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + shunt = network.get("shunt", {}) + if not shunt: + return + + sn_mva = float(network["baseMVA"]) + shunt_keys = sorted(shunt, key=int) + shunt_bus = pm_bus_to_ls(np.array([int(shunt[k]["shunt_bus"]) for k in shunt_keys]), pm_to_ls) + p_mw = np.array([shunt[k]["gs"] for k in shunt_keys]) * sn_mva + q_mvar = -np.array([shunt[k]["bs"] for k in shunt_keys]) * sn_mva + model.init_shunt(p_mw, q_mvar, shunt_bus) + + status = np.array([shunt[k].get("status", 1) for k in shunt_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(shunt_bus, isolated_ls_bus) + for sh_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_shunt(sh_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_slack.py b/lightsim2grid/network/from_powermodels/_aux_add_slack.py new file mode 100644 index 00000000..5f177d2a --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_slack.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings +import numpy as np + +from ._my_const import REF +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_slack(model, network, pm_to_ls, isolated_ls_bus): + """ + Resolve the slack bus(es) from `network["bus"][k]["bus_type"] == 3` (the + PowerModels/matpower reference bus) and assign every in-service generator + connected to it as a (possibly distributed) slack generator, with equal weight. + + Note + ---- + Unlike pandapower (which has a separate `ext_grid` table to fall back on), + PowerModels/matpower has no fallback: if the reference bus has no in-service + generator connected to it, there is no slack source and a `RuntimeError` is raised. + + Several generators can be connected to the reference bus: they are all assigned as + slack, each with weight 1.0 (equal-weight distributed slack), consistent with the + `add_gen_slackbus` weighting convention already used by the pandapower loader. + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + bus = network["bus"] + ref_keys = [k for k, b in bus.items() if b.get("bus_type") == REF] + if len(ref_keys) == 0: + warnings.warn("No bus with `bus_type == 3` (reference bus) found. lightsim2grid " + "could not assign any slack bus, you will need to do it manually " + "with `model.add_gen_slackbus(...)`.") + return + if len(ref_keys) > 1: + warnings.warn(f"{len(ref_keys)} buses found with `bus_type == 3` (reference bus), " + "PowerModels/matpower normally expects a single one. All of them will " + "be considered, with equal weights.") + + ref_bus_pm = np.array([int(bus[k]["bus_i"]) for k in ref_keys]) + ref_bus_ls = pm_bus_to_ls(ref_bus_pm, pm_to_ls) + + gen = network.get("gen", {}) + if not gen: + raise RuntimeError("No generator found at all, so no slack bus can be assigned.") + + gen_keys = sorted(gen, key=int) + gen_bus_ls = pm_bus_to_ls(np.array([int(gen[k]["gen_bus"]) for k in gen_keys]), pm_to_ls) + gen_in_service = np.array([gen[k].get("gen_status", 1) for k in gen_keys]) > 0 + if isolated_ls_bus.size: + gen_in_service = gen_in_service & ~np.isin(gen_bus_ls, isolated_ls_bus) + + is_slack_gen = np.isin(gen_bus_ls, ref_bus_ls) & gen_in_service + slack_gen_ids = np.where(is_slack_gen)[0] + if slack_gen_ids.size == 0: + raise RuntimeError(f"Could not find any in-service generator connected to the reference " + f"bus(es) {ref_bus_pm.tolist()}. PowerModels/matpower has no separate " + "slack-bus fallback (unlike pandapower's `ext_grid`): lightsim2grid " + "needs at least one in-service generator at the reference bus to use " + "as slack.") + + for gen_id in slack_gen_ids: + model.add_gen_slackbus(int(gen_id), 1.0) diff --git a/lightsim2grid/network/from_powermodels/_aux_add_storage.py b/lightsim2grid/network/from_powermodels/_aux_add_storage.py new file mode 100644 index 00000000..39f5fff2 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_add_storage.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._bus_remap import pm_bus_to_ls + + +def _aux_add_storage(model, network, pm_to_ls, isolated_ls_bus): + """ + Add the storage units of `network["storage"]` into the lightsim2grid "model". + + Note + ---- + PowerModels' `"ps"`/`"qs"` are "Active/Reactive power withdrawn" (same load-like + sign convention as `"pd"`/`"qd"`), matching lightsim2grid's own `init_storages` + convention exactly (`LoadContainer`'s doc: "positive storage: the unit is + charging, power is taken from the grid"), so no sign conversion is needed -- + unlike shunts. Energy/rating/efficiency/inverter-impedance fields have no + equivalent in lightsim2grid's (loss-free, rating-free) storage model, matching + how `from_pandapower`'s own storage loader already ignores pandapower's own + storage ratings. + + Parameters + ---------- + model + network: dict + The PowerModels network data dictionary + pm_to_ls: dict + PowerModels bus number (`"bus_i"`) -> lightsim2grid bus id + isolated_ls_bus: numpy array + lightsim2grid bus ids of isolated (`bus_type == 4`) buses + + """ + storage = network.get("storage", {}) + if not storage: + return + + storage_keys = sorted(storage, key=int) + storage_bus = pm_bus_to_ls(np.array([int(storage[k]["storage_bus"]) for k in storage_keys]), pm_to_ls) + ps = np.array([storage[k]["ps"] for k in storage_keys]) + qs = np.array([storage[k].get("qs", 0.0) for k in storage_keys]) + model.init_storages(ps, qs, storage_bus) + + status = np.array([storage[k].get("status", 1) for k in storage_keys]) != 0 + if isolated_ls_bus.size: + status &= ~np.isin(storage_bus, isolated_ls_bus) + for st_id, is_ok in enumerate(status): + if not is_ok: + model.deactivate_storage(st_id) diff --git a/lightsim2grid/network/from_powermodels/_aux_check_legit.py b/lightsim2grid/network/from_powermodels/_aux_check_legit.py new file mode 100644 index 00000000..652e1015 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_aux_check_legit.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings + +from ._my_const import NONE + + +def _aux_check_legit(network): + """ + Check that this PowerModels network data dictionary can be handled by lightsim2grid. + + Parameters + ---------- + network: dict + The PowerModels network data dictionary + + """ + if network.get("multinetwork"): + raise RuntimeError("multinetwork PowerModels dicts are not supported by this loader.") + + n_isolated = sum(1 for bus in network["bus"].values() if bus.get("bus_type") == NONE) + if n_isolated: + warnings.warn(f"{n_isolated} bus(es) have `bus_type == {NONE}` (isolated). They will be " + "deactivated, along with any load, shunt, generator, storage, dcline or " + "branch side still connected to them.") diff --git a/lightsim2grid/network/from_powermodels/_bus_remap.py b/lightsim2grid/network/from_powermodels/_bus_remap.py new file mode 100644 index 00000000..93ab1865 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_bus_remap.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + + +def pm_bus_to_ls(pm_bus_id, pm_to_ls_converter): + """Map PowerModels bus numbers (``"bus_i"``, arbitrary integers, not necessarily + contiguous / 0-based) to lightsim2grid contiguous 0-based bus ids, given the dict + built by `build_bus_remap`. + """ + return np.array([pm_to_ls_converter[pm_id] for pm_id in pm_bus_id], dtype=int) + + +def build_bus_remap(bus_keys, network): + """ + Build the mapping from PowerModels bus number (``network["bus"][k]["bus_i"]``, + arbitrary integers, not necessarily contiguous / 0-based / sorted) to a contiguous + 0-based lightsim2grid bus id. + + Parameters + ---------- + bus_keys: list of str + ``network["bus"]`` dict keys, in the order lightsim2grid buses should be laid + out (see `_aux_check_legit`/`initLSGrid.init`: ``sorted(network["bus"], key=int)``) + network: dict + The PowerModels network data dictionary + + Returns + ------- + pm_to_ls: dict + mapping from PowerModels bus number (``bus_i``) to lightsim2grid bus id + ls_to_orig: numpy array + for each lightsim2grid bus id, the original PowerModels bus number (useful to + keep on the model, mirroring `model._ls_to_orig` in the pandapower loader) + """ + bus_i = np.array([int(network["bus"][k]["bus_i"]) for k in bus_keys], dtype=int) + if np.unique(bus_i).shape[0] != bus_i.shape[0]: + raise RuntimeError('Duplicated "bus_i" values found in this PowerModels network, ' + "lightsim2grid cannot handle this.") + ls_to_orig = bus_i + pm_to_ls = {pm_id: ls_id for ls_id, pm_id in enumerate(bus_i)} + return pm_to_ls, ls_to_orig diff --git a/lightsim2grid/network/from_powermodels/_from_pfdelta.py b/lightsim2grid/network/from_powermodels/_from_pfdelta.py new file mode 100644 index 00000000..8df9145a --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_from_pfdelta.py @@ -0,0 +1,53 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Build a LSGrid from a single PFΔ (arXiv:2510.22048) dataset row: a dict wrapping a +PowerModels.jl network data dictionary (under a `"network"` key) alongside its solved +AC power-flow state (under a `"solution"` key). The network data itself is plain +PowerModels.jl, so this is a thin wrapper around `lightsim2grid.network.from_powermodels.init`. +""" + +from typing import Optional, Union +import os +import json + +from ...lightsim2grid_cpp import LSGrid +from .initLSGrid import init as _init_from_powermodels + + +def init_from_pfdelta(row: Union[dict, str, "os.PathLike"], + n_busbar_per_sub: Optional[int] = None, + ) -> LSGrid: + """ + Convert a PFΔ dataset row into a LSGrid. + + Parameters + ---------- + row: + Either a PFΔ row already parsed into a dict (with a top-level `"network"` + key), or a path (str or `os.PathLike`) to a `.json` file containing that same + structure. + n_busbar_per_sub: + Passed through directly to `lightsim2grid.network.init_from_powermodels`. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + if isinstance(row, (str, os.PathLike)): + with open(row, "r") as f: + row = json.load(f) + + if "network" not in row: + raise RuntimeError('Expected a PFΔ row (a dict with a top-level "network" key), ' + f"got a dict with keys {sorted(row.keys())}.") + + return _init_from_powermodels(row["network"], n_busbar_per_sub=n_busbar_per_sub) diff --git a/lightsim2grid/network/from_powermodels/_my_const.py b/lightsim2grid/network/from_powermodels/_my_const.py new file mode 100644 index 00000000..f3896b28 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/_my_const.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +# bus type constants, matching PowerModels.jl / MATPOWER's convention +# (https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/) +PQ, PV, REF, NONE = 1, 2, 3, 4 diff --git a/lightsim2grid/network/from_powermodels/initLSGrid.py b/lightsim2grid/network/from_powermodels/initLSGrid.py new file mode 100644 index 00000000..a2917f81 --- /dev/null +++ b/lightsim2grid/network/from_powermodels/initLSGrid.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Natively parse a PowerModels.jl network data dictionary (see +https://lanl-ansi.github.io/PowerModels.jl/stable/network-data/) and initialize a +LSGrid c++ object from it directly (bus, branch, gen, load, shunt, storage and dcline), +without ever going through a raw MATPOWER matrix or a pandapower/pypowsybl network. + +This is the shared, feature-complete engine other loaders build on: `from_matpower` +converts its raw `bus`/`gen`/`branch`/`dcline` matrices into a PowerModels-style dict +and delegates here, and `from_powermodels.init_from_pfdelta` (PFΔ dataset rows, see +arXiv:2510.22048) unwraps its row wrapper and delegates here directly, since PFΔ rows +already are PowerModels dicts. +""" + +from typing import Optional +import numpy as np + +from ...lightsim2grid_cpp import LSGrid +from ._bus_remap import build_bus_remap, pm_bus_to_ls +from ._aux_check_legit import _aux_check_legit +from ._aux_add_branch import _aux_add_branch, classify_branches +from ._aux_add_load import _aux_add_load +from ._aux_add_shunt import _aux_add_shunt +from ._aux_add_storage import _aux_add_storage +from ._aux_add_gen import _aux_add_gen +from ._aux_add_slack import _aux_add_slack +from ._aux_add_dc_line import _aux_add_dc_line +from ._my_const import NONE + + +def init(network: dict, + n_busbar_per_sub: Optional[int] = None, # max number of buses allowed per substation / voltage level + ) -> LSGrid: + """ + Convert a PowerModels.jl network data dictionary into a LSGrid. + + Parameters + ---------- + network: dict + A PowerModels network data dictionary (the top-level dict with `"bus"` / + `"branch"` / `"gen"` / ... keys -- *not* a full PFΔ dataset row, which wraps + this dict under a `"network"` key; use `init_from_pfdelta` for that). + n_busbar_per_sub: + There is always exactly one substation / voltage level per PowerModels bus + (PowerModels has no notion of several busbar sections within a bus, so this is + not configurable). This parameter only controls how many buses / busbar + sections lightsim2grid allocates *per substation*, which is useful if you + intend to perform grid2op-like topology actions on the resulting grid + afterwards. Defaults to 1 (no extra busbar section). Any extra busbar section + is deactivated, since nothing in the base network is ever connected to it. + + Returns + ------- + model: :class:`lightsim2grid.network.LSGrid` + The initialized network + + """ + _aux_check_legit(network) + + bus_keys = sorted(network["bus"], key=int) + pm_to_ls, ls_to_orig = build_bus_remap(bus_keys, network) + + line_keys, trafo_keys = classify_branches(network) + nb_line, nb_trafo = len(line_keys), len(trafo_keys) + + model = LSGrid() + model.set_sn_mva(float(network["baseMVA"])) + + n_sub = len(bus_keys) + if n_busbar_per_sub is None: + n_busbar_per_sub = 1 + try: + tmp = int(n_busbar_per_sub) + except ValueError as exc_: + raise RuntimeError("Impossible to convert n_busbar_per_sub to int") from exc_ + if tmp != n_busbar_per_sub: + raise RuntimeError(f"n_busbar_per_sub should be a int, you provided {tmp} which cannot safely be converted to an int.") + n_busbar_per_sub = tmp + if n_busbar_per_sub <= 0: + raise RuntimeError(f"You need to provide a grid with at least 1 busbar per " + f"substation / voltage level, provided n_busbar_per_sub={n_busbar_per_sub}") + + # lightsim2grid lays out global bus ids busbar-section-major: ids [0, n_sub) are + # busbar section 1 of every substation (one per PowerModels bus, in `bus_keys` + # order), ids [n_sub, 2*n_sub) are section 2, etc. + base_kv = np.array([network["bus"][k].get("base_kv", 1.0) for k in bus_keys]) + vn_kv = np.tile(base_kv, n_busbar_per_sub) + model.init_bus(n_sub, n_busbar_per_sub, vn_kv, nb_line, nb_trafo) + if n_busbar_per_sub > 1: + # extra busbar sections have no corresponding PowerModels bus at all + ls_to_orig = np.concatenate((ls_to_orig, + np.full(n_sub * (n_busbar_per_sub - 1), -1, dtype=int))) + model._ls_to_orig = ls_to_orig + + if n_busbar_per_sub > 1: + # nothing is connected to these extra busbar sections in the base case, so + # deactivate them until a topology action moves something there + for ls_bus_id in range(n_sub, n_sub * n_busbar_per_sub): + model.deactivate_bus(ls_bus_id) + + isolated_keys = [k for k in bus_keys if network["bus"][k].get("bus_type") == NONE] + if isolated_keys: + isolated_bus_i = np.array([int(network["bus"][k]["bus_i"]) for k in isolated_keys]) + isolated_ls_bus = pm_bus_to_ls(isolated_bus_i, pm_to_ls) + for ls_bus_id in isolated_ls_bus: + model.deactivate_bus(int(ls_bus_id)) + else: + isolated_ls_bus = np.array([], dtype=int) + + # init the powerlines and transformers + _aux_add_branch(model, network, pm_to_ls, isolated_ls_bus) + + # init the shunts + _aux_add_shunt(model, network, pm_to_ls, isolated_ls_bus) + + # init the loads + _aux_add_load(model, network, pm_to_ls, isolated_ls_bus) + + # init the storage units + _aux_add_storage(model, network, pm_to_ls, isolated_ls_bus) + + # init the generators + _aux_add_gen(model, network, pm_to_ls, isolated_ls_bus) + + # deal with the slack bus(es) + _aux_add_slack(model, network, pm_to_ls, isolated_ls_bus) + + # init the HVDC lines, if any + _aux_add_dc_line(model, network, pm_to_ls, isolated_ls_bus) + + return model diff --git a/lightsim2grid/tests/test_LSGrid_pf_delta.py b/lightsim2grid/tests/test_LSGrid_pf_delta.py index ad7a9f9b..817ec891 100644 --- a/lightsim2grid/tests/test_LSGrid_pf_delta.py +++ b/lightsim2grid/tests/test_LSGrid_pf_delta.py @@ -6,32 +6,18 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -import copy import json import os import unittest import numpy as np -from lightsim2grid.network import init_from_pf_delta -from lightsim2grid.network.from_pf_delta._pf_delta_to_mpc import network_to_mpc -from lightsim2grid.network.from_matpower._aux_add_branch import get_branch_split +from lightsim2grid.network import init_from_pf_delta, init_from_powermodels +from lightsim2grid.network.from_powermodels._aux_add_branch import classify_branches _FIXTURE_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pf_delta_case14.json") -def _branch_split_keys(network): - """Reproduces, from the row's `network` dict alone, which branch keys end up as - lines vs. transformers and in what order -- by reusing `network_to_mpc` (the exact - function `init_from_pf_delta` uses) and `from_matpower`'s own classification, so - this never drifts out of sync with the loader itself.""" - branch_keys = sorted(network["branch"], key=int) - is_trafo = get_branch_split(network_to_mpc(network)["branch"]) - line_keys = [k for k, t in zip(branch_keys, is_trafo) if not t] - trafo_keys = [k for k, t in zip(branch_keys, is_trafo) if t] - return line_keys, trafo_keys - - def validate_against_row(model, row, tol=1e-5): """Compares `model`'s CURRENT (already solved) AC state against the PFΔ row's own solved `solution`. Does not run any powerflow itself. Returns a dict with the max @@ -41,7 +27,7 @@ def validate_against_row(model, row, tol=1e-5): solution = row["solution"]["solution"] bus_keys = sorted(network["bus"], key=int) - line_keys, trafo_keys = _branch_split_keys(network) + line_keys, trafo_keys = classify_branches(network) vm = model.get_Vm() va = model.get_Va() @@ -87,7 +73,7 @@ def setUp(self): def test_counts(self): network = self.row["network"] - line_keys, trafo_keys = _branch_split_keys(network) + line_keys, trafo_keys = classify_branches(network) assert len(self.model.get_lines()) == len(line_keys) assert len(self.model.get_trafos()) == len(trafo_keys) assert len(self.model.get_generators()) == len(network["gen"]) @@ -99,7 +85,7 @@ def test_fixture_exercises_taps_and_shunt(self): # shunt absent) the AC powerflow test below would not actually be exercising # the tap-ratio / shunt-sign conversions network = self.row["network"] - _, trafo_keys = _branch_split_keys(network) + _, trafo_keys = classify_branches(network) assert len(trafo_keys) > 0, "fixture has no transformers, tap conversion untested" assert any(network["branch"][k]["tap"] != 1.0 for k in trafo_keys) assert len(network.get("shunt", {})) > 0, "fixture has no shunt, sign convention untested" @@ -120,20 +106,59 @@ def test_accepts_path(self): Vfinal = model.ac_pf(np.full(nb_bus, 1.0, dtype=complex), 10, 1e-8) assert Vfinal.shape[0] > 0, "powerflow diverged" + def test_init_from_powermodels_on_bare_network_dict(self): + # init_from_pf_delta is a thin wrapper unwrapping row["network"] and calling + # init_from_powermodels directly -- both should behave identically + model = init_from_powermodels(self.row["network"]) + assert len(model.get_lines()) == len(self.model.get_lines()) + assert len(model.get_trafos()) == len(self.model.get_trafos()) + nb_bus = len(self.row["network"]["bus"]) + Vfinal = model.ac_pf(np.full(nb_bus, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + def test_missing_network_key_raises(self): with self.assertRaises(RuntimeError): init_from_pf_delta(self.row["network"]) - def test_nonzero_g_fr_raises(self): - row = copy.deepcopy(self.row) - first_branch_key = next(iter(row["network"]["branch"])) - row["network"]["branch"][first_branch_key]["g_fr"] = 0.01 - with self.assertRaises(RuntimeError): - init_from_pf_delta(row) - def test_shift_is_converted_from_radians_to_degrees(self): - # case14 has no phase shifters (shift == 0 everywhere), so this exercises the - # rad -> deg conversion directly on a small synthetic network instead +class TestLSGridPFDelta(BaseTests, unittest.TestCase): + pass + + +class TestPFDeltaBranchConductance(unittest.TestCase): + """Unlike `init_from_matpower` (which routes through matpower's raw `mpc.branch`, + whose single `BR_B` column cannot represent line conductance at all), + `init_from_powermodels` builds lines directly from `"g_fr"`/`"g_to"`, so an + asymmetric, non-zero line conductance should convert and solve without issue.""" + + @staticmethod + def _network(g_fr=0.0, g_to=0.0): + return { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 10.0, "qmin": -10.0, + "vg": 1.0, "pmax": 100.0, "pmin": 0.0, "gen_status": 1}}, + "load": {"1": {"load_bus": 2, "pd": 10.0, "qd": 2.0, "status": 1}}, + "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, + "g_fr": g_fr, "g_to": g_to, "br_status": 1}}, + } + + def test_asymmetric_line_conductance_converts_and_solves(self): + model = init_from_powermodels(self._network(g_fr=0.001, g_to=0.002)) + assert len(model.get_lines()) == 1 + Vfinal = model.ac_pf(np.full(2, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + +class TestPFDeltaShiftConversion(unittest.TestCase): + """case14 has no phase shifters (`shift == 0` everywhere in the real pglib case), + so this exercises the radians -> degrees -> radians round trip through the + `init_trafo` API boundary directly on a small synthetic network instead.""" + + def test_shift_round_trips_through_init_trafo(self): + shift_rad = np.pi / 6 network = { "baseMVA": 100.0, "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, @@ -142,15 +167,58 @@ def test_shift_is_converted_from_radians_to_degrees(self): "vg": 1.0, "pmax": 100.0, "pmin": 0.0, "gen_status": 1}}, "load": {}, "shunt": {}, "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, - "tap": 1.05, "shift": np.pi / 6, "br_status": 1}}, + "tap": 1.05, "shift": shift_rad, "br_status": 1}}, } - mpc = network_to_mpc(network) - np.testing.assert_allclose(mpc["branch"][0, 9], 30.0) # SHIFT column, degrees + model = init_from_powermodels(network) + trafos = model.get_trafos() + assert len(trafos) == 1 + assert abs(trafos[0].shift_rad - shift_rad) <= 1e-8 + + +class TestPFDeltaStorage(unittest.TestCase): + """PFΔ's own pglib-derived cases never contain a `"storage"` entry, but + PowerModels' schema supports one and lightsim2grid itself supports storage + (unlike `from_matpower`'s raw mpc format, which has no storage table at all), so + this exercises that conversion path directly.""" + + @staticmethod + def _network_with_storage(status=1): + return { + "baseMVA": 100.0, + "bus": {"1": {"bus_i": 1, "bus_type": 3, "vmax": 1.1, "vmin": 0.9}, + "2": {"bus_i": 2, "bus_type": 1, "vmax": 1.1, "vmin": 0.9}}, + "gen": {"1": {"gen_bus": 1, "pg": 0.0, "qg": 0.0, "qmax": 100.0, "qmin": -100.0, + "vg": 1.0, "pmax": 200.0, "pmin": 0.0, "gen_status": 1}}, + "load": {"1": {"load_bus": 2, "pd": 10.0, "qd": 2.0, "status": 1}}, + "shunt": {}, + "branch": {"1": {"f_bus": 1, "t_bus": 2, "br_r": 0.01, "br_x": 0.1, "br_status": 1}}, + "storage": {"1": {"storage_bus": 2, "ps": 5.0, "qs": 1.0, "status": status}}, + } + + def test_no_storage_key_at_all(self): + network = self._network_with_storage() + del network["storage"] + model = init_from_powermodels(network) + assert len(model.get_storages()) == 0 + + def test_storage_is_converted(self): + model = init_from_powermodels(self._network_with_storage()) + storages = model.get_storages() + assert len(storages) == 1 + # PowerModels' "ps"/"qs" are load-convention (withdrawn=positive), matching + # lightsim2grid's own init_storages convention directly -- no sign flip + Vfinal = model.ac_pf(np.full(2, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" + + def test_deactivated_storage(self): + model = init_from_powermodels(self._network_with_storage(status=0)) + Vfinal = model.ac_pf(np.full(2, 1.0, dtype=complex), 10, 1e-8) + assert Vfinal.shape[0] > 0, "powerflow diverged" class TestPFDeltaDCLine(unittest.TestCase): """PFΔ's own pglib-derived cases (case14/30/57/118/500/2000) never contain a - `"dcline"` entry, but PowerModels' schema supports one and `from_matpower` now + `"dcline"` entry, but PowerModels' schema supports one and lightsim2grid now converts it (`model.init_dclines`), so this exercises that translation path directly rather than leaving it silently unsupported.""" @@ -170,7 +238,7 @@ def _network_with_dcline(): "2": {"f_bus": 2, "t_bus": 3, "br_r": 0.01, "br_x": 0.1, "br_status": 1}}, # PowerModels keeps "pf" at matpower's own sign, but negates "pt"/"qf"/"qt"; # only f_bus/t_bus/br_status/pf/loss0/loss1/vf/vt/qmin*/qmax* are actually - # consumed by from_matpower's converter (see _aux_add_dc_line.py) + # consumed by the converter (see from_powermodels/_aux_add_dc_line.py) "dcline": {"1": {"f_bus": 2, "t_bus": 3, "pf": 10.0, "pt": -9.0, "qf": 0.0, "qt": 0.0, "vf": 1.0, "vt": 1.0, "qminf": -10.0, "qmaxf": 10.0, "qmint": -10.0, "qmaxt": 10.0, "loss0": 0.5, "loss1": 0.05, @@ -206,9 +274,5 @@ def test_deactivated_dcline(self): assert Vfinal.shape[0] > 0, "powerflow diverged" -class TestLSGridPFDelta(BaseTests, unittest.TestCase): - pass - - if __name__ == "__main__": unittest.main() From 36d2d2cc881d058d59871c9578d31e1588961a57 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 12:28:30 +0200 Subject: [PATCH 033/166] lots of improvements still Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 33 +++++++++++++++++++ src/bindings/python/binding_batch.cpp | 6 +++- src/bindings/python/binding_lsgrid.cpp | 15 +++++++++ src/core/LSGrid.hpp | 9 +++++ .../batch_algorithm/BaseBatchSolverSynch.cpp | 4 +-- .../batch_algorithm/BaseBatchSolverSynch.hpp | 11 +++++-- .../batch_algorithm/ContingencyAnalysis.cpp | 10 +++--- src/core/batch_algorithm/LimitViolation.hpp | 5 +++ src/core/batch_algorithm/TimeSeries.cpp | 4 +-- .../element_container/GenericContainer.hpp | 6 +++- 10 files changed, 90 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 57f81461..c5108a9c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -145,6 +145,12 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. does not converge (eg reaches `max_iter`). This only applies to `get_violations()` / `get_violations_n()` -- see the `ContingencyAnalysisCPP.compute()` change above for the pre-contingency ("n") case itself. +- [ADDED] `LimitViolation.name`: the name of the LINE / TRAFO element the violation was + detected on, if names were configured on the grid (`LSGrid.set_line_names` / + `set_trafo_names`); empty string otherwise, and for `BUS` / `GRID` violations (there is no + per-bus name in `LSGrid`, only per-substation ones via `get_substation_names()`). Also adds + `LSGrid.get_line_names()` / `get_trafo_names()` getters (previously write-only) and + `GenericContainer::get_names()` c++ side. - [FIXED] `ContingencyAnalysisCPP` / `TimeSeriesCPP` (and so the python `ContingencyAnalysis`, `SecurityAnalysis` and `TimeSerie` wrappers) solved every powerflow with a fresh, independent, *default* solver (`NR_SparseLU`, no scaling/damping policy), completely ignoring whatever @@ -157,6 +163,33 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. construction time; `get_algo_config()` / `set_algo_config()` were added to `ContingencyAnalysisCPP` / `TimeSeriesCPP` to re-apply a config afterwards (eg after `change_algorithm()`, which resets to that algorithm's defaults). +- [FIXED] the pre-contingency ("n") powerflow inside `ContingencyAnalysisCPP.compute()` and the + initial powerflow inside `TimeSeriesCPP.compute_Vs()` (both go through the shared + `BaseBatchSolverSynch::_finish_preprocessing`) compared the NR mismatch against the raw, + un-scaled `tol` argument, instead of `tol / sn_mva` like every other powerflow in the codebase + (`LSGrid::ac_pf`, and even the *per-contingency* / per-step solves in these same two classes): + `Sbus_` is already per-unit (`sn_mva`-divided, same convention as `LSGrid::ac_pf`'s `acSbus_`), + so comparing it against a physical-MW `tol` accepted a mismatch up to `sn_mva` times (eg 100x) + looser than what the caller asked for on this one call. +- [FIXED] every powerflow solved by `ContingencyAnalysisCPP` and `TimeSeriesCPP` (the + pre-contingency / initial "n" solve in `BaseBatchSolverSynch::_finish_preprocessing` and + `::warmup_solver`, every per-contingency solve in `ContingencyAnalysis::run_contingency_range`, + and every per-step solve in `TimeSeries::compute_Vs`) passed `slack_ids_me_` -- the slack bus ids + in *gridmodel* (global) numbering -- to `AlgorithmSelector::compute_pf` / `compute_pf_dc`, which + expect *solver-space* numbering (matching `Ybus_` / `Sbus_` / `bus_pv_` / `bus_pq_`'s own + indexing), exactly like `LSGrid::ac_pf` correctly passes its `slack_bus_id_ac_solver_`. The + correct member, `slack_ids_solver_`, was computed by `pre_process_solver` but never actually + used anywhere. On a grid with any deactivated bus, `id_me_to_solver` compacts the numbering, so + global bus ids run higher than (and do not correspond to) valid solver-space indices -- silently + picking the wrong buses as the NR angle reference, up to reading out of bounds on a large grid + (reproduced as a segfault while isolating this bug). This was invisible on small, fully-connected + test grids (global id == solver id there, no compaction), which is why the existing test suite + never caught it. This was the actual cause of the pre-contingency `SolverFactor` divergence + reported and investigated above: on a real large RTE grid, `ContingencyAnalysisCPP.compute()`'s + "n" solve diverged while `LSGrid.ac_pf()` called directly on an extracted, verified-bit-identical + copy (same `Vinit` / `Ybus_` / `Sbus_` / `bus_pv_` / `bus_pq_` / algorithm / `AlgoConfig` / `tol`) + converged fine; fixing the slack ids resolves it (confirmed convergent, all contingencies, on two + different real grids). - [IMPROVED] (cpp) the codebase now compiles warning-free under ``-Wall -Wextra -Werror``: fixed 29 signed/unsigned comparison warnings (``int`` / ``Eigen::Index`` vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index e14b3f53..5239f5c2 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -44,7 +44,11 @@ void bind_batch(py::module_& m) { .def_readonly("value", &LimitViolation::value, "value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE") .def_readonly("limit", &LimitViolation::limit, - "limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE"); + "limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE") + .def_readonly("name", &LimitViolation::name, + "element name (LINE / TRAFO only, see LSGrid.set_line_names / set_trafo_names) ; " + "empty string if names were never set on the grid, or for BUS / GRID (no per-bus " + "name exists in LSGrid, only per-substation ones)"); py::class_(m, "TimeSeriesCPP", DocComputers::Computers.c_str()) .def(py::init()) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index a1e866eb..0eb0db25 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -152,8 +152,10 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` // names .def("set_line_names", &LSGrid::set_line_names, "TODO") + .def("get_line_names", &LSGrid::get_line_names, "Names of the powerlines, as set by `set_line_names`; empty if never set.") .def("set_dcline_names", &LSGrid::set_dcline_names, "TODO") .def("set_trafo_names", &LSGrid::set_trafo_names, "TODO") + .def("get_trafo_names", &LSGrid::get_trafo_names, "Names of the transformers, as set by `set_trafo_names`; empty if never set.") .def("set_line_current_limit_side1", &LSGrid::set_line_current_limit_side1, "Set the side-1 current limit of each powerline, in kA (see `limit_a1_ka` on `LineInfo`).") .def("set_line_current_limit_side2", &LSGrid::set_line_current_limit_side2, @@ -329,9 +331,22 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("get_line_res1", &LSGrid::get_line_res1, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_line_res2", &LSGrid::get_line_res2, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_lines_status", &LSGrid::get_lines_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) + .def("get_lines_status_side1", &LSGrid::get_lines_status_side1, + "Per-side status of each powerline's side 1 (relevant for half-open lines: " + "get_lines_status() is True as soon as either side is connected).", + py::return_value_policy::reference) + .def("get_lines_status_side2", &LSGrid::get_lines_status_side2, + "Per-side status of each powerline's side 2, see get_lines_status_side1().", + py::return_value_policy::reference) .def("get_trafo_res1", &LSGrid::get_trafo_res1, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_trafo_res2", &LSGrid::get_trafo_res2, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_trafo_status", &LSGrid::get_trafo_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) + .def("get_trafo_status_side1", &LSGrid::get_trafo_status_side1, + "Per-side status of each transformer's side 1, see get_lines_status_side1().", + py::return_value_policy::reference) + .def("get_trafo_status_side2", &LSGrid::get_trafo_status_side2, + "Per-side status of each transformer's side 2, see get_lines_status_side1().", + py::return_value_policy::reference) .def("get_storages_res", &LSGrid::get_storages_res, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_storages_status", &LSGrid::get_storages_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_sgens_res", &LSGrid::get_sgens_res, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 829b4f44..ce24a8b6 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -635,6 +635,7 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, powerlines_.nb(), "set_line_names"); powerlines_.set_names(names); } + const std::vector & get_line_names() const {return powerlines_.get_names();} void set_dcline_names(const std::vector & names){ GenericContainer::check_size(names, hvdc_lines_.nb(), "set_dcline_names"); hvdc_lines_.set_names(names); @@ -643,6 +644,7 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, trafos_.nb(), "set_trafo_names"); trafos_.set_names(names); } + const std::vector & get_trafo_names() const {return trafos_.get_names();} // per-side current limit, in kA, optional: empty if never set void set_line_current_limit_side1(const RealVect & limit_a1_ka){ GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_current_limit_side1"); @@ -1010,9 +1012,16 @@ class LS2G_API LSGrid final tuple4d get_line_res1() const {return powerlines_.get_res_side_1();} tuple4d get_line_res2() const {return powerlines_.get_res_side_2();} const std::vector& get_lines_status() const { return powerlines_.get_status_global();} + // per-side status (relevant for half-open lines, see `set_synch_status_both_side` / + // `keep_half_open_lines`): `get_lines_status()` is the *global* status (both sides + // disconnected), these report each side independently. + const std::vector& get_lines_status_side1() const { return powerlines_.get_status_side_1();} + const std::vector& get_lines_status_side2() const { return powerlines_.get_status_side_2();} tuple4d get_trafo_res1() const {return trafos_.get_res_side_1();} tuple4d get_trafo_res2() const {return trafos_.get_res_side_2();} const std::vector& get_trafo_status() const { return trafos_.get_status_global();} + const std::vector& get_trafo_status_side1() const { return trafos_.get_status_side_1();} + const std::vector& get_trafo_status_side2() const { return trafos_.get_status_side_2();} tuple3d get_storages_res() const {return storages_.get_res();} const std::vector& get_storages_status() const { return storages_.get_status();} tuple3d get_sgens_res() const {return sgens_.get_res();} diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index c86e5191..6fdbb618 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -104,7 +104,7 @@ bool BaseBatchSolverSynch::warmup_solver( Ybus_, Vinit_solver, Sbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), @@ -115,7 +115,7 @@ bool BaseBatchSolverSynch::warmup_solver( Bbus_, Vinit_solver, Pbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen()); diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index fb2c33de..d53e6b2f 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -395,22 +395,27 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants CplxVect Vinit_solver2 = Vinit_solver; bool conv; if(_algo.ac_solver_used()){ + // Sbus_ is already per-unit (pre_process_solver / fillSbus_me divides by + // sn_mva when != 1), same convention as LSGrid::ac_pf's acSbus_ -- so tol + // (a physical MW/MVAr tolerance) must be converted the same way LSGrid::ac_pf + // does (`tol / sn_mva_`), or this initial solve accepts a per-unit mismatch + // up to sn_mva times looser than what the caller asked for. conv = _algo.compute_pf( Ybus_, Vinit_solver2, Sbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), max_iter, - tol); + tol / _grid_model.get_sn_mva()); } else { conv = _algo.compute_pf_dc( Bbus_, Vinit_solver2, Pbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen()); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 4d930fa5..6b836823 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -92,6 +92,7 @@ void check_current_violations( const auto & el_status = structure_data.get_status_global(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); + const std::vector & el_names = structure_data.get_names(); // empty if never set Eigen::Ref yac_eff_11 = structure_data.yac_eff_11(); Eigen::Ref yac_eff_12 = structure_data.yac_eff_12(); @@ -170,13 +171,14 @@ void check_current_violations( amps2 = std::abs(p_to) / (sqrt_3 * v_to_kv); } + const std::string el_name = el_id < el_names.size() ? el_names[el_id] : std::string(); if(has_lim1 && amps1 > limit1(el_idx)){ out.push_back(LimitViolation{el_type, static_cast(el_id), 1, - LimitViolationType::CURRENT, amps1, limit1(el_idx)}); + LimitViolationType::CURRENT, amps1, limit1(el_idx), el_name}); } if(has_lim2 && amps2 > limit2(el_idx)){ out.push_back(LimitViolation{el_type, static_cast(el_id), 2, - LimitViolationType::CURRENT, amps2, limit2(el_idx)}); + LimitViolationType::CURRENT, amps2, limit2(el_idx), el_name}); } } } @@ -778,7 +780,7 @@ void ContingencyAnalysis::run_contingency_range( conv = compute_one_powerflow( algo, control, nb_solved, timer_solver, Ybus, V, Sbus_, - slack_ids_me_.as_eigen(), sw, + slack_ids_solver_.as_eigen(), sw, bus_pv_.as_eigen(), bus_pq_.as_eigen(), max_iter, tol / sn_mva); if(needs_solver_init){ @@ -816,7 +818,7 @@ void ContingencyAnalysis::run_contingency_range( Ybus, V, Sbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), diff --git a/src/core/batch_algorithm/LimitViolation.hpp b/src/core/batch_algorithm/LimitViolation.hpp index 6c9844f4..065876b8 100644 --- a/src/core/batch_algorithm/LimitViolation.hpp +++ b/src/core/batch_algorithm/LimitViolation.hpp @@ -10,6 +10,7 @@ #define LIMITVIOLATION_H #include "Utils.hpp" +#include namespace ls2g { @@ -38,6 +39,10 @@ struct LS2G_API LimitViolation { LimitViolationType violation_type; real_type value; // value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE real_type limit; // limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE + // element name (LINE / TRAFO only, from LSGrid::set_line_names / set_trafo_names) ; empty + // string if the grid never had names set for this element type, or for BUS / GRID (no + // per-bus name exists in LSGrid, only per-substation ones) + std::string name{}; }; } // namespace ls2g diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index bb449a72..2156bc43 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -92,9 +92,9 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, for(size_t i = 0; i < nb_steps; ++i){ conv = false; conv = compute_one_powerflow(Ybus_, - V, + V, _Sbuses.row(i), - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index 42f8e583..f6d1054b 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -113,7 +113,11 @@ class LS2G_API GenericContainer : public BaseConstants void set_names(const std::vector & names){ names_ = names; } - + // empty if set_names() was never called on this container + const std::vector & get_names() const { + return names_; + } + /**"define" the destructor for compliance with clang (otherwise lots of warnings)**/ GenericContainer() noexcept = default; virtual ~GenericContainer() noexcept = default; From 40ef2f621056c9857043f23f484b88bea4389770 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 12:28:30 +0200 Subject: [PATCH 034/166] lots of improvements still Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 33 +++++++++++++++++++ src/bindings/python/binding_batch.cpp | 6 +++- src/bindings/python/binding_lsgrid.cpp | 15 +++++++++ src/core/LSGrid.hpp | 9 +++++ .../batch_algorithm/BaseBatchSolverSynch.cpp | 4 +-- .../batch_algorithm/BaseBatchSolverSynch.hpp | 11 +++++-- .../batch_algorithm/ContingencyAnalysis.cpp | 10 +++--- src/core/batch_algorithm/LimitViolation.hpp | 5 +++ src/core/batch_algorithm/TimeSeries.cpp | 4 +-- .../element_container/GenericContainer.hpp | 6 +++- 10 files changed, 90 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 57f81461..c5108a9c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -145,6 +145,12 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. does not converge (eg reaches `max_iter`). This only applies to `get_violations()` / `get_violations_n()` -- see the `ContingencyAnalysisCPP.compute()` change above for the pre-contingency ("n") case itself. +- [ADDED] `LimitViolation.name`: the name of the LINE / TRAFO element the violation was + detected on, if names were configured on the grid (`LSGrid.set_line_names` / + `set_trafo_names`); empty string otherwise, and for `BUS` / `GRID` violations (there is no + per-bus name in `LSGrid`, only per-substation ones via `get_substation_names()`). Also adds + `LSGrid.get_line_names()` / `get_trafo_names()` getters (previously write-only) and + `GenericContainer::get_names()` c++ side. - [FIXED] `ContingencyAnalysisCPP` / `TimeSeriesCPP` (and so the python `ContingencyAnalysis`, `SecurityAnalysis` and `TimeSerie` wrappers) solved every powerflow with a fresh, independent, *default* solver (`NR_SparseLU`, no scaling/damping policy), completely ignoring whatever @@ -157,6 +163,33 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. construction time; `get_algo_config()` / `set_algo_config()` were added to `ContingencyAnalysisCPP` / `TimeSeriesCPP` to re-apply a config afterwards (eg after `change_algorithm()`, which resets to that algorithm's defaults). +- [FIXED] the pre-contingency ("n") powerflow inside `ContingencyAnalysisCPP.compute()` and the + initial powerflow inside `TimeSeriesCPP.compute_Vs()` (both go through the shared + `BaseBatchSolverSynch::_finish_preprocessing`) compared the NR mismatch against the raw, + un-scaled `tol` argument, instead of `tol / sn_mva` like every other powerflow in the codebase + (`LSGrid::ac_pf`, and even the *per-contingency* / per-step solves in these same two classes): + `Sbus_` is already per-unit (`sn_mva`-divided, same convention as `LSGrid::ac_pf`'s `acSbus_`), + so comparing it against a physical-MW `tol` accepted a mismatch up to `sn_mva` times (eg 100x) + looser than what the caller asked for on this one call. +- [FIXED] every powerflow solved by `ContingencyAnalysisCPP` and `TimeSeriesCPP` (the + pre-contingency / initial "n" solve in `BaseBatchSolverSynch::_finish_preprocessing` and + `::warmup_solver`, every per-contingency solve in `ContingencyAnalysis::run_contingency_range`, + and every per-step solve in `TimeSeries::compute_Vs`) passed `slack_ids_me_` -- the slack bus ids + in *gridmodel* (global) numbering -- to `AlgorithmSelector::compute_pf` / `compute_pf_dc`, which + expect *solver-space* numbering (matching `Ybus_` / `Sbus_` / `bus_pv_` / `bus_pq_`'s own + indexing), exactly like `LSGrid::ac_pf` correctly passes its `slack_bus_id_ac_solver_`. The + correct member, `slack_ids_solver_`, was computed by `pre_process_solver` but never actually + used anywhere. On a grid with any deactivated bus, `id_me_to_solver` compacts the numbering, so + global bus ids run higher than (and do not correspond to) valid solver-space indices -- silently + picking the wrong buses as the NR angle reference, up to reading out of bounds on a large grid + (reproduced as a segfault while isolating this bug). This was invisible on small, fully-connected + test grids (global id == solver id there, no compaction), which is why the existing test suite + never caught it. This was the actual cause of the pre-contingency `SolverFactor` divergence + reported and investigated above: on a real large RTE grid, `ContingencyAnalysisCPP.compute()`'s + "n" solve diverged while `LSGrid.ac_pf()` called directly on an extracted, verified-bit-identical + copy (same `Vinit` / `Ybus_` / `Sbus_` / `bus_pv_` / `bus_pq_` / algorithm / `AlgoConfig` / `tol`) + converged fine; fixing the slack ids resolves it (confirmed convergent, all contingencies, on two + different real grids). - [IMPROVED] (cpp) the codebase now compiles warning-free under ``-Wall -Wextra -Werror``: fixed 29 signed/unsigned comparison warnings (``int`` / ``Eigen::Index`` vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index e14b3f53..5239f5c2 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -44,7 +44,11 @@ void bind_batch(py::module_& m) { .def_readonly("value", &LimitViolation::value, "value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE") .def_readonly("limit", &LimitViolation::limit, - "limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE"); + "limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE") + .def_readonly("name", &LimitViolation::name, + "element name (LINE / TRAFO only, see LSGrid.set_line_names / set_trafo_names) ; " + "empty string if names were never set on the grid, or for BUS / GRID (no per-bus " + "name exists in LSGrid, only per-substation ones)"); py::class_(m, "TimeSeriesCPP", DocComputers::Computers.c_str()) .def(py::init()) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index a1e866eb..0eb0db25 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -152,8 +152,10 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` // names .def("set_line_names", &LSGrid::set_line_names, "TODO") + .def("get_line_names", &LSGrid::get_line_names, "Names of the powerlines, as set by `set_line_names`; empty if never set.") .def("set_dcline_names", &LSGrid::set_dcline_names, "TODO") .def("set_trafo_names", &LSGrid::set_trafo_names, "TODO") + .def("get_trafo_names", &LSGrid::get_trafo_names, "Names of the transformers, as set by `set_trafo_names`; empty if never set.") .def("set_line_current_limit_side1", &LSGrid::set_line_current_limit_side1, "Set the side-1 current limit of each powerline, in kA (see `limit_a1_ka` on `LineInfo`).") .def("set_line_current_limit_side2", &LSGrid::set_line_current_limit_side2, @@ -329,9 +331,22 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("get_line_res1", &LSGrid::get_line_res1, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_line_res2", &LSGrid::get_line_res2, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_lines_status", &LSGrid::get_lines_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) + .def("get_lines_status_side1", &LSGrid::get_lines_status_side1, + "Per-side status of each powerline's side 1 (relevant for half-open lines: " + "get_lines_status() is True as soon as either side is connected).", + py::return_value_policy::reference) + .def("get_lines_status_side2", &LSGrid::get_lines_status_side2, + "Per-side status of each powerline's side 2, see get_lines_status_side1().", + py::return_value_policy::reference) .def("get_trafo_res1", &LSGrid::get_trafo_res1, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_trafo_res2", &LSGrid::get_trafo_res2, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_trafo_status", &LSGrid::get_trafo_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) + .def("get_trafo_status_side1", &LSGrid::get_trafo_status_side1, + "Per-side status of each transformer's side 1, see get_lines_status_side1().", + py::return_value_policy::reference) + .def("get_trafo_status_side2", &LSGrid::get_trafo_status_side2, + "Per-side status of each transformer's side 2, see get_lines_status_side1().", + py::return_value_policy::reference) .def("get_storages_res", &LSGrid::get_storages_res, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_storages_status", &LSGrid::get_storages_status, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) .def("get_sgens_res", &LSGrid::get_sgens_res, DocLSGrid::_internal_do_not_use.c_str(), py::return_value_policy::reference) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 829b4f44..ce24a8b6 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -635,6 +635,7 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, powerlines_.nb(), "set_line_names"); powerlines_.set_names(names); } + const std::vector & get_line_names() const {return powerlines_.get_names();} void set_dcline_names(const std::vector & names){ GenericContainer::check_size(names, hvdc_lines_.nb(), "set_dcline_names"); hvdc_lines_.set_names(names); @@ -643,6 +644,7 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, trafos_.nb(), "set_trafo_names"); trafos_.set_names(names); } + const std::vector & get_trafo_names() const {return trafos_.get_names();} // per-side current limit, in kA, optional: empty if never set void set_line_current_limit_side1(const RealVect & limit_a1_ka){ GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_current_limit_side1"); @@ -1010,9 +1012,16 @@ class LS2G_API LSGrid final tuple4d get_line_res1() const {return powerlines_.get_res_side_1();} tuple4d get_line_res2() const {return powerlines_.get_res_side_2();} const std::vector& get_lines_status() const { return powerlines_.get_status_global();} + // per-side status (relevant for half-open lines, see `set_synch_status_both_side` / + // `keep_half_open_lines`): `get_lines_status()` is the *global* status (both sides + // disconnected), these report each side independently. + const std::vector& get_lines_status_side1() const { return powerlines_.get_status_side_1();} + const std::vector& get_lines_status_side2() const { return powerlines_.get_status_side_2();} tuple4d get_trafo_res1() const {return trafos_.get_res_side_1();} tuple4d get_trafo_res2() const {return trafos_.get_res_side_2();} const std::vector& get_trafo_status() const { return trafos_.get_status_global();} + const std::vector& get_trafo_status_side1() const { return trafos_.get_status_side_1();} + const std::vector& get_trafo_status_side2() const { return trafos_.get_status_side_2();} tuple3d get_storages_res() const {return storages_.get_res();} const std::vector& get_storages_status() const { return storages_.get_status();} tuple3d get_sgens_res() const {return sgens_.get_res();} diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index c86e5191..6fdbb618 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -104,7 +104,7 @@ bool BaseBatchSolverSynch::warmup_solver( Ybus_, Vinit_solver, Sbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), @@ -115,7 +115,7 @@ bool BaseBatchSolverSynch::warmup_solver( Bbus_, Vinit_solver, Pbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen()); diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index fb2c33de..d53e6b2f 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -395,22 +395,27 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants CplxVect Vinit_solver2 = Vinit_solver; bool conv; if(_algo.ac_solver_used()){ + // Sbus_ is already per-unit (pre_process_solver / fillSbus_me divides by + // sn_mva when != 1), same convention as LSGrid::ac_pf's acSbus_ -- so tol + // (a physical MW/MVAr tolerance) must be converted the same way LSGrid::ac_pf + // does (`tol / sn_mva_`), or this initial solve accepts a per-unit mismatch + // up to sn_mva times looser than what the caller asked for. conv = _algo.compute_pf( Ybus_, Vinit_solver2, Sbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), max_iter, - tol); + tol / _grid_model.get_sn_mva()); } else { conv = _algo.compute_pf_dc( Bbus_, Vinit_solver2, Pbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen()); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 4d930fa5..6b836823 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -92,6 +92,7 @@ void check_current_violations( const auto & el_status = structure_data.get_status_global(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); + const std::vector & el_names = structure_data.get_names(); // empty if never set Eigen::Ref yac_eff_11 = structure_data.yac_eff_11(); Eigen::Ref yac_eff_12 = structure_data.yac_eff_12(); @@ -170,13 +171,14 @@ void check_current_violations( amps2 = std::abs(p_to) / (sqrt_3 * v_to_kv); } + const std::string el_name = el_id < el_names.size() ? el_names[el_id] : std::string(); if(has_lim1 && amps1 > limit1(el_idx)){ out.push_back(LimitViolation{el_type, static_cast(el_id), 1, - LimitViolationType::CURRENT, amps1, limit1(el_idx)}); + LimitViolationType::CURRENT, amps1, limit1(el_idx), el_name}); } if(has_lim2 && amps2 > limit2(el_idx)){ out.push_back(LimitViolation{el_type, static_cast(el_id), 2, - LimitViolationType::CURRENT, amps2, limit2(el_idx)}); + LimitViolationType::CURRENT, amps2, limit2(el_idx), el_name}); } } } @@ -778,7 +780,7 @@ void ContingencyAnalysis::run_contingency_range( conv = compute_one_powerflow( algo, control, nb_solved, timer_solver, Ybus, V, Sbus_, - slack_ids_me_.as_eigen(), sw, + slack_ids_solver_.as_eigen(), sw, bus_pv_.as_eigen(), bus_pq_.as_eigen(), max_iter, tol / sn_mva); if(needs_solver_init){ @@ -816,7 +818,7 @@ void ContingencyAnalysis::run_contingency_range( Ybus, V, Sbus_, - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), diff --git a/src/core/batch_algorithm/LimitViolation.hpp b/src/core/batch_algorithm/LimitViolation.hpp index 6c9844f4..065876b8 100644 --- a/src/core/batch_algorithm/LimitViolation.hpp +++ b/src/core/batch_algorithm/LimitViolation.hpp @@ -10,6 +10,7 @@ #define LIMITVIOLATION_H #include "Utils.hpp" +#include namespace ls2g { @@ -38,6 +39,10 @@ struct LS2G_API LimitViolation { LimitViolationType violation_type; real_type value; // value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE real_type limit; // limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE + // element name (LINE / TRAFO only, from LSGrid::set_line_names / set_trafo_names) ; empty + // string if the grid never had names set for this element type, or for BUS / GRID (no + // per-bus name exists in LSGrid, only per-substation ones) + std::string name{}; }; } // namespace ls2g diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index bb449a72..2156bc43 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -92,9 +92,9 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, for(size_t i = 0; i < nb_steps; ++i){ conv = false; conv = compute_one_powerflow(Ybus_, - V, + V, _Sbuses.row(i), - slack_ids_me_.as_eigen(), + slack_ids_solver_.as_eigen(), slack_weights_, bus_pv_.as_eigen(), bus_pq_.as_eigen(), diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index 42f8e583..f6d1054b 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -113,7 +113,11 @@ class LS2G_API GenericContainer : public BaseConstants void set_names(const std::vector & names){ names_ = names; } - + // empty if set_names() was never called on this container + const std::vector & get_names() const { + return names_; + } + /**"define" the destructor for compliance with clang (otherwise lots of warnings)**/ GenericContainer() noexcept = default; virtual ~GenericContainer() noexcept = default; From 2f201c515e4921f675196d71a69f7fbeab847558 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 15:33:30 +0200 Subject: [PATCH 035/166] add serializing of algorithm config Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 11 ++++ docs/conf.py | 2 +- lightsim2grid/__init__.py | 2 +- .../tests/test_binary_serialization.py | 30 +++++++++ lightsim2grid/tests/test_pickleable.py | 32 +++++++++ src/core/LSGrid.cpp | 66 ++++++++++++------- src/core/LSGrid.hpp | 36 +++++++++- 7 files changed, 152 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c5108a9c..138d7ca6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -342,6 +342,17 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). +- [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same + `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the + per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- + see `get_ac_algo_config()`/`set_ac_algo_config()` and the DC counterparts): only the + coarse `AlgorithmType` enum (eg `NR_SparseLU`) round-tripped, so any custom tuning + applied via `set_ac_algo_config()`/`set_dc_algo_config()` reverted to the solver's + defaults after a save/load or pickle round trip. Both AC and DC `AlgoConfig` are now + part of `LSGrid::StateRes` and restored after the algorithm itself is selected on + `set_state()` (mirroring the existing copy-constructor behavior). `LSGrid::StateRes` + tuple positions are now named (`LSGrid::SUBSTATION_ID`, `LSGrid::HVDC_ID`, etc.) + instead of raw integer literals in `get_state()`/`set_state()`. - [ADDED] `init_from_pypowsybl` now reads and exposes operating limits: per-bus min/max voltage (`LSGrid.get_bus_vmin_kv()` / `get_bus_vmax_kv()`, from the source voltage levels' `low_voltage_limit` / `high_voltage_limit`) and per-side branch thermal diff --git a/docs/conf.py b/docs/conf.py index df96192a..9bae68cb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'Benjamin DONNOT' # The full version, including alpha/beta/rc tags -release = "1.0.0.rc1" +release = "1.0.0.rc2" version = '1.0' # -- General configuration --------------------------------------------------- diff --git a/lightsim2grid/__init__.py b/lightsim2grid/__init__.py index f3049a73..7b4ac7dc 100644 --- a/lightsim2grid/__init__.py +++ b/lightsim2grid/__init__.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__version__ = "1.0.0.rc1" +__version__ = "1.0.0.rc2" __all__ = [ "newtonpf", diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 150c1cee..7442c66d 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -188,6 +188,36 @@ def test_binary_hvdc(self): def test_binary_svcs(self): self._aux_test_binary("get_svcs", _compare_svcs) + def test_binary_algo_config(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + + ac_cfg = grid.get_ac_algo_config() + ac_cfg.int_params = [1, 2, 3, 4] + ac_cfg.real_params = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + grid.set_ac_algo_config(ac_cfg) + # DC solver (DC_SparseLU) does not populate AlgoConfig at all (only the + # NRAlgo family does): get_config()/set_config() are BaseAlgo's no-op. + # Read back whatever is actually applied rather than assuming the + # values above took effect, so this only tests that the round trip + # preserves whatever the config actually is. + dc_cfg = grid.get_dc_algo_config() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_binary.lsb") + grid.save_binary(path) + grid_1 = type(grid).load_binary(path) + + ac_cfg_1 = grid_1.get_ac_algo_config() + assert list(ac_cfg_1.int_params) == list(ac_cfg.int_params) + assert np.allclose(ac_cfg_1.real_params, ac_cfg.real_params) + + dc_cfg_1 = grid_1.get_dc_algo_config() + assert list(dc_cfg_1.int_params) == list(dc_cfg.int_params) + assert np.allclose(dc_cfg_1.real_params, dc_cfg.real_params) + def test_cannot_load_wrong_version(self): with warnings.catch_warnings(): warnings.filterwarnings("ignore") diff --git a/lightsim2grid/tests/test_pickleable.py b/lightsim2grid/tests/test_pickleable.py index f62dc522..4e8de0f7 100644 --- a/lightsim2grid/tests/test_pickleable.py +++ b/lightsim2grid/tests/test_pickleable.py @@ -146,6 +146,38 @@ def test_save_load(self): self.aux_test_2sides(self.env.backend._grid, backend_1._grid, True) self.aux_test_1side(self.env.backend._grid, backend_1._grid, True) + def test_pickle_algo_config(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + + ac_cfg = grid.get_ac_algo_config() + ac_cfg.int_params = [1, 2, 3, 4] + ac_cfg.real_params = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + grid.set_ac_algo_config(ac_cfg) + # DC solver (DC_SparseLU) does not populate AlgoConfig at all (only the + # NRAlgo family does): get_config()/set_config() are BaseAlgo's no-op. + # Read back whatever is actually applied rather than assuming the + # values above took effect, so this only tests that the round trip + # preserves whatever the config actually is. + dc_cfg = grid.get_dc_algo_config() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_pickle_algo_config.pickle") + with open(path, "wb") as f: + pickle.dump(grid, f) + with open(path, "rb") as f: + grid_1 = pickle.load(f) + + ac_cfg_1 = grid_1.get_ac_algo_config() + assert list(ac_cfg_1.int_params) == list(ac_cfg.int_params) + assert np.allclose(ac_cfg_1.real_params, ac_cfg.real_params) + + dc_cfg_1 = grid_1.get_dc_algo_config() + assert list(dc_cfg_1.int_params) == list(dc_cfg.int_params) + assert np.allclose(dc_cfg_1.real_params, dc_cfg.real_params) + def test_cannot_load_unfit_ls_version(self): tmpdir = Path(".") / "old_pickle" with self.assertRaises((ImportError, AttributeError)): diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index c7b3936e..cefdd5fd 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -87,6 +87,11 @@ LSGrid::StateRes LSGrid::get_state() const auto res_hvdc_line = hvdc_lines_.get_state(); auto res_svc = svcs_.get_state(); + AlgoConfig ac_algo_cfg = get_ac_algo_config(); + AlgoConfig dc_algo_cfg = get_dc_algo_config(); + auto res_ac_algo_cfg = std::make_tuple(ac_algo_cfg.int_params, ac_algo_cfg.real_params); + auto res_dc_algo_cfg = std::make_tuple(dc_algo_cfg.int_params, dc_algo_cfg.real_params); + LSGrid::StateRes res(version_major, version_medium, version_minor, @@ -103,9 +108,11 @@ LSGrid::StateRes LSGrid::get_state() const res_sgen, res_storage, res_hvdc_line, + res_svc, get_algo_type(), get_dc_algo_type(), - res_svc + res_ac_algo_cfg, + res_dc_algo_cfg ); return res; }; @@ -119,9 +126,9 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) compute_results_ = true; // extract data from the state - std::string version_major = std::get<0>(my_state); - std::string version_medium = std::get<1>(my_state); - std::string version_minor = std::get<2>(my_state); + std::string version_major = std::get(my_state); + std::string version_medium = std::get(my_state); + std::string version_minor = std::get(my_state); if((version_major != VERSION_MAJOR )| (version_medium != VERSION_MEDIUM) | (version_minor != VERSION_MINOR)) { std::ostringstream exc_; @@ -132,33 +139,35 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) exc_ << "It is not possible. Please reinstall it."; throw std::runtime_error(exc_.str()); } - const std::vector & ls_to_pp = std::get<3>(my_state); - init_vm_pu_ = std::get<4>(my_state); - sn_mva_ = std::get<5>(my_state); - // const std::vector & bus_vn_kv = std::get<6>(my_state); - const std::vector & last_bus_status_saved = std::get<6>(my_state); - SubstationContainer::StateRes & state_substations = std::get<7>(my_state); + const std::vector & ls_to_pp = std::get(my_state); + init_vm_pu_ = std::get(my_state); + sn_mva_ = std::get(my_state); + const std::vector & last_bus_status_saved = std::get(my_state); + SubstationContainer::StateRes & state_substations = std::get(my_state); // powerlines - LineContainer::StateRes & state_lines = std::get<8>(my_state); + LineContainer::StateRes & state_lines = std::get(my_state); // shunts - ShuntContainer::StateRes & state_shunts = std::get<9>(my_state); + ShuntContainer::StateRes & state_shunts = std::get(my_state); // trafos - TrafoContainer::StateRes & state_trafos = std::get<10>(my_state); + TrafoContainer::StateRes & state_trafos = std::get(my_state); // generators // total_q_min_per_bus_; // total_q_max_per_bus_; // total_gen_per_bus_; - GeneratorContainer::StateRes & state_gens = std::get<11>(my_state); + GeneratorContainer::StateRes & state_gens = std::get(my_state); // loads - LoadContainer::StateRes & state_loads = std::get<12>(my_state); + LoadContainer::StateRes & state_loads = std::get(my_state); // static gen - SGenContainer::StateRes & state_sgens= std::get<13>(my_state); + SGenContainer::StateRes & state_sgens= std::get(my_state); // storage units - StorageContainer::StateRes & state_storages = std::get<14>(my_state); + StorageContainer::StateRes & state_storages = std::get(my_state); // hvdc lines - HvdcLineContainer::StateRes & state_hvdc_lines = std::get<15>(my_state); - // static var compensators (index 16/17 are the ac/dc algo types) - SvcContainer::StateRes & state_svcs = std::get<18>(my_state); + HvdcLineContainer::StateRes & state_hvdc_lines = std::get(my_state); + // static var compensators + SvcContainer::StateRes & state_svcs = std::get(my_state); + // algo configs (scaling/refactor/line-search params) + auto & state_ac_algo_cfg = std::get(my_state); + auto & state_dc_algo_cfg = std::get(my_state); // substations last_bus_status_saved_ = last_bus_status_saved; @@ -196,8 +205,21 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // handle the solver reset(true, true, true); - _algo.change_algorithm(std::get<16>(my_state)); - _dc_algo.change_algorithm(std::get<17>(my_state)); + _algo.change_algorithm(std::get(my_state)); + _dc_algo.change_algorithm(std::get(my_state)); + + // algo configs -- must be restored *after* change_algorithm() above, + // since set_config() operates on the currently-selected concrete solver + // (same order as the copy constructor) + AlgoConfig ac_algo_cfg; + ac_algo_cfg.int_params = std::get<0>(state_ac_algo_cfg); + ac_algo_cfg.real_params = std::get<1>(state_ac_algo_cfg); + set_ac_algo_config(ac_algo_cfg); + + AlgoConfig dc_algo_cfg; + dc_algo_cfg.int_params = std::get<0>(state_dc_algo_cfg); + dc_algo_cfg.real_params = std::get<1>(state_dc_algo_cfg); + set_dc_algo_config(dc_algo_cfg); }; void LSGrid::save_binary(const std::string & path) const { diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index ce24a8b6..c077c620 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -77,13 +77,42 @@ class LS2G_API LSGrid final StorageContainer::StateRes, //hvdc lines (was the "dc lines" before lightsim2grid 0.12) HvdcLineContainer::StateRes, + // static var compensators (appended; old pickles are version-gated) + SvcContainer::StateRes, // algo types AlgorithmType, // ac_algo AlgorithmType, // dc_algo - // static var compensators (appended; old pickles are version-gated) - SvcContainer::StateRes + // algo config (scaling/refactor/line-search params, appended; + // old pickles are version-gated): int_params, real_params + std::tuple, std::vector >, // ac_algo_config + std::tuple, std::vector > // dc_algo_config >; + // named indices into the StateRes tuple above (get_state()/set_state() + // use these instead of raw std::get literals so the two stay in + // sync when fields are reordered or appended) + static const std::size_t VERSION_MAJOR_ID = 0; + static const std::size_t VERSION_MEDIUM_ID = 1; + static const std::size_t VERSION_MINOR_ID = 2; + static const std::size_t LS_TO_ORIG_ID = 3; + static const std::size_t INIT_VM_PU_ID = 4; + static const std::size_t SN_MVA_ID = 5; + static const std::size_t BUS_STATUS_ID = 6; + static const std::size_t SUBSTATION_ID = 7; + static const std::size_t LINE_ID = 8; + static const std::size_t SHUNT_ID = 9; + static const std::size_t TRAFO_ID = 10; + static const std::size_t GEN_ID = 11; + static const std::size_t LOAD_ID = 12; + static const std::size_t SGEN_ID = 13; + static const std::size_t STORAGE_ID = 14; + static const std::size_t HVDC_ID = 15; + static const std::size_t SVC_ID = 16; + static const std::size_t AC_ALGO_TYPE_ID = 17; + static const std::size_t DC_ALGO_TYPE_ID = 18; + static const std::size_t AC_ALGO_CONFIG_ID = 19; + static const std::size_t DC_ALGO_CONFIG_ID = 20; + LSGrid(): timer_last_ac_pf_(0.), timer_last_dc_pf_(0.), @@ -474,7 +503,8 @@ class LS2G_API LSGrid final void save_binary(const std::string & path) const; static LSGrid load_binary(const std::string & path); - // algo config (scaling/refactor policy params) — not part of StateRes pickle + // algo config (scaling/refactor policy params) — also part of StateRes, + // see LSGrid::get_state()/set_state() AlgoConfig get_ac_algo_config() const { return _algo.get_config(); } void set_ac_algo_config(const AlgoConfig& cfg) { _algo.set_config(cfg); } AlgoConfig get_dc_algo_config() const { return _dc_algo.get_config(); } From 80107b69c055064eca321cdef40ca9fcabf12da2 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 15:33:30 +0200 Subject: [PATCH 036/166] add serializing of algorithm config Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 11 ++++ docs/conf.py | 2 +- lightsim2grid/__init__.py | 2 +- .../tests/test_binary_serialization.py | 30 +++++++++ lightsim2grid/tests/test_pickleable.py | 32 +++++++++ src/core/LSGrid.cpp | 66 ++++++++++++------- src/core/LSGrid.hpp | 36 +++++++++- 7 files changed, 152 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c5108a9c..138d7ca6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -342,6 +342,17 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). +- [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same + `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the + per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- + see `get_ac_algo_config()`/`set_ac_algo_config()` and the DC counterparts): only the + coarse `AlgorithmType` enum (eg `NR_SparseLU`) round-tripped, so any custom tuning + applied via `set_ac_algo_config()`/`set_dc_algo_config()` reverted to the solver's + defaults after a save/load or pickle round trip. Both AC and DC `AlgoConfig` are now + part of `LSGrid::StateRes` and restored after the algorithm itself is selected on + `set_state()` (mirroring the existing copy-constructor behavior). `LSGrid::StateRes` + tuple positions are now named (`LSGrid::SUBSTATION_ID`, `LSGrid::HVDC_ID`, etc.) + instead of raw integer literals in `get_state()`/`set_state()`. - [ADDED] `init_from_pypowsybl` now reads and exposes operating limits: per-bus min/max voltage (`LSGrid.get_bus_vmin_kv()` / `get_bus_vmax_kv()`, from the source voltage levels' `low_voltage_limit` / `high_voltage_limit`) and per-side branch thermal diff --git a/docs/conf.py b/docs/conf.py index df96192a..9bae68cb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'Benjamin DONNOT' # The full version, including alpha/beta/rc tags -release = "1.0.0.rc1" +release = "1.0.0.rc2" version = '1.0' # -- General configuration --------------------------------------------------- diff --git a/lightsim2grid/__init__.py b/lightsim2grid/__init__.py index f3049a73..7b4ac7dc 100644 --- a/lightsim2grid/__init__.py +++ b/lightsim2grid/__init__.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__version__ = "1.0.0.rc1" +__version__ = "1.0.0.rc2" __all__ = [ "newtonpf", diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 150c1cee..7442c66d 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -188,6 +188,36 @@ def test_binary_hvdc(self): def test_binary_svcs(self): self._aux_test_binary("get_svcs", _compare_svcs) + def test_binary_algo_config(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + + ac_cfg = grid.get_ac_algo_config() + ac_cfg.int_params = [1, 2, 3, 4] + ac_cfg.real_params = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + grid.set_ac_algo_config(ac_cfg) + # DC solver (DC_SparseLU) does not populate AlgoConfig at all (only the + # NRAlgo family does): get_config()/set_config() are BaseAlgo's no-op. + # Read back whatever is actually applied rather than assuming the + # values above took effect, so this only tests that the round trip + # preserves whatever the config actually is. + dc_cfg = grid.get_dc_algo_config() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_binary.lsb") + grid.save_binary(path) + grid_1 = type(grid).load_binary(path) + + ac_cfg_1 = grid_1.get_ac_algo_config() + assert list(ac_cfg_1.int_params) == list(ac_cfg.int_params) + assert np.allclose(ac_cfg_1.real_params, ac_cfg.real_params) + + dc_cfg_1 = grid_1.get_dc_algo_config() + assert list(dc_cfg_1.int_params) == list(dc_cfg.int_params) + assert np.allclose(dc_cfg_1.real_params, dc_cfg.real_params) + def test_cannot_load_wrong_version(self): with warnings.catch_warnings(): warnings.filterwarnings("ignore") diff --git a/lightsim2grid/tests/test_pickleable.py b/lightsim2grid/tests/test_pickleable.py index f62dc522..4e8de0f7 100644 --- a/lightsim2grid/tests/test_pickleable.py +++ b/lightsim2grid/tests/test_pickleable.py @@ -146,6 +146,38 @@ def test_save_load(self): self.aux_test_2sides(self.env.backend._grid, backend_1._grid, True) self.aux_test_1side(self.env.backend._grid, backend_1._grid, True) + def test_pickle_algo_config(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + + ac_cfg = grid.get_ac_algo_config() + ac_cfg.int_params = [1, 2, 3, 4] + ac_cfg.real_params = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + grid.set_ac_algo_config(ac_cfg) + # DC solver (DC_SparseLU) does not populate AlgoConfig at all (only the + # NRAlgo family does): get_config()/set_config() are BaseAlgo's no-op. + # Read back whatever is actually applied rather than assuming the + # values above took effect, so this only tests that the round trip + # preserves whatever the config actually is. + dc_cfg = grid.get_dc_algo_config() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_pickle_algo_config.pickle") + with open(path, "wb") as f: + pickle.dump(grid, f) + with open(path, "rb") as f: + grid_1 = pickle.load(f) + + ac_cfg_1 = grid_1.get_ac_algo_config() + assert list(ac_cfg_1.int_params) == list(ac_cfg.int_params) + assert np.allclose(ac_cfg_1.real_params, ac_cfg.real_params) + + dc_cfg_1 = grid_1.get_dc_algo_config() + assert list(dc_cfg_1.int_params) == list(dc_cfg.int_params) + assert np.allclose(dc_cfg_1.real_params, dc_cfg.real_params) + def test_cannot_load_unfit_ls_version(self): tmpdir = Path(".") / "old_pickle" with self.assertRaises((ImportError, AttributeError)): diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index c7b3936e..cefdd5fd 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -87,6 +87,11 @@ LSGrid::StateRes LSGrid::get_state() const auto res_hvdc_line = hvdc_lines_.get_state(); auto res_svc = svcs_.get_state(); + AlgoConfig ac_algo_cfg = get_ac_algo_config(); + AlgoConfig dc_algo_cfg = get_dc_algo_config(); + auto res_ac_algo_cfg = std::make_tuple(ac_algo_cfg.int_params, ac_algo_cfg.real_params); + auto res_dc_algo_cfg = std::make_tuple(dc_algo_cfg.int_params, dc_algo_cfg.real_params); + LSGrid::StateRes res(version_major, version_medium, version_minor, @@ -103,9 +108,11 @@ LSGrid::StateRes LSGrid::get_state() const res_sgen, res_storage, res_hvdc_line, + res_svc, get_algo_type(), get_dc_algo_type(), - res_svc + res_ac_algo_cfg, + res_dc_algo_cfg ); return res; }; @@ -119,9 +126,9 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) compute_results_ = true; // extract data from the state - std::string version_major = std::get<0>(my_state); - std::string version_medium = std::get<1>(my_state); - std::string version_minor = std::get<2>(my_state); + std::string version_major = std::get(my_state); + std::string version_medium = std::get(my_state); + std::string version_minor = std::get(my_state); if((version_major != VERSION_MAJOR )| (version_medium != VERSION_MEDIUM) | (version_minor != VERSION_MINOR)) { std::ostringstream exc_; @@ -132,33 +139,35 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) exc_ << "It is not possible. Please reinstall it."; throw std::runtime_error(exc_.str()); } - const std::vector & ls_to_pp = std::get<3>(my_state); - init_vm_pu_ = std::get<4>(my_state); - sn_mva_ = std::get<5>(my_state); - // const std::vector & bus_vn_kv = std::get<6>(my_state); - const std::vector & last_bus_status_saved = std::get<6>(my_state); - SubstationContainer::StateRes & state_substations = std::get<7>(my_state); + const std::vector & ls_to_pp = std::get(my_state); + init_vm_pu_ = std::get(my_state); + sn_mva_ = std::get(my_state); + const std::vector & last_bus_status_saved = std::get(my_state); + SubstationContainer::StateRes & state_substations = std::get(my_state); // powerlines - LineContainer::StateRes & state_lines = std::get<8>(my_state); + LineContainer::StateRes & state_lines = std::get(my_state); // shunts - ShuntContainer::StateRes & state_shunts = std::get<9>(my_state); + ShuntContainer::StateRes & state_shunts = std::get(my_state); // trafos - TrafoContainer::StateRes & state_trafos = std::get<10>(my_state); + TrafoContainer::StateRes & state_trafos = std::get(my_state); // generators // total_q_min_per_bus_; // total_q_max_per_bus_; // total_gen_per_bus_; - GeneratorContainer::StateRes & state_gens = std::get<11>(my_state); + GeneratorContainer::StateRes & state_gens = std::get(my_state); // loads - LoadContainer::StateRes & state_loads = std::get<12>(my_state); + LoadContainer::StateRes & state_loads = std::get(my_state); // static gen - SGenContainer::StateRes & state_sgens= std::get<13>(my_state); + SGenContainer::StateRes & state_sgens= std::get(my_state); // storage units - StorageContainer::StateRes & state_storages = std::get<14>(my_state); + StorageContainer::StateRes & state_storages = std::get(my_state); // hvdc lines - HvdcLineContainer::StateRes & state_hvdc_lines = std::get<15>(my_state); - // static var compensators (index 16/17 are the ac/dc algo types) - SvcContainer::StateRes & state_svcs = std::get<18>(my_state); + HvdcLineContainer::StateRes & state_hvdc_lines = std::get(my_state); + // static var compensators + SvcContainer::StateRes & state_svcs = std::get(my_state); + // algo configs (scaling/refactor/line-search params) + auto & state_ac_algo_cfg = std::get(my_state); + auto & state_dc_algo_cfg = std::get(my_state); // substations last_bus_status_saved_ = last_bus_status_saved; @@ -196,8 +205,21 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // handle the solver reset(true, true, true); - _algo.change_algorithm(std::get<16>(my_state)); - _dc_algo.change_algorithm(std::get<17>(my_state)); + _algo.change_algorithm(std::get(my_state)); + _dc_algo.change_algorithm(std::get(my_state)); + + // algo configs -- must be restored *after* change_algorithm() above, + // since set_config() operates on the currently-selected concrete solver + // (same order as the copy constructor) + AlgoConfig ac_algo_cfg; + ac_algo_cfg.int_params = std::get<0>(state_ac_algo_cfg); + ac_algo_cfg.real_params = std::get<1>(state_ac_algo_cfg); + set_ac_algo_config(ac_algo_cfg); + + AlgoConfig dc_algo_cfg; + dc_algo_cfg.int_params = std::get<0>(state_dc_algo_cfg); + dc_algo_cfg.real_params = std::get<1>(state_dc_algo_cfg); + set_dc_algo_config(dc_algo_cfg); }; void LSGrid::save_binary(const std::string & path) const { diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index ce24a8b6..c077c620 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -77,13 +77,42 @@ class LS2G_API LSGrid final StorageContainer::StateRes, //hvdc lines (was the "dc lines" before lightsim2grid 0.12) HvdcLineContainer::StateRes, + // static var compensators (appended; old pickles are version-gated) + SvcContainer::StateRes, // algo types AlgorithmType, // ac_algo AlgorithmType, // dc_algo - // static var compensators (appended; old pickles are version-gated) - SvcContainer::StateRes + // algo config (scaling/refactor/line-search params, appended; + // old pickles are version-gated): int_params, real_params + std::tuple, std::vector >, // ac_algo_config + std::tuple, std::vector > // dc_algo_config >; + // named indices into the StateRes tuple above (get_state()/set_state() + // use these instead of raw std::get literals so the two stay in + // sync when fields are reordered or appended) + static const std::size_t VERSION_MAJOR_ID = 0; + static const std::size_t VERSION_MEDIUM_ID = 1; + static const std::size_t VERSION_MINOR_ID = 2; + static const std::size_t LS_TO_ORIG_ID = 3; + static const std::size_t INIT_VM_PU_ID = 4; + static const std::size_t SN_MVA_ID = 5; + static const std::size_t BUS_STATUS_ID = 6; + static const std::size_t SUBSTATION_ID = 7; + static const std::size_t LINE_ID = 8; + static const std::size_t SHUNT_ID = 9; + static const std::size_t TRAFO_ID = 10; + static const std::size_t GEN_ID = 11; + static const std::size_t LOAD_ID = 12; + static const std::size_t SGEN_ID = 13; + static const std::size_t STORAGE_ID = 14; + static const std::size_t HVDC_ID = 15; + static const std::size_t SVC_ID = 16; + static const std::size_t AC_ALGO_TYPE_ID = 17; + static const std::size_t DC_ALGO_TYPE_ID = 18; + static const std::size_t AC_ALGO_CONFIG_ID = 19; + static const std::size_t DC_ALGO_CONFIG_ID = 20; + LSGrid(): timer_last_ac_pf_(0.), timer_last_dc_pf_(0.), @@ -474,7 +503,8 @@ class LS2G_API LSGrid final void save_binary(const std::string & path) const; static LSGrid load_binary(const std::string & path); - // algo config (scaling/refactor policy params) — not part of StateRes pickle + // algo config (scaling/refactor policy params) — also part of StateRes, + // see LSGrid::get_state()/set_state() AlgoConfig get_ac_algo_config() const { return _algo.get_config(); } void set_ac_algo_config(const AlgoConfig& cfg) { _algo.set_config(cfg); } AlgoConfig get_dc_algo_config() const { return _dc_algo.get_config(); } From 32eeef3567268e8973c7fd8f06f0e6bd5209f690 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 16:49:53 +0200 Subject: [PATCH 037/166] improve handling of real grid Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_from_pypowsybl.py | 254 +++++++++++++---- .../tests/test_bus_fusion_pypowsybl.py | 259 ++++++++++++++++++ 2 files changed, 466 insertions(+), 47 deletions(-) create mode 100644 lightsim2grid/tests/test_bus_fusion_pypowsybl.py diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 3244f518..d174e003 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -206,6 +206,65 @@ def _aux_current_limits(element_ids, group_1, group_2, operational_limits): return limit_a1_ka, limit_a2_ka +def _aux_ensure_net_pu(net, net_pu): + """Return `net_pu` unchanged if already provided by the caller; otherwise + build the per-unit view of `net` once. Factored out so the zero-impedance + bus-fusion pre-pass (which needs per-unit r/x before the "normal" per-unit + view is built) and the main line/trafo processing can share a single, + lazily-built `net_pu` instead of constructing it twice.""" + if net_pu is not None: + return net_pu + if hasattr(net, "per_unit"): + net_pu = copy.deepcopy(net) + net_pu.per_unit = True + else: + # legacy pypowsybl mode: this did not exist + from pypowsybl.network import PerUnitView + net_pu = PerUnitView(net) + warnings.warn("The `PerUnitView` (python side) is less efficient and less " + "tested that the equivalent java class. Please upgrade pypowsybl version") + return net_pu + + +def _aux_get_2wt_all_attrs(net_like): + """`get_2_windings_transformers(all_attributes=True)`, falling back to the + plain call on legacy pypowsybl versions that don't support it.""" + try: + return net_like.get_2_windings_transformers(all_attributes=True) + except TypeError: + return net_like.get_2_windings_transformers() + + +def _aux_trafo_alpha(df_trafo_pu, net): + """Phase-shift angle, in **degree**, for every transformer in `df_trafo_pu` + (a per-unit `get_2_windings_transformers()` table).""" + if 'alpha' in df_trafo_pu: + return np.rad2deg(df_trafo_pu['alpha'].values) # given in radian by pypowsybl + if net.get_phase_tap_changers().shape[0] > 0: + raise RuntimeError("Phase tap changer are not handled by the pypowsybl converter " + "when not accessible using the 'alpha' columns " + "of the net (once per unit). Please upgrade pypowsybl." + "NB: phase tap change are handled by lightsim2grid)") + return np.zeros(df_trafo_pu.shape[0]) + + +def _aux_trafo_rho(df_trafo_pu, ratio_tap_changer): + """Turns ratio for every transformer in `df_trafo_pu` (a per-unit + `get_2_windings_transformers()` table).""" + if "rho" in df_trafo_pu: + return 1. * df_trafo_pu["rho"].values + # in powsybl (https://javadoc.io/doc/com.powsybl/powsybl-core/latest/com/powsybl/iidm/network/TwoWindingsTransformer.html) + # rho = transfo.getRatedU2() / transfo.getRatedU1() + # * (transfo.getRatioTapChanger() != null ? transfo.getRatioTapChanger().getCurrentStep().getRho() : 1); + # * (transfo.getPhaseTapChanger() != null ? transfo.getPhaseTapChanger().getCurrentStep().getRho() : 1); + ratio = 1. * (df_trafo_pu["rated_u2"].values / df_trafo_pu["rated_u1"].values) + has_r_tap_changer = np.isin(df_trafo_pu.index, ratio_tap_changer.index) + if PYPOWSYBL_VER <= PP_BUG_RATIO_TAP_CHANGER: + # bug in per unit view in both python and java + ratio[has_r_tap_changer] = 1. * ratio_tap_changer.loc[df_trafo_pu.loc[has_r_tap_changer].index, "rho"].values + return ratio + + def _aux_regulated_bus_view_ids(net, regulated_ids): """Resolve voltage-controller regulated elements to their terminal bus. @@ -431,6 +490,8 @@ def init(net : pypo.network.Network, init_vm_pu:float=1.06, keep_half_open_lines: bool=False, convert_dangling_lines: bool=False, + fuse_zero_impedance_branches: bool=False, + zero_impedance_threshold_pu: float=1e-8, ) -> LSGrid: """ This function is available under the `init_from_pypowsybl` in lightsim2grid @@ -538,6 +599,50 @@ def init(net : pypo.network.Network, reduce/validate debug scripts turn it on. :type convert_dangling_lines: bool + :param fuse_zero_impedance_branches: If True, a line or 2-winding transformer + whose per-unit impedance is (near-)zero -- ``|r_pu| < + zero_impedance_threshold_pu`` and ``|x_pu| < + zero_impedance_threshold_pu`` -- has its two terminal buses + fused into a single electrical node instead of contributing + a ``1/Z`` admittance (which is ``Inf`` for an exact zero, and + breaks the sparse LU factorization outright). This mirrors + OpenLoadFlow's ``lowImpedanceBranchMode`` / + ``lowImpedanceThreshold``. A zero-impedance transformer is + only fused if it is also at (near-)neutral tap (``rho`` close + to 1 and ``alpha`` close to 0) *and* its two sides are at the + same nominal voltage: pypowsybl's per-unit ``rho`` is the + deviation from the transformer's *own* rated ratio (the + tap-changer effect), not its absolute turns ratio, so + ``rho~=1`` alone does not mean "no transformation" for a + genuine step-down/up transformer -- otherwise it is a real + ideal ratio/phase-shifting element, not a same-node short, and + is left untouched. A zero-impedance *line* spanning two + *different* nominal voltages raises a ``RuntimeError`` + (inconsistent grid data) -- unlike for transformers, this is + never legitimate for a line. The fusing branch itself is kept in + the model (for topology-vector bookkeeping) but deactivated, + same as any other disconnected branch; substation/topology + identity (``glop_sub_id``) of elements on the two original + buses is *not* changed, only the internal solver bus id -- + grid2op topology actions still see the original, distinct + substations. Off by default to keep existing behaviour + unchanged. + + .. warning:: + This is a static, import-time decision. If a fused + transformer's tap is later moved away from neutral + during a simulation, the two buses stay fused in + lightsim2grid even though the transformer is no longer + electrically a plain wire. Best suited for static / + diagnostic use, or grid2op environments known not to + actuate the affected transformer's tap. + :type fuse_zero_impedance_branches: bool + + :param zero_impedance_threshold_pu: Per-unit impedance magnitude threshold used + by ``fuse_zero_impedance_branches`` (ignored otherwise). + Matches OpenLoadFlow's ``lowImpedanceThreshold`` default. + :type zero_impedance_threshold_pu: float + :return: The properly initialized network. :rtype: :class:`LSGrid` """ @@ -710,7 +815,92 @@ def init(net : pypo.network.Network, model._max_nb_bus_per_sub = n_busbar_per_sub_ls model.set_substation_names(sub_names) model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) - + + # fuse the two terminal buses of (near-)zero-impedance lines / neutral-tap + # transformers, mirroring OpenLoadFlow's `lowImpedanceBranchMode`. Must run + # before any `_aux_get_bus` call (generators, just below) so every element + # type transparently picks up the fused `bus_global_id` for free -- see + # `_aux_get_bus`'s single choke point at `bus_df["bus_global_id"]`. + fused_line_ids = set() + fused_trafo_ids = set() + if fuse_zero_impedance_branches: + net_pu = _aux_ensure_net_pu(net, net_pu) + parent = np.arange(all_buses_vn_kv.shape[0]) + + def _uf_find(x): + while parent[x] != x: + x = parent[x] + return x + + def _uf_union(a, b): + ra, rb = _uf_find(a), _uf_find(b) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) # deterministic: smaller id wins + + # -- zero-impedance LINES -- + df_line_fuse = net.get_lines() + if sort_index: + df_line_fuse = df_line_fuse.sort_index() + df_line_fuse_pu = net_pu.get_lines().loc[df_line_fuse.index] + both_conn = (df_line_fuse["connected1"].values & df_line_fuse["connected2"].values) + is_zero = ((np.abs(df_line_fuse_pu["r"].values) < zero_impedance_threshold_pu) + & (np.abs(df_line_fuse_pu["x"].values) < zero_impedance_threshold_pu)) + line_candidate = both_conn & is_zero + if line_candidate.any(): + cand = df_line_fuse[line_candidate] + vn1 = voltage_levels.loc[cand["voltage_level1_id"].values, "nominal_v"].values + vn2 = voltage_levels.loc[cand["voltage_level2_id"].values, "nominal_v"].values + bad = ~np.isclose(vn1, vn2) + if bad.any(): + raise RuntimeError( + f"Zero-impedance line(s) {list(cand.index[bad])} connect two buses at " + "different nominal voltages: this is inconsistent grid data, refusing " + "to fuse them (fuse_zero_impedance_branches=True)." + ) + gid1 = bus_df.loc[cand["bus1_id"].values, "bus_global_id"].values + gid2 = bus_df.loc[cand["bus2_id"].values, "bus_global_id"].values + for a, b in zip(gid1, gid2): + _uf_union(int(a), int(b)) + fused_line_ids.update(cand.index) + + # -- zero-impedance, neutral-tap, SAME-NOMINAL-VOLTAGE TRANSFORMERS -- + # NB: pypowsybl's per-unit `rho` is the deviation from the transformer's OWN + # rated_u1/rated_u2 ratio (tap-changer effect), not the absolute turns ratio: + # a genuine step-down transformer (eg rated_u1=225kV/rated_u2=90kV) reports + # rho=1 at neutral tap even though it performs a real voltage transformation + # (implicit in the differing per-unit bus voltage bases on each side, not in + # `rho` itself). So `rho~=1` alone only means "no ADDITIONAL tap deviation" -- + # it does NOT mean "no transformation at all". A transformer is only a true, + # fusable ideal wire when it is ALSO between two buses of the same nominal + # voltage (same requirement as for lines, just not an error here since most + # real transformers legitimately span different nominal voltages). + df_trafo_fuse = _aux_get_2wt_all_attrs(net) + if sort_index: + df_trafo_fuse = df_trafo_fuse.sort_index() + if df_trafo_fuse.shape[0] > 0: + df_trafo_fuse_pu = _aux_get_2wt_all_attrs(net_pu).loc[df_trafo_fuse.index] + ratio_tap_changer_fuse = net_pu.get_ratio_tap_changers() + alpha_fuse = _aux_trafo_alpha(df_trafo_fuse_pu, net) + rho_fuse = _aux_trafo_rho(df_trafo_fuse_pu, ratio_tap_changer_fuse) + both_conn_t = (df_trafo_fuse["connected1"].values & df_trafo_fuse["connected2"].values) + is_zero_t = ((np.abs(df_trafo_fuse_pu["r"].values) < zero_impedance_threshold_pu) + & (np.abs(df_trafo_fuse_pu["x"].values) < zero_impedance_threshold_pu)) + is_ideal_t = (np.abs(rho_fuse - 1.) < 1e-9) & (np.abs(alpha_fuse) < 1e-9) + vn1_t = voltage_levels.loc[df_trafo_fuse["voltage_level1_id"].values, "nominal_v"].values + vn2_t = voltage_levels.loc[df_trafo_fuse["voltage_level2_id"].values, "nominal_v"].values + same_vn_t = np.isclose(vn1_t, vn2_t) + trafo_candidate = both_conn_t & is_zero_t & is_ideal_t & same_vn_t + if trafo_candidate.any(): + cand_t = df_trafo_fuse[trafo_candidate] + gid1 = bus_df.loc[cand_t["bus1_id"].values, "bus_global_id"].values + gid2 = bus_df.loc[cand_t["bus2_id"].values, "bus_global_id"].values + for a, b in zip(gid1, gid2): + _uf_union(int(a), int(b)) + fused_trafo_ids.update(cand_t.index) + + rep = np.array([_uf_find(i) for i in range(parent.shape[0])]) + bus_df["bus_global_id"] = rep[bus_df["bus_global_id"].values] + # do the generators gen_attrs = [ "connected", "max_p", "target_p", "target_v", "target_q", "p", @@ -839,16 +1029,7 @@ def init(net : pypo.network.Network, ) # per unit - if net_pu is None: - if hasattr(net, "per_unit"): - net_pu = copy.deepcopy(net) - net_pu.per_unit = True - else: - # legacy pypowsybl mode: this did not exist - from pypowsybl.network import PerUnitView - net_pu = PerUnitView(net) - warnings.warn("The `PerUnitView` (python side) is less efficient and less " - "tested that the equivalent java class. Please upgrade pypowsybl version") + net_pu = _aux_ensure_net_pu(net, net_pu) df_line_pu = net_pu.get_lines().loc[df_line.index] if df_dl.shape[0] > 0: # equivalent branch (local bus -> fictitious boundary bus) for each @@ -896,6 +1077,10 @@ def init(net : pypo.network.Network, model.deactivate_powerline_side1(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) elif is_ex_disc: model.deactivate_powerline_side2(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) + elif fuse_zero_impedance_branches and df_line.index[line_id] in fused_line_ids: + # both terminal buses already fused into one node above: this line + # would otherwise contribute a 1/Z admittance (Inf for an exact zero) + model.deactivate_powerline(line_id) model.set_line_names(df_line.index) line_limit_a1_ka, line_limit_a2_ka = _aux_current_limits( df_line.index, @@ -908,35 +1093,20 @@ def init(net : pypo.network.Network, # for trafo # I extract trafo with `all_attributes=True` so that I have access to the `rho` - try: - df_trafo_not_sorted = net.get_2_windings_transformers(all_attributes=True) - except TypeError: - # not available in legacy pypowsybl version - df_trafo_not_sorted = net.get_2_windings_transformers() - + df_trafo_not_sorted = _aux_get_2wt_all_attrs(net) + if sort_index: df_trafo = df_trafo_not_sorted.sort_index() else: df_trafo = df_trafo_not_sorted - - try : - df_trafo_pu = net_pu.get_2_windings_transformers(all_attributes=True) - except TypeError: - df_trafo_pu = net_pu.get_2_windings_transformers() + + df_trafo_pu = _aux_get_2wt_all_attrs(net_pu) df_trafo_pu = df_trafo_pu.loc[df_trafo.index] ratio_tap_changer = net_pu.get_ratio_tap_changers() - - if 'alpha' in df_trafo_pu: - shift_ = np.rad2deg(df_trafo_pu['alpha'].values) # given in radian by pypowsybl - else: - if net.get_phase_tap_changers().shape[0] > 0: - raise RuntimeError("Phase tap changer are not handled by the pypowsybl converter " - "when not accessible using the 'alpha' columns " - "of the net (once per unit). Please upgrade pypowsybl." - "NB: phase tap change are handled by lightsim2grid)") - shift_ = np.zeros(df_trafo.shape[0]) + + shift_ = _aux_trafo_alpha(df_trafo_pu, net) # tap is side 2 in IIDM - is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) + is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) # neutral-tap impedance (the phase-shift -> r/x dependence of phase-shifting # transformers is handled by lightsim2grid as a function of the shift alpha, see # the model.set_trafo_shift_dependent_rx(...) call below). @@ -946,20 +1116,7 @@ def init(net : pypo.network.Network, # now get the ratio # in lightsim2grid (cpp) - if "rho" in df_trafo_pu: - ratio = 1. * df_trafo_pu["rho"].values - else: - # in powsybl (https://javadoc.io/doc/com.powsybl/powsybl-core/latest/com/powsybl/iidm/network/TwoWindingsTransformer.html) - # rho = transfo.getRatedU2() / transfo.getRatedU1() - # * (transfo.getRatioTapChanger() != null ? transfo.getRatioTapChanger().getCurrentStep().getRho() : 1); - # * (transfo.getPhaseTapChanger() != null ? transfo.getPhaseTapChanger().getCurrentStep().getRho() : 1); - - ratio = 1. * (df_trafo_pu["rated_u2"].values / df_trafo_pu["rated_u1"].values) - has_r_tap_changer = np.isin(df_trafo_pu.index, ratio_tap_changer.index) - - if PYPOWSYBL_VER <= PP_BUG_RATIO_TAP_CHANGER: - # bug in per unit view in both python and java - ratio[has_r_tap_changer] = 1. * ratio_tap_changer.loc[df_trafo_pu.loc[has_r_tap_changer].index, "rho"].values + ratio = _aux_trafo_rho(df_trafo_pu, ratio_tap_changer) tor_bus, tor_disco, tor_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 1)", df_trafo, conn_key="connected1", bus_key="bus1_id", vl_key="voltage_level1_id") tex_bus, tex_disco, tex_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 2)", df_trafo, conn_key="connected2", bus_key="bus2_id", vl_key="voltage_level2_id") @@ -980,6 +1137,9 @@ def init(net : pypo.network.Network, model.deactivate_trafo_side1(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) elif is_ex_disc: model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) + elif fuse_zero_impedance_branches and df_trafo.index[t_id] in fused_trafo_ids: + # both terminal buses already fused into one node above + model.deactivate_trafo(t_id) model.set_trafo_names(df_trafo.index) if "selected_limits_group_1" in df_trafo.columns: trafo_group_1 = df_trafo["selected_limits_group_1"] diff --git a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py new file mode 100644 index 00000000..7a25063d --- /dev/null +++ b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py @@ -0,0 +1,259 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""`fuse_zero_impedance_branches` (from_pypowsybl `init()`). + +A real RTE grid was found to have a line with `r_pu = x_pu = 0.0`. lightsim2grid +computed `B = 1/X = Inf`, corrupting the Ybus/dcYbus with literal `+/-Inf` entries +and making the sparse LU factorization fail outright. OpenLoadFlow avoids this via +its `lowImpedanceBranchMode` (default `REPLACE_BY_ZERO_IMPEDANCE_LINE`): the two +terminal buses of a near-zero-impedance branch are fused into a single electrical +node instead of computing `1/Z`. This is the same behaviour, opt-in via +`fuse_zero_impedance_branches` (see HVDC_OLF_FINDINGS.md and the docstring of +`lightsim2grid.network.from_pypowsybl.init`). +""" + +import unittest +import warnings + +import numpy as np + +try: + import pypowsybl.network as pn + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +def _dc_pf_ok(model): + nb_bus = model.get_bus_vn_kv().shape[0] + flat = np.full(nb_bus, 1.0, dtype=complex) + Vdc = model.dc_pf(flat, 30, 1e-8) + return Vdc, Vdc.shape[0] > 0 + + +class TestBusFusionZeroImpedanceLine(unittest.TestCase): + """A single r=x=0 line in an otherwise normal (ieee14-sized) grid.""" + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = pn.create_ieee14() + # L6-11-1 connects two non-slack, non-reference buses (unlike L1-2-1, + # which happens to touch the reference bus and would not reproduce the + # crash: eliminating the reference row/column also eliminates the Inf + # entries by coincidence). + self.net.update_lines(id="L6-11-1", r=0.0, x=0.0) + + def test_flag_off_reproduces_the_bug(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + _, ok = _dc_pf_ok(model) + self.assertFalse(ok, "dc_pf should fail without fuse_zero_impedance_branches " + "(Inf entries from B=1/X=1/0 break the LU factorization)") + + def test_flag_on_fixes_it_and_fuses_buses(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False, + fuse_zero_impedance_branches=True) + line = next(l for l in model.get_lines() if l.name == "L6-11-1") + # the fusing line is kept (for bookkeeping) but fully deactivated + self.assertFalse(line.connected1) + self.assertFalse(line.connected2) + self.assertEqual(line.bus1_id, -1) + self.assertEqual(line.bus2_id, -1) + + Vdc, ok = _dc_pf_ok(model) + self.assertTrue(ok, "dc_pf should succeed with fuse_zero_impedance_branches=True") + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0, "ac_pf should also succeed") + + # find the two ORIGINAL terminal buses of L6-11-1 (before fusion) using a + # model built WITHOUT the flag (where L6-11-1 still has real bus ids), via + # two OTHER lines each touching exactly one of the two original buses + # (bus "6" also carries L6-12-1/L6-13-1, bus "11" also carries L10-11-1). + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model_off = init_from_pypowsybl(self.net, sort_index=False) + by_name_off = {l.name: l for l in model_off.get_lines()} + fused = by_name_off["L6-11-1"] + bus_side1, bus_side2 = fused.bus1_id, fused.bus2_id + + def _anchor(name, target_bus): + l = by_name_off[name] + return l.bus1_id if l.bus1_id == target_bus else l.bus2_id + + anchor1_id = _anchor("L6-12-1", bus_side1) + anchor2_id = _anchor("L10-11-1", bus_side2) + self.assertEqual(anchor1_id, bus_side1) + self.assertEqual(anchor2_id, bus_side2) + + by_name_on = {l.name: l for l in model.get_lines()} + + def _same_side_bus(name, target_bus): + l = by_name_on[name] + return l.bus1_id if l.bus1_id == target_bus or l.bus2_id != target_bus else l.bus2_id + + # since fusion never renumbers an unaffected bus, whichever of the two + # original ids is NOT the (smaller-id) representative disappears from + # every OTHER element too, replaced by the representative -- so the two + # anchor lines' relevant side should now agree. + b1_on = by_name_on["L6-12-1"].bus1_id if by_name_off["L6-12-1"].bus1_id == bus_side1 else by_name_on["L6-12-1"].bus2_id + b2_on = by_name_on["L10-11-1"].bus1_id if by_name_off["L10-11-1"].bus1_id == bus_side2 else by_name_on["L10-11-1"].bus2_id + self.assertEqual(b1_on, b2_on, + "the two original terminal buses of the fused line " + "should now resolve to the same solver bus") + self.assertAlmostEqual(V[b1_on], V[b2_on]) + + +class TestBusFusionCrossVoltageLineRaises(unittest.TestCase): + """A zero-impedance line spanning two different nominal voltages is + inconsistent data and must raise, never be silently fused.""" + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = pn.create_empty() + self.net.create_substations(id="S1") + self.net.create_voltage_levels(substation_id="S1", id="VL1", nominal_v=225.0, + topology_kind="BUS_BREAKER") + self.net.create_voltage_levels(substation_id="S1", id="VL2", nominal_v=90.0, + topology_kind="BUS_BREAKER") + self.net.create_buses(voltage_level_id="VL1", id="B1") + self.net.create_buses(voltage_level_id="VL2", id="B2") + self.net.create_lines(id="L1", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + + def test_raises(self): + with self.assertRaises(RuntimeError): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + init_from_pypowsybl(self.net, sort_index=False, + fuse_zero_impedance_branches=True, + only_main_component=False) + + +class TestBusFusionTransformer(unittest.TestCase): + """A zero-impedance, neutral-tap transformer between two SAME-nominal-voltage + buses is a genuine ideal wire and gets fused; the same transformer between two + DIFFERENT nominal voltages must NOT be fused, even though pypowsybl's per-unit + `rho` still reports ~1 (rho is the deviation from the transformer's own rated + ratio, not its absolute turns ratio).""" + + def _build(self, vn2): + net = pn.create_empty() + net.create_substations(id="S1") + net.create_voltage_levels(substation_id="S1", id="VL1", nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_voltage_levels(substation_id="S1", id="VL2", nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_voltage_levels(substation_id="S1", id="VL3", nominal_v=vn2, + topology_kind="BUS_BREAKER") + net.create_buses(voltage_level_id="VL1", id="B1") + net.create_buses(voltage_level_id="VL2", id="B2") + net.create_buses(voltage_level_id="VL3", id="B3") + net.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, + min_p=0.0, max_p=100.0, voltage_regulator_on=True) + net.create_loads(id="LD1", voltage_level_id="VL3", bus_id="B3", p0=5.0, q0=1.0) + # the candidate zero-impedance transformer: B1 (225kV) <-> B2 (225kV) + net.create_2_windings_transformers(id="T1", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g=0.0, b=0.0, + rated_u1=225.0, rated_u2=225.0) + # a real, non-zero-impedance line so the system doesn't collapse to a + # single trivial bus after fusion (a pre-existing, unrelated lightsim2grid + # issue with 0-non-slack-bus networks, not something this feature should + # need to work around by construction). + net.create_lines(id="L23", voltage_level1_id="VL2", bus1_id="B2", + voltage_level2_id="VL3", bus2_id="B3", + r=0.01, x=0.1, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + return net + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + + def test_same_nominal_voltage_is_fused(self): + net = self._build(vn2=225.0) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=True) + trafo = next(t for t in model.get_trafos() if t.name == "T1") + self.assertFalse(trafo.connected1) + self.assertFalse(trafo.connected2) + + gen_bus = next(g.bus_id for g in model.get_generators()) + line_bus1 = next(l.bus1_id for l in model.get_lines()) + self.assertEqual(gen_bus, line_bus1, + "B1 and B2 should be fused to the same solver bus") + + Vdc, ok = _dc_pf_ok(model) + self.assertTrue(ok) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + def test_different_nominal_voltage_is_not_fused(self): + # B1 is 225kV, "VL3"/B3 stays 225kV too here but T1 itself is still + # between B1 (225kV) and B2 (225kV) -- to actually test the cross-voltage + # guard we need T1's OWN two sides to differ, so build a dedicated net. + net = pn.create_empty() + net.create_substations(id="S1") + net.create_voltage_levels(substation_id="S1", id="VL1", nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_voltage_levels(substation_id="S1", id="VL2", nominal_v=90.0, + topology_kind="BUS_BREAKER") + net.create_buses(voltage_level_id="VL1", id="B1") + net.create_buses(voltage_level_id="VL2", id="B2") + net.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, + min_p=0.0, max_p=100.0, voltage_regulator_on=True) + net.create_loads(id="LD1", voltage_level_id="VL2", bus_id="B2", p0=5.0, q0=1.0) + net.create_2_windings_transformers(id="T1", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g=0.0, b=0.0, + rated_u1=225.0, rated_u2=90.0) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=True) + trafo = next(t for t in model.get_trafos() if t.name == "T1") + self.assertTrue(trafo.connected1, "a genuine (even if lossless) " + "step-up/down transformer must never be fused") + self.assertTrue(trafo.connected2) + + +class TestBusFusionFlagOffUnchanged(unittest.TestCase): + """With the flag omitted (default), behaviour must be identical to before + this feature existed.""" + + def test_default_is_false_and_unchanged(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + net = pn.create_ieee14() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model_default = init_from_pypowsybl(net, sort_index=False) + model_explicit_off = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=False) + self.assertEqual(model_default.get_bus_vn_kv().shape[0], + model_explicit_off.get_bus_vn_kv().shape[0]) + Vdc1, ok1 = _dc_pf_ok(model_default) + Vdc2, ok2 = _dc_pf_ok(model_explicit_off) + self.assertEqual(ok1, ok2) + if ok1: + np.testing.assert_array_almost_equal(Vdc1, Vdc2) + + +if __name__ == "__main__": + unittest.main() From f5913d9b4a1f36249136728d4ebc07e25b06fa42 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 16:49:53 +0200 Subject: [PATCH 038/166] improve handling of real grid Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_from_pypowsybl.py | 254 +++++++++++++---- .../tests/test_bus_fusion_pypowsybl.py | 259 ++++++++++++++++++ 2 files changed, 466 insertions(+), 47 deletions(-) create mode 100644 lightsim2grid/tests/test_bus_fusion_pypowsybl.py diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 3244f518..d174e003 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -206,6 +206,65 @@ def _aux_current_limits(element_ids, group_1, group_2, operational_limits): return limit_a1_ka, limit_a2_ka +def _aux_ensure_net_pu(net, net_pu): + """Return `net_pu` unchanged if already provided by the caller; otherwise + build the per-unit view of `net` once. Factored out so the zero-impedance + bus-fusion pre-pass (which needs per-unit r/x before the "normal" per-unit + view is built) and the main line/trafo processing can share a single, + lazily-built `net_pu` instead of constructing it twice.""" + if net_pu is not None: + return net_pu + if hasattr(net, "per_unit"): + net_pu = copy.deepcopy(net) + net_pu.per_unit = True + else: + # legacy pypowsybl mode: this did not exist + from pypowsybl.network import PerUnitView + net_pu = PerUnitView(net) + warnings.warn("The `PerUnitView` (python side) is less efficient and less " + "tested that the equivalent java class. Please upgrade pypowsybl version") + return net_pu + + +def _aux_get_2wt_all_attrs(net_like): + """`get_2_windings_transformers(all_attributes=True)`, falling back to the + plain call on legacy pypowsybl versions that don't support it.""" + try: + return net_like.get_2_windings_transformers(all_attributes=True) + except TypeError: + return net_like.get_2_windings_transformers() + + +def _aux_trafo_alpha(df_trafo_pu, net): + """Phase-shift angle, in **degree**, for every transformer in `df_trafo_pu` + (a per-unit `get_2_windings_transformers()` table).""" + if 'alpha' in df_trafo_pu: + return np.rad2deg(df_trafo_pu['alpha'].values) # given in radian by pypowsybl + if net.get_phase_tap_changers().shape[0] > 0: + raise RuntimeError("Phase tap changer are not handled by the pypowsybl converter " + "when not accessible using the 'alpha' columns " + "of the net (once per unit). Please upgrade pypowsybl." + "NB: phase tap change are handled by lightsim2grid)") + return np.zeros(df_trafo_pu.shape[0]) + + +def _aux_trafo_rho(df_trafo_pu, ratio_tap_changer): + """Turns ratio for every transformer in `df_trafo_pu` (a per-unit + `get_2_windings_transformers()` table).""" + if "rho" in df_trafo_pu: + return 1. * df_trafo_pu["rho"].values + # in powsybl (https://javadoc.io/doc/com.powsybl/powsybl-core/latest/com/powsybl/iidm/network/TwoWindingsTransformer.html) + # rho = transfo.getRatedU2() / transfo.getRatedU1() + # * (transfo.getRatioTapChanger() != null ? transfo.getRatioTapChanger().getCurrentStep().getRho() : 1); + # * (transfo.getPhaseTapChanger() != null ? transfo.getPhaseTapChanger().getCurrentStep().getRho() : 1); + ratio = 1. * (df_trafo_pu["rated_u2"].values / df_trafo_pu["rated_u1"].values) + has_r_tap_changer = np.isin(df_trafo_pu.index, ratio_tap_changer.index) + if PYPOWSYBL_VER <= PP_BUG_RATIO_TAP_CHANGER: + # bug in per unit view in both python and java + ratio[has_r_tap_changer] = 1. * ratio_tap_changer.loc[df_trafo_pu.loc[has_r_tap_changer].index, "rho"].values + return ratio + + def _aux_regulated_bus_view_ids(net, regulated_ids): """Resolve voltage-controller regulated elements to their terminal bus. @@ -431,6 +490,8 @@ def init(net : pypo.network.Network, init_vm_pu:float=1.06, keep_half_open_lines: bool=False, convert_dangling_lines: bool=False, + fuse_zero_impedance_branches: bool=False, + zero_impedance_threshold_pu: float=1e-8, ) -> LSGrid: """ This function is available under the `init_from_pypowsybl` in lightsim2grid @@ -538,6 +599,50 @@ def init(net : pypo.network.Network, reduce/validate debug scripts turn it on. :type convert_dangling_lines: bool + :param fuse_zero_impedance_branches: If True, a line or 2-winding transformer + whose per-unit impedance is (near-)zero -- ``|r_pu| < + zero_impedance_threshold_pu`` and ``|x_pu| < + zero_impedance_threshold_pu`` -- has its two terminal buses + fused into a single electrical node instead of contributing + a ``1/Z`` admittance (which is ``Inf`` for an exact zero, and + breaks the sparse LU factorization outright). This mirrors + OpenLoadFlow's ``lowImpedanceBranchMode`` / + ``lowImpedanceThreshold``. A zero-impedance transformer is + only fused if it is also at (near-)neutral tap (``rho`` close + to 1 and ``alpha`` close to 0) *and* its two sides are at the + same nominal voltage: pypowsybl's per-unit ``rho`` is the + deviation from the transformer's *own* rated ratio (the + tap-changer effect), not its absolute turns ratio, so + ``rho~=1`` alone does not mean "no transformation" for a + genuine step-down/up transformer -- otherwise it is a real + ideal ratio/phase-shifting element, not a same-node short, and + is left untouched. A zero-impedance *line* spanning two + *different* nominal voltages raises a ``RuntimeError`` + (inconsistent grid data) -- unlike for transformers, this is + never legitimate for a line. The fusing branch itself is kept in + the model (for topology-vector bookkeeping) but deactivated, + same as any other disconnected branch; substation/topology + identity (``glop_sub_id``) of elements on the two original + buses is *not* changed, only the internal solver bus id -- + grid2op topology actions still see the original, distinct + substations. Off by default to keep existing behaviour + unchanged. + + .. warning:: + This is a static, import-time decision. If a fused + transformer's tap is later moved away from neutral + during a simulation, the two buses stay fused in + lightsim2grid even though the transformer is no longer + electrically a plain wire. Best suited for static / + diagnostic use, or grid2op environments known not to + actuate the affected transformer's tap. + :type fuse_zero_impedance_branches: bool + + :param zero_impedance_threshold_pu: Per-unit impedance magnitude threshold used + by ``fuse_zero_impedance_branches`` (ignored otherwise). + Matches OpenLoadFlow's ``lowImpedanceThreshold`` default. + :type zero_impedance_threshold_pu: float + :return: The properly initialized network. :rtype: :class:`LSGrid` """ @@ -710,7 +815,92 @@ def init(net : pypo.network.Network, model._max_nb_bus_per_sub = n_busbar_per_sub_ls model.set_substation_names(sub_names) model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) - + + # fuse the two terminal buses of (near-)zero-impedance lines / neutral-tap + # transformers, mirroring OpenLoadFlow's `lowImpedanceBranchMode`. Must run + # before any `_aux_get_bus` call (generators, just below) so every element + # type transparently picks up the fused `bus_global_id` for free -- see + # `_aux_get_bus`'s single choke point at `bus_df["bus_global_id"]`. + fused_line_ids = set() + fused_trafo_ids = set() + if fuse_zero_impedance_branches: + net_pu = _aux_ensure_net_pu(net, net_pu) + parent = np.arange(all_buses_vn_kv.shape[0]) + + def _uf_find(x): + while parent[x] != x: + x = parent[x] + return x + + def _uf_union(a, b): + ra, rb = _uf_find(a), _uf_find(b) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) # deterministic: smaller id wins + + # -- zero-impedance LINES -- + df_line_fuse = net.get_lines() + if sort_index: + df_line_fuse = df_line_fuse.sort_index() + df_line_fuse_pu = net_pu.get_lines().loc[df_line_fuse.index] + both_conn = (df_line_fuse["connected1"].values & df_line_fuse["connected2"].values) + is_zero = ((np.abs(df_line_fuse_pu["r"].values) < zero_impedance_threshold_pu) + & (np.abs(df_line_fuse_pu["x"].values) < zero_impedance_threshold_pu)) + line_candidate = both_conn & is_zero + if line_candidate.any(): + cand = df_line_fuse[line_candidate] + vn1 = voltage_levels.loc[cand["voltage_level1_id"].values, "nominal_v"].values + vn2 = voltage_levels.loc[cand["voltage_level2_id"].values, "nominal_v"].values + bad = ~np.isclose(vn1, vn2) + if bad.any(): + raise RuntimeError( + f"Zero-impedance line(s) {list(cand.index[bad])} connect two buses at " + "different nominal voltages: this is inconsistent grid data, refusing " + "to fuse them (fuse_zero_impedance_branches=True)." + ) + gid1 = bus_df.loc[cand["bus1_id"].values, "bus_global_id"].values + gid2 = bus_df.loc[cand["bus2_id"].values, "bus_global_id"].values + for a, b in zip(gid1, gid2): + _uf_union(int(a), int(b)) + fused_line_ids.update(cand.index) + + # -- zero-impedance, neutral-tap, SAME-NOMINAL-VOLTAGE TRANSFORMERS -- + # NB: pypowsybl's per-unit `rho` is the deviation from the transformer's OWN + # rated_u1/rated_u2 ratio (tap-changer effect), not the absolute turns ratio: + # a genuine step-down transformer (eg rated_u1=225kV/rated_u2=90kV) reports + # rho=1 at neutral tap even though it performs a real voltage transformation + # (implicit in the differing per-unit bus voltage bases on each side, not in + # `rho` itself). So `rho~=1` alone only means "no ADDITIONAL tap deviation" -- + # it does NOT mean "no transformation at all". A transformer is only a true, + # fusable ideal wire when it is ALSO between two buses of the same nominal + # voltage (same requirement as for lines, just not an error here since most + # real transformers legitimately span different nominal voltages). + df_trafo_fuse = _aux_get_2wt_all_attrs(net) + if sort_index: + df_trafo_fuse = df_trafo_fuse.sort_index() + if df_trafo_fuse.shape[0] > 0: + df_trafo_fuse_pu = _aux_get_2wt_all_attrs(net_pu).loc[df_trafo_fuse.index] + ratio_tap_changer_fuse = net_pu.get_ratio_tap_changers() + alpha_fuse = _aux_trafo_alpha(df_trafo_fuse_pu, net) + rho_fuse = _aux_trafo_rho(df_trafo_fuse_pu, ratio_tap_changer_fuse) + both_conn_t = (df_trafo_fuse["connected1"].values & df_trafo_fuse["connected2"].values) + is_zero_t = ((np.abs(df_trafo_fuse_pu["r"].values) < zero_impedance_threshold_pu) + & (np.abs(df_trafo_fuse_pu["x"].values) < zero_impedance_threshold_pu)) + is_ideal_t = (np.abs(rho_fuse - 1.) < 1e-9) & (np.abs(alpha_fuse) < 1e-9) + vn1_t = voltage_levels.loc[df_trafo_fuse["voltage_level1_id"].values, "nominal_v"].values + vn2_t = voltage_levels.loc[df_trafo_fuse["voltage_level2_id"].values, "nominal_v"].values + same_vn_t = np.isclose(vn1_t, vn2_t) + trafo_candidate = both_conn_t & is_zero_t & is_ideal_t & same_vn_t + if trafo_candidate.any(): + cand_t = df_trafo_fuse[trafo_candidate] + gid1 = bus_df.loc[cand_t["bus1_id"].values, "bus_global_id"].values + gid2 = bus_df.loc[cand_t["bus2_id"].values, "bus_global_id"].values + for a, b in zip(gid1, gid2): + _uf_union(int(a), int(b)) + fused_trafo_ids.update(cand_t.index) + + rep = np.array([_uf_find(i) for i in range(parent.shape[0])]) + bus_df["bus_global_id"] = rep[bus_df["bus_global_id"].values] + # do the generators gen_attrs = [ "connected", "max_p", "target_p", "target_v", "target_q", "p", @@ -839,16 +1029,7 @@ def init(net : pypo.network.Network, ) # per unit - if net_pu is None: - if hasattr(net, "per_unit"): - net_pu = copy.deepcopy(net) - net_pu.per_unit = True - else: - # legacy pypowsybl mode: this did not exist - from pypowsybl.network import PerUnitView - net_pu = PerUnitView(net) - warnings.warn("The `PerUnitView` (python side) is less efficient and less " - "tested that the equivalent java class. Please upgrade pypowsybl version") + net_pu = _aux_ensure_net_pu(net, net_pu) df_line_pu = net_pu.get_lines().loc[df_line.index] if df_dl.shape[0] > 0: # equivalent branch (local bus -> fictitious boundary bus) for each @@ -896,6 +1077,10 @@ def init(net : pypo.network.Network, model.deactivate_powerline_side1(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) elif is_ex_disc: model.deactivate_powerline_side2(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) + elif fuse_zero_impedance_branches and df_line.index[line_id] in fused_line_ids: + # both terminal buses already fused into one node above: this line + # would otherwise contribute a 1/Z admittance (Inf for an exact zero) + model.deactivate_powerline(line_id) model.set_line_names(df_line.index) line_limit_a1_ka, line_limit_a2_ka = _aux_current_limits( df_line.index, @@ -908,35 +1093,20 @@ def init(net : pypo.network.Network, # for trafo # I extract trafo with `all_attributes=True` so that I have access to the `rho` - try: - df_trafo_not_sorted = net.get_2_windings_transformers(all_attributes=True) - except TypeError: - # not available in legacy pypowsybl version - df_trafo_not_sorted = net.get_2_windings_transformers() - + df_trafo_not_sorted = _aux_get_2wt_all_attrs(net) + if sort_index: df_trafo = df_trafo_not_sorted.sort_index() else: df_trafo = df_trafo_not_sorted - - try : - df_trafo_pu = net_pu.get_2_windings_transformers(all_attributes=True) - except TypeError: - df_trafo_pu = net_pu.get_2_windings_transformers() + + df_trafo_pu = _aux_get_2wt_all_attrs(net_pu) df_trafo_pu = df_trafo_pu.loc[df_trafo.index] ratio_tap_changer = net_pu.get_ratio_tap_changers() - - if 'alpha' in df_trafo_pu: - shift_ = np.rad2deg(df_trafo_pu['alpha'].values) # given in radian by pypowsybl - else: - if net.get_phase_tap_changers().shape[0] > 0: - raise RuntimeError("Phase tap changer are not handled by the pypowsybl converter " - "when not accessible using the 'alpha' columns " - "of the net (once per unit). Please upgrade pypowsybl." - "NB: phase tap change are handled by lightsim2grid)") - shift_ = np.zeros(df_trafo.shape[0]) + + shift_ = _aux_trafo_alpha(df_trafo_pu, net) # tap is side 2 in IIDM - is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) + is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) # neutral-tap impedance (the phase-shift -> r/x dependence of phase-shifting # transformers is handled by lightsim2grid as a function of the shift alpha, see # the model.set_trafo_shift_dependent_rx(...) call below). @@ -946,20 +1116,7 @@ def init(net : pypo.network.Network, # now get the ratio # in lightsim2grid (cpp) - if "rho" in df_trafo_pu: - ratio = 1. * df_trafo_pu["rho"].values - else: - # in powsybl (https://javadoc.io/doc/com.powsybl/powsybl-core/latest/com/powsybl/iidm/network/TwoWindingsTransformer.html) - # rho = transfo.getRatedU2() / transfo.getRatedU1() - # * (transfo.getRatioTapChanger() != null ? transfo.getRatioTapChanger().getCurrentStep().getRho() : 1); - # * (transfo.getPhaseTapChanger() != null ? transfo.getPhaseTapChanger().getCurrentStep().getRho() : 1); - - ratio = 1. * (df_trafo_pu["rated_u2"].values / df_trafo_pu["rated_u1"].values) - has_r_tap_changer = np.isin(df_trafo_pu.index, ratio_tap_changer.index) - - if PYPOWSYBL_VER <= PP_BUG_RATIO_TAP_CHANGER: - # bug in per unit view in both python and java - ratio[has_r_tap_changer] = 1. * ratio_tap_changer.loc[df_trafo_pu.loc[has_r_tap_changer].index, "rho"].values + ratio = _aux_trafo_rho(df_trafo_pu, ratio_tap_changer) tor_bus, tor_disco, tor_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 1)", df_trafo, conn_key="connected1", bus_key="bus1_id", vl_key="voltage_level1_id") tex_bus, tex_disco, tex_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 2)", df_trafo, conn_key="connected2", bus_key="bus2_id", vl_key="voltage_level2_id") @@ -980,6 +1137,9 @@ def init(net : pypo.network.Network, model.deactivate_trafo_side1(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) elif is_ex_disc: model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) + elif fuse_zero_impedance_branches and df_trafo.index[t_id] in fused_trafo_ids: + # both terminal buses already fused into one node above + model.deactivate_trafo(t_id) model.set_trafo_names(df_trafo.index) if "selected_limits_group_1" in df_trafo.columns: trafo_group_1 = df_trafo["selected_limits_group_1"] diff --git a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py new file mode 100644 index 00000000..7a25063d --- /dev/null +++ b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py @@ -0,0 +1,259 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""`fuse_zero_impedance_branches` (from_pypowsybl `init()`). + +A real RTE grid was found to have a line with `r_pu = x_pu = 0.0`. lightsim2grid +computed `B = 1/X = Inf`, corrupting the Ybus/dcYbus with literal `+/-Inf` entries +and making the sparse LU factorization fail outright. OpenLoadFlow avoids this via +its `lowImpedanceBranchMode` (default `REPLACE_BY_ZERO_IMPEDANCE_LINE`): the two +terminal buses of a near-zero-impedance branch are fused into a single electrical +node instead of computing `1/Z`. This is the same behaviour, opt-in via +`fuse_zero_impedance_branches` (see HVDC_OLF_FINDINGS.md and the docstring of +`lightsim2grid.network.from_pypowsybl.init`). +""" + +import unittest +import warnings + +import numpy as np + +try: + import pypowsybl.network as pn + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +def _dc_pf_ok(model): + nb_bus = model.get_bus_vn_kv().shape[0] + flat = np.full(nb_bus, 1.0, dtype=complex) + Vdc = model.dc_pf(flat, 30, 1e-8) + return Vdc, Vdc.shape[0] > 0 + + +class TestBusFusionZeroImpedanceLine(unittest.TestCase): + """A single r=x=0 line in an otherwise normal (ieee14-sized) grid.""" + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = pn.create_ieee14() + # L6-11-1 connects two non-slack, non-reference buses (unlike L1-2-1, + # which happens to touch the reference bus and would not reproduce the + # crash: eliminating the reference row/column also eliminates the Inf + # entries by coincidence). + self.net.update_lines(id="L6-11-1", r=0.0, x=0.0) + + def test_flag_off_reproduces_the_bug(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + _, ok = _dc_pf_ok(model) + self.assertFalse(ok, "dc_pf should fail without fuse_zero_impedance_branches " + "(Inf entries from B=1/X=1/0 break the LU factorization)") + + def test_flag_on_fixes_it_and_fuses_buses(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False, + fuse_zero_impedance_branches=True) + line = next(l for l in model.get_lines() if l.name == "L6-11-1") + # the fusing line is kept (for bookkeeping) but fully deactivated + self.assertFalse(line.connected1) + self.assertFalse(line.connected2) + self.assertEqual(line.bus1_id, -1) + self.assertEqual(line.bus2_id, -1) + + Vdc, ok = _dc_pf_ok(model) + self.assertTrue(ok, "dc_pf should succeed with fuse_zero_impedance_branches=True") + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0, "ac_pf should also succeed") + + # find the two ORIGINAL terminal buses of L6-11-1 (before fusion) using a + # model built WITHOUT the flag (where L6-11-1 still has real bus ids), via + # two OTHER lines each touching exactly one of the two original buses + # (bus "6" also carries L6-12-1/L6-13-1, bus "11" also carries L10-11-1). + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model_off = init_from_pypowsybl(self.net, sort_index=False) + by_name_off = {l.name: l for l in model_off.get_lines()} + fused = by_name_off["L6-11-1"] + bus_side1, bus_side2 = fused.bus1_id, fused.bus2_id + + def _anchor(name, target_bus): + l = by_name_off[name] + return l.bus1_id if l.bus1_id == target_bus else l.bus2_id + + anchor1_id = _anchor("L6-12-1", bus_side1) + anchor2_id = _anchor("L10-11-1", bus_side2) + self.assertEqual(anchor1_id, bus_side1) + self.assertEqual(anchor2_id, bus_side2) + + by_name_on = {l.name: l for l in model.get_lines()} + + def _same_side_bus(name, target_bus): + l = by_name_on[name] + return l.bus1_id if l.bus1_id == target_bus or l.bus2_id != target_bus else l.bus2_id + + # since fusion never renumbers an unaffected bus, whichever of the two + # original ids is NOT the (smaller-id) representative disappears from + # every OTHER element too, replaced by the representative -- so the two + # anchor lines' relevant side should now agree. + b1_on = by_name_on["L6-12-1"].bus1_id if by_name_off["L6-12-1"].bus1_id == bus_side1 else by_name_on["L6-12-1"].bus2_id + b2_on = by_name_on["L10-11-1"].bus1_id if by_name_off["L10-11-1"].bus1_id == bus_side2 else by_name_on["L10-11-1"].bus2_id + self.assertEqual(b1_on, b2_on, + "the two original terminal buses of the fused line " + "should now resolve to the same solver bus") + self.assertAlmostEqual(V[b1_on], V[b2_on]) + + +class TestBusFusionCrossVoltageLineRaises(unittest.TestCase): + """A zero-impedance line spanning two different nominal voltages is + inconsistent data and must raise, never be silently fused.""" + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = pn.create_empty() + self.net.create_substations(id="S1") + self.net.create_voltage_levels(substation_id="S1", id="VL1", nominal_v=225.0, + topology_kind="BUS_BREAKER") + self.net.create_voltage_levels(substation_id="S1", id="VL2", nominal_v=90.0, + topology_kind="BUS_BREAKER") + self.net.create_buses(voltage_level_id="VL1", id="B1") + self.net.create_buses(voltage_level_id="VL2", id="B2") + self.net.create_lines(id="L1", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + + def test_raises(self): + with self.assertRaises(RuntimeError): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + init_from_pypowsybl(self.net, sort_index=False, + fuse_zero_impedance_branches=True, + only_main_component=False) + + +class TestBusFusionTransformer(unittest.TestCase): + """A zero-impedance, neutral-tap transformer between two SAME-nominal-voltage + buses is a genuine ideal wire and gets fused; the same transformer between two + DIFFERENT nominal voltages must NOT be fused, even though pypowsybl's per-unit + `rho` still reports ~1 (rho is the deviation from the transformer's own rated + ratio, not its absolute turns ratio).""" + + def _build(self, vn2): + net = pn.create_empty() + net.create_substations(id="S1") + net.create_voltage_levels(substation_id="S1", id="VL1", nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_voltage_levels(substation_id="S1", id="VL2", nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_voltage_levels(substation_id="S1", id="VL3", nominal_v=vn2, + topology_kind="BUS_BREAKER") + net.create_buses(voltage_level_id="VL1", id="B1") + net.create_buses(voltage_level_id="VL2", id="B2") + net.create_buses(voltage_level_id="VL3", id="B3") + net.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, + min_p=0.0, max_p=100.0, voltage_regulator_on=True) + net.create_loads(id="LD1", voltage_level_id="VL3", bus_id="B3", p0=5.0, q0=1.0) + # the candidate zero-impedance transformer: B1 (225kV) <-> B2 (225kV) + net.create_2_windings_transformers(id="T1", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g=0.0, b=0.0, + rated_u1=225.0, rated_u2=225.0) + # a real, non-zero-impedance line so the system doesn't collapse to a + # single trivial bus after fusion (a pre-existing, unrelated lightsim2grid + # issue with 0-non-slack-bus networks, not something this feature should + # need to work around by construction). + net.create_lines(id="L23", voltage_level1_id="VL2", bus1_id="B2", + voltage_level2_id="VL3", bus2_id="B3", + r=0.01, x=0.1, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + return net + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + + def test_same_nominal_voltage_is_fused(self): + net = self._build(vn2=225.0) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=True) + trafo = next(t for t in model.get_trafos() if t.name == "T1") + self.assertFalse(trafo.connected1) + self.assertFalse(trafo.connected2) + + gen_bus = next(g.bus_id for g in model.get_generators()) + line_bus1 = next(l.bus1_id for l in model.get_lines()) + self.assertEqual(gen_bus, line_bus1, + "B1 and B2 should be fused to the same solver bus") + + Vdc, ok = _dc_pf_ok(model) + self.assertTrue(ok) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + def test_different_nominal_voltage_is_not_fused(self): + # B1 is 225kV, "VL3"/B3 stays 225kV too here but T1 itself is still + # between B1 (225kV) and B2 (225kV) -- to actually test the cross-voltage + # guard we need T1's OWN two sides to differ, so build a dedicated net. + net = pn.create_empty() + net.create_substations(id="S1") + net.create_voltage_levels(substation_id="S1", id="VL1", nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_voltage_levels(substation_id="S1", id="VL2", nominal_v=90.0, + topology_kind="BUS_BREAKER") + net.create_buses(voltage_level_id="VL1", id="B1") + net.create_buses(voltage_level_id="VL2", id="B2") + net.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, + min_p=0.0, max_p=100.0, voltage_regulator_on=True) + net.create_loads(id="LD1", voltage_level_id="VL2", bus_id="B2", p0=5.0, q0=1.0) + net.create_2_windings_transformers(id="T1", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g=0.0, b=0.0, + rated_u1=225.0, rated_u2=90.0) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=True) + trafo = next(t for t in model.get_trafos() if t.name == "T1") + self.assertTrue(trafo.connected1, "a genuine (even if lossless) " + "step-up/down transformer must never be fused") + self.assertTrue(trafo.connected2) + + +class TestBusFusionFlagOffUnchanged(unittest.TestCase): + """With the flag omitted (default), behaviour must be identical to before + this feature existed.""" + + def test_default_is_false_and_unchanged(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + net = pn.create_ieee14() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model_default = init_from_pypowsybl(net, sort_index=False) + model_explicit_off = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=False) + self.assertEqual(model_default.get_bus_vn_kv().shape[0], + model_explicit_off.get_bus_vn_kv().shape[0]) + Vdc1, ok1 = _dc_pf_ok(model_default) + Vdc2, ok2 = _dc_pf_ok(model_explicit_off) + self.assertEqual(ok1, ok2) + if ok1: + np.testing.assert_array_almost_equal(Vdc1, Vdc2) + + +if __name__ == "__main__": + unittest.main() From 02ad7078dfe9bb2c30a72cf759aa586f43a91da8 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 17:07:59 +0200 Subject: [PATCH 039/166] forget changelog Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 138d7ca6..ac205418 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -384,6 +384,20 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `qt` to solver tolerance, and synthetic-network checks of the `"storage"` and `"dcline"` conversions (PFΔ's own pglib-derived cases never contain either, but the schema and `init_from_powermodels` both support them). +- [ADDED] `init_from_pypowsybl(..., fuse_zero_impedance_branches=True)`: a line or + 2-winding transformer whose per-unit impedance is (near-)zero has its two terminal + buses fused into a single electrical node instead of contributing a `1/Z` admittance + (`Inf` for an exact zero, which broke the sparse LU factorization outright -- found on + a real RTE grid with a genuine `r=x=0` line). Mirrors PowSyBl OpenLoadFlow's + `lowImpedanceBranchMode`/`lowImpedanceThreshold` (new `zero_impedance_threshold_pu` + parameter, default `1e-8` pu, matching OLF's default). A zero-impedance transformer is + only fused if it is also at (near-)neutral tap **and** its two sides are at the same + nominal voltage: PowSyBl's per-unit `rho` is the deviation from the transformer's own + rated ratio (the tap-changer effect), not its absolute turns ratio, so `rho~=1` alone + does not mean "no transformation" for a genuine step-up/down transformer. A + zero-impedance *line* spanning two different nominal voltages raises a `RuntimeError` + (inconsistent grid data) rather than being silently fused. Off by default (existing + behaviour unchanged); tested in `test_bus_fusion_pypowsybl.py`. [0.13.1] 2026-04-21 -------------------- From b4ece582703c271db9e74f1f224b5836ce07584d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jul 2026 17:07:59 +0200 Subject: [PATCH 040/166] forget changelog Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 138d7ca6..ac205418 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -384,6 +384,20 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `qt` to solver tolerance, and synthetic-network checks of the `"storage"` and `"dcline"` conversions (PFΔ's own pglib-derived cases never contain either, but the schema and `init_from_powermodels` both support them). +- [ADDED] `init_from_pypowsybl(..., fuse_zero_impedance_branches=True)`: a line or + 2-winding transformer whose per-unit impedance is (near-)zero has its two terminal + buses fused into a single electrical node instead of contributing a `1/Z` admittance + (`Inf` for an exact zero, which broke the sparse LU factorization outright -- found on + a real RTE grid with a genuine `r=x=0` line). Mirrors PowSyBl OpenLoadFlow's + `lowImpedanceBranchMode`/`lowImpedanceThreshold` (new `zero_impedance_threshold_pu` + parameter, default `1e-8` pu, matching OLF's default). A zero-impedance transformer is + only fused if it is also at (near-)neutral tap **and** its two sides are at the same + nominal voltage: PowSyBl's per-unit `rho` is the deviation from the transformer's own + rated ratio (the tap-changer effect), not its absolute turns ratio, so `rho~=1` alone + does not mean "no transformation" for a genuine step-up/down transformer. A + zero-impedance *line* spanning two different nominal voltages raises a `RuntimeError` + (inconsistent grid data) rather than being silently fused. Off by default (existing + behaviour unchanged); tested in `test_bus_fusion_pypowsybl.py`. [0.13.1] 2026-04-21 -------------------- From 6078e8e1097da386d4b8300056bc0a7b3c25d397 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 09:40:25 +0200 Subject: [PATCH 041/166] fixing some other issues when reading real data Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 14 +++ .../network/from_pypowsybl/_from_pypowsybl.py | 31 ++++- .../tests/test_gen_reactive_curve_swap.py | 50 ++++++++ ...test_remote_control_disconnected_target.py | 110 ++++++++++++++++++ 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 lightsim2grid/tests/test_gen_reactive_curve_swap.py create mode 100644 lightsim2grid/tests/test_remote_control_disconnected_target.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac205418..a5195448 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -398,6 +398,20 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. zero-impedance *line* spanning two different nominal voltages raises a `RuntimeError` (inconsistent grid data) rather than being silently fused. Off by default (existing behaviour unchanged); tested in `test_bus_fusion_pypowsybl.py`. +- [FIXED] `init_from_pypowsybl` no longer crashes (`GeneratorContainer::init: ...min_q + being above max_q...`) when a generator's reactive capability curve has a malformed + point (`min_q > max_q` at that active power, a data-entry error) that, after the + curve is interpolated at the generator's `target_p`, yields an inverted interval. + OpenLoadFlow tolerates this silently; lightsim2grid now sorts the pair instead of + hard-rejecting it (found on real RTE grids PtFige-20240531-2225 / PtFige-20240601-0030, + generators CSTTT.HG1/CSTTT.HG2). Tested in `test_gen_reactive_curve_swap.py`. +- [FIXED] `init_from_pypowsybl` no longer crashes (`KeyError: "[''] not in index"`) when + a *connected* generator or SVC remotely regulates the voltage of a terminal that is + itself disconnected (e.g. a de-energized voltage level). PowSyBl's bus-view id for a + disconnected element is `''`, not NaN, and could not be resolved to any bus; the + controller now falls back to local voltage control instead of crashing, matching + OpenLoadFlow's behaviour (found on real RTE grid PtFige-20251102-0905, generators + B.SSBEA1/B.SSBEA2). Tested in `test_remote_control_disconnected_target.py`. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index d174e003..6b6834cb 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -925,11 +925,21 @@ def _uf_union(a, b): min_q_src = df_gen["min_q_at_target_p"].where(df_gen["min_q_at_target_p"].notna(), df_gen["min_q"]) max_q_src = df_gen["max_q_at_target_p"].where(df_gen["max_q_at_target_p"].notna(), df_gen["max_q"]) min_q_aux = 1. * min_q_src.values + max_q_aux = 1. * max_q_src.values + # malformed source curve data (eg a reactive capability curve point entered with + # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. + # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init + # hard-rejects it (real case found on PtFige-20240531-2225 / PtFige-20240601-0030, + # generators CSTTT.HG1/CSTTT.HG2). Restore a valid interval by sorting the pair + # instead of crashing -- this only ever affects the (already tiny) width of the + # interval, never which generators get a reactive constraint at all. + swapped = min_q_aux > max_q_aux + if swapped.any(): + min_q_aux[swapped], max_q_aux[swapped] = max_q_aux[swapped], min_q_aux[swapped].copy() too_small = min_q_aux < min_float_value min_q_aux[too_small] = min_float_value min_q = min_q_aux.astype(np.float32) - max_q_aux = 1. * max_q_src.values too_big = np.abs(max_q_aux) > max_float_value max_q_aux[too_big] = np.sign(max_q_aux[too_big]) * max_float_value max_q = max_q_aux.astype(np.float32) @@ -954,7 +964,18 @@ def _uf_union(a, b): mask_remote_gen = (bus_reg != df_gen.index.values) & ~gen_disco gen_reg_bus_view = None if mask_remote_gen.any(): + remote_idx = np.nonzero(mask_remote_gen)[0] gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) + # a *connected* generator can still remotely regulate a busbar section that is + # itself disconnected (found on PtFige-20251102-0905: B.SSBEA1/B.SSBEA2 -> + # B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id for a + # disconnected element is '' , not NaN, and can't be resolved to any bus_df + # row. OLF converges fine on this grid, so it must fall back to local voltage + # control in this situation; mirror that instead of crashing. + unresolved = gen_reg_bus_view == "" + if unresolved.any(): + mask_remote_gen[remote_idx[unresolved]] = False + gen_reg_bus_view = gen_reg_bus_view[~unresolved] vl_reg[mask_remote_gen] = bus_df.loc[gen_reg_bus_view, "voltage_level_id"].values model.init_generators_full(df_gen["target_p"].values, # df_gen["target_v"].values / voltage_levels.loc[df_gen["voltage_level_id"].values]["nominal_v"].values, @@ -1217,7 +1238,15 @@ def _uf_union(a, b): # TODO: resolved once at import; if the regulated element later changes # bus inside lightsim2grid this stays frozen and desynchronises from the # original grid (see `_aux_regulated_bus_view_ids` warning). + remote_svc_idx = np.nonzero(mask_svc_remote)[0] svc_reg_bus_view = _aux_regulated_bus_view_ids(net, reg_id[mask_svc_remote]) + # same disconnected-remote-target situation as for generators above: + # fall back to local control rather than crashing on an unresolvable + # (disconnected) regulated element. + unresolved_svc = svc_reg_bus_view == "" + if unresolved_svc.any(): + mask_svc_remote[remote_svc_idx[unresolved_svc]] = False + svc_reg_bus_view = svc_reg_bus_view[~unresolved_svc] svc_reg_bus[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "bus_global_id"].values svc_vl[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "voltage_level_id"].values svc_reg_vn = voltage_levels.loc[svc_vl, "nominal_v"].values diff --git a/lightsim2grid/tests/test_gen_reactive_curve_swap.py b/lightsim2grid/tests/test_gen_reactive_curve_swap.py new file mode 100644 index 00000000..c32898f3 --- /dev/null +++ b/lightsim2grid/tests/test_gen_reactive_curve_swap.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Malformed reactive capability curve data (min_q/max_q effectively "swapped" at +one of the curve points) was found on two real RTE grids (PtFige-20240531-2225 and +PtFige-20240601-0030, generators CSTTT.HG1/CSTTT.HG2): pypowsybl's "at target p" +interpolation then yields min_q > max_q, which OpenLoadFlow tolerates silently but +which used to make lightsim2grid's ``GeneratorContainer::init`` hard-crash with +``RuntimeError: ... min_q being above max_q``. `from_pypowsybl.init()` now sorts +the pair instead of crashing. +""" + +import unittest +import warnings + +try: + import pypowsybl.network as pn + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +class TestGenReactiveCurveSwap(unittest.TestCase): + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = pn.create_ieee14() + self.gid = self.net.get_generators().index[0] + # reproduces the real-world curve found on CSTTT.HG1/CSTTT.HG2: the + # p=0.99 point has min_q (0.053) > max_q (0.04), a data-entry error. + self.net.create_curve_reactive_limits( + id=[self.gid, self.gid], p=[-0.15, 0.99], + min_q=[0.0, 0.053], max_q=[0.0, 0.04]) + + def test_does_not_crash_and_sorts_the_pair(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + gen = next(g for g in model.get_generators() if g.name == self.gid) + self.assertLessEqual(gen.min_q_mvar, gen.max_q_mvar) + + +if __name__ == "__main__": + unittest.main() diff --git a/lightsim2grid/tests/test_remote_control_disconnected_target.py b/lightsim2grid/tests/test_remote_control_disconnected_target.py new file mode 100644 index 00000000..8959142e --- /dev/null +++ b/lightsim2grid/tests/test_remote_control_disconnected_target.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""A *connected* generator (or SVC) can remotely regulate the voltage of a +terminal that is itself disconnected (found on a real RTE grid, +PtFige-20251102-0905: generators B.SSBEA1/B.SSBEA2 remotely regulate busbar +sections B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id +for a disconnected element is `''`, not NaN, which used to crash +`from_pypowsybl.init()` with `KeyError: "[''] not in index"`. OpenLoadFlow +converges fine on such grids, so `init()` now falls back to local voltage +control for the affected controller instead of crashing. + +This is a different case from the one covered by +`test_disconnected_remote_voltage_control` (a *disconnected* generator +remotely regulating a disconnected element, which is simply moot since the +generator itself gets deactivated): here the controller is connected and +actually needs a fallback so it keeps regulating *something*. +""" + +import unittest +import warnings + +try: + import pypowsybl.network as pn + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +def _build_base_net(): + net = pn.create_empty() + net.create_substations(id=["S1", "S2"]) + net.create_voltage_levels(substation_id=["S1", "S2"], id=["VL1", "VL2"], + nominal_v=[225.0, 225.0], + topology_kind=["BUS_BREAKER", "BUS_BREAKER"]) + net.create_buses(voltage_level_id=["VL1", "VL2"], id=["B1", "B2"]) + net.create_lines(id="L12", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.1, x=1.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + net.create_loads(id="LD1", voltage_level_id="VL2", bus_id="B2", p0=5.0, q0=1.0) + return net + + +class TestRemoteGenDisconnectedTarget(unittest.TestCase): + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = _build_base_net() + # a disconnected load, its terminal's bus-view id resolves to '' + self.net.create_loads(id="LD_TARGET", voltage_level_id="VL2", bus_id="B2", + p0=0.0, q0=0.0) + self.net.update_loads(id="LD_TARGET", connected=False) + self.net.create_generators(id="G0", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, min_p=0.0, max_p=100.0, + voltage_regulator_on=True) + self.net.update_generators(id="G0", regulated_element_id="LD_TARGET") + + def test_does_not_crash_and_falls_back_to_local_control(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + gen = next(g for g in model.get_generators() if g.name == "G0") + self.assertTrue(gen.connected) + flat_v = [1.0] * model.get_bus_vn_kv().shape[0] + import numpy as np + Vdc = model.dc_pf(np.array(flat_v, dtype=complex), 30, 1e-8) + self.assertGreater(Vdc.shape[0], 0) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + +class TestRemoteSVCDisconnectedTarget(unittest.TestCase): + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = _build_base_net() + self.net.create_loads(id="LD_TARGET", voltage_level_id="VL2", bus_id="B2", + p0=0.0, q0=0.0) + self.net.update_loads(id="LD_TARGET", connected=False) + self.net.create_generators(id="G0", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, min_p=0.0, max_p=100.0, + voltage_regulator_on=True) + # SVC sits on B2 (a PQ bus, no other voltage controller there) so it doesn't + # clash with G0's local PV control on B1. + self.net.create_static_var_compensators( + id="SVC1", voltage_level_id="VL2", bus_id="B2", + b_min=-0.01, b_max=0.01, target_v=225.0, target_q=0.0, + regulation_mode="VOLTAGE", regulating=True) + self.net.update_static_var_compensators(id="SVC1", regulated_element_id="LD_TARGET") + + def test_does_not_crash_and_falls_back_to_local_control(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + import numpy as np + flat_v = np.full(model.get_bus_vn_kv().shape[0], 1.0, dtype=complex) + Vdc = model.dc_pf(flat_v, 30, 1e-8) + self.assertGreater(Vdc.shape[0], 0) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + +if __name__ == "__main__": + unittest.main() From ab1ab0e9f2bd2b4708123d4a7ad38c2bf483b08d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 09:40:25 +0200 Subject: [PATCH 042/166] fixing some other issues when reading real data Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 14 +++ .../network/from_pypowsybl/_from_pypowsybl.py | 31 ++++- .../tests/test_gen_reactive_curve_swap.py | 50 ++++++++ ...test_remote_control_disconnected_target.py | 110 ++++++++++++++++++ 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 lightsim2grid/tests/test_gen_reactive_curve_swap.py create mode 100644 lightsim2grid/tests/test_remote_control_disconnected_target.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac205418..a5195448 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -398,6 +398,20 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. zero-impedance *line* spanning two different nominal voltages raises a `RuntimeError` (inconsistent grid data) rather than being silently fused. Off by default (existing behaviour unchanged); tested in `test_bus_fusion_pypowsybl.py`. +- [FIXED] `init_from_pypowsybl` no longer crashes (`GeneratorContainer::init: ...min_q + being above max_q...`) when a generator's reactive capability curve has a malformed + point (`min_q > max_q` at that active power, a data-entry error) that, after the + curve is interpolated at the generator's `target_p`, yields an inverted interval. + OpenLoadFlow tolerates this silently; lightsim2grid now sorts the pair instead of + hard-rejecting it (found on real RTE grids PtFige-20240531-2225 / PtFige-20240601-0030, + generators CSTTT.HG1/CSTTT.HG2). Tested in `test_gen_reactive_curve_swap.py`. +- [FIXED] `init_from_pypowsybl` no longer crashes (`KeyError: "[''] not in index"`) when + a *connected* generator or SVC remotely regulates the voltage of a terminal that is + itself disconnected (e.g. a de-energized voltage level). PowSyBl's bus-view id for a + disconnected element is `''`, not NaN, and could not be resolved to any bus; the + controller now falls back to local voltage control instead of crashing, matching + OpenLoadFlow's behaviour (found on real RTE grid PtFige-20251102-0905, generators + B.SSBEA1/B.SSBEA2). Tested in `test_remote_control_disconnected_target.py`. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index d174e003..6b6834cb 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -925,11 +925,21 @@ def _uf_union(a, b): min_q_src = df_gen["min_q_at_target_p"].where(df_gen["min_q_at_target_p"].notna(), df_gen["min_q"]) max_q_src = df_gen["max_q_at_target_p"].where(df_gen["max_q_at_target_p"].notna(), df_gen["max_q"]) min_q_aux = 1. * min_q_src.values + max_q_aux = 1. * max_q_src.values + # malformed source curve data (eg a reactive capability curve point entered with + # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. + # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init + # hard-rejects it (real case found on PtFige-20240531-2225 / PtFige-20240601-0030, + # generators CSTTT.HG1/CSTTT.HG2). Restore a valid interval by sorting the pair + # instead of crashing -- this only ever affects the (already tiny) width of the + # interval, never which generators get a reactive constraint at all. + swapped = min_q_aux > max_q_aux + if swapped.any(): + min_q_aux[swapped], max_q_aux[swapped] = max_q_aux[swapped], min_q_aux[swapped].copy() too_small = min_q_aux < min_float_value min_q_aux[too_small] = min_float_value min_q = min_q_aux.astype(np.float32) - max_q_aux = 1. * max_q_src.values too_big = np.abs(max_q_aux) > max_float_value max_q_aux[too_big] = np.sign(max_q_aux[too_big]) * max_float_value max_q = max_q_aux.astype(np.float32) @@ -954,7 +964,18 @@ def _uf_union(a, b): mask_remote_gen = (bus_reg != df_gen.index.values) & ~gen_disco gen_reg_bus_view = None if mask_remote_gen.any(): + remote_idx = np.nonzero(mask_remote_gen)[0] gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) + # a *connected* generator can still remotely regulate a busbar section that is + # itself disconnected (found on PtFige-20251102-0905: B.SSBEA1/B.SSBEA2 -> + # B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id for a + # disconnected element is '' , not NaN, and can't be resolved to any bus_df + # row. OLF converges fine on this grid, so it must fall back to local voltage + # control in this situation; mirror that instead of crashing. + unresolved = gen_reg_bus_view == "" + if unresolved.any(): + mask_remote_gen[remote_idx[unresolved]] = False + gen_reg_bus_view = gen_reg_bus_view[~unresolved] vl_reg[mask_remote_gen] = bus_df.loc[gen_reg_bus_view, "voltage_level_id"].values model.init_generators_full(df_gen["target_p"].values, # df_gen["target_v"].values / voltage_levels.loc[df_gen["voltage_level_id"].values]["nominal_v"].values, @@ -1217,7 +1238,15 @@ def _uf_union(a, b): # TODO: resolved once at import; if the regulated element later changes # bus inside lightsim2grid this stays frozen and desynchronises from the # original grid (see `_aux_regulated_bus_view_ids` warning). + remote_svc_idx = np.nonzero(mask_svc_remote)[0] svc_reg_bus_view = _aux_regulated_bus_view_ids(net, reg_id[mask_svc_remote]) + # same disconnected-remote-target situation as for generators above: + # fall back to local control rather than crashing on an unresolvable + # (disconnected) regulated element. + unresolved_svc = svc_reg_bus_view == "" + if unresolved_svc.any(): + mask_svc_remote[remote_svc_idx[unresolved_svc]] = False + svc_reg_bus_view = svc_reg_bus_view[~unresolved_svc] svc_reg_bus[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "bus_global_id"].values svc_vl[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "voltage_level_id"].values svc_reg_vn = voltage_levels.loc[svc_vl, "nominal_v"].values diff --git a/lightsim2grid/tests/test_gen_reactive_curve_swap.py b/lightsim2grid/tests/test_gen_reactive_curve_swap.py new file mode 100644 index 00000000..c32898f3 --- /dev/null +++ b/lightsim2grid/tests/test_gen_reactive_curve_swap.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Malformed reactive capability curve data (min_q/max_q effectively "swapped" at +one of the curve points) was found on two real RTE grids (PtFige-20240531-2225 and +PtFige-20240601-0030, generators CSTTT.HG1/CSTTT.HG2): pypowsybl's "at target p" +interpolation then yields min_q > max_q, which OpenLoadFlow tolerates silently but +which used to make lightsim2grid's ``GeneratorContainer::init`` hard-crash with +``RuntimeError: ... min_q being above max_q``. `from_pypowsybl.init()` now sorts +the pair instead of crashing. +""" + +import unittest +import warnings + +try: + import pypowsybl.network as pn + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +class TestGenReactiveCurveSwap(unittest.TestCase): + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = pn.create_ieee14() + self.gid = self.net.get_generators().index[0] + # reproduces the real-world curve found on CSTTT.HG1/CSTTT.HG2: the + # p=0.99 point has min_q (0.053) > max_q (0.04), a data-entry error. + self.net.create_curve_reactive_limits( + id=[self.gid, self.gid], p=[-0.15, 0.99], + min_q=[0.0, 0.053], max_q=[0.0, 0.04]) + + def test_does_not_crash_and_sorts_the_pair(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + gen = next(g for g in model.get_generators() if g.name == self.gid) + self.assertLessEqual(gen.min_q_mvar, gen.max_q_mvar) + + +if __name__ == "__main__": + unittest.main() diff --git a/lightsim2grid/tests/test_remote_control_disconnected_target.py b/lightsim2grid/tests/test_remote_control_disconnected_target.py new file mode 100644 index 00000000..8959142e --- /dev/null +++ b/lightsim2grid/tests/test_remote_control_disconnected_target.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""A *connected* generator (or SVC) can remotely regulate the voltage of a +terminal that is itself disconnected (found on a real RTE grid, +PtFige-20251102-0905: generators B.SSBEA1/B.SSBEA2 remotely regulate busbar +sections B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id +for a disconnected element is `''`, not NaN, which used to crash +`from_pypowsybl.init()` with `KeyError: "[''] not in index"`. OpenLoadFlow +converges fine on such grids, so `init()` now falls back to local voltage +control for the affected controller instead of crashing. + +This is a different case from the one covered by +`test_disconnected_remote_voltage_control` (a *disconnected* generator +remotely regulating a disconnected element, which is simply moot since the +generator itself gets deactivated): here the controller is connected and +actually needs a fallback so it keeps regulating *something*. +""" + +import unittest +import warnings + +try: + import pypowsybl.network as pn + from lightsim2grid.network import init_from_pypowsybl + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + + +def _build_base_net(): + net = pn.create_empty() + net.create_substations(id=["S1", "S2"]) + net.create_voltage_levels(substation_id=["S1", "S2"], id=["VL1", "VL2"], + nominal_v=[225.0, 225.0], + topology_kind=["BUS_BREAKER", "BUS_BREAKER"]) + net.create_buses(voltage_level_id=["VL1", "VL2"], id=["B1", "B2"]) + net.create_lines(id="L12", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.1, x=1.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + net.create_loads(id="LD1", voltage_level_id="VL2", bus_id="B2", p0=5.0, q0=1.0) + return net + + +class TestRemoteGenDisconnectedTarget(unittest.TestCase): + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = _build_base_net() + # a disconnected load, its terminal's bus-view id resolves to '' + self.net.create_loads(id="LD_TARGET", voltage_level_id="VL2", bus_id="B2", + p0=0.0, q0=0.0) + self.net.update_loads(id="LD_TARGET", connected=False) + self.net.create_generators(id="G0", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, min_p=0.0, max_p=100.0, + voltage_regulator_on=True) + self.net.update_generators(id="G0", regulated_element_id="LD_TARGET") + + def test_does_not_crash_and_falls_back_to_local_control(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + gen = next(g for g in model.get_generators() if g.name == "G0") + self.assertTrue(gen.connected) + flat_v = [1.0] * model.get_bus_vn_kv().shape[0] + import numpy as np + Vdc = model.dc_pf(np.array(flat_v, dtype=complex), 30, 1e-8) + self.assertGreater(Vdc.shape[0], 0) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + +class TestRemoteSVCDisconnectedTarget(unittest.TestCase): + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + self.net = _build_base_net() + self.net.create_loads(id="LD_TARGET", voltage_level_id="VL2", bus_id="B2", + p0=0.0, q0=0.0) + self.net.update_loads(id="LD_TARGET", connected=False) + self.net.create_generators(id="G0", voltage_level_id="VL1", bus_id="B1", + target_p=10.0, target_v=225.0, min_p=0.0, max_p=100.0, + voltage_regulator_on=True) + # SVC sits on B2 (a PQ bus, no other voltage controller there) so it doesn't + # clash with G0's local PV control on B1. + self.net.create_static_var_compensators( + id="SVC1", voltage_level_id="VL2", bus_id="B2", + b_min=-0.01, b_max=0.01, target_v=225.0, target_q=0.0, + regulation_mode="VOLTAGE", regulating=True) + self.net.update_static_var_compensators(id="SVC1", regulated_element_id="LD_TARGET") + + def test_does_not_crash_and_falls_back_to_local_control(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(self.net, sort_index=False) + import numpy as np + flat_v = np.full(model.get_bus_vn_kv().shape[0], 1.0, dtype=complex) + Vdc = model.dc_pf(flat_v, 30, 1e-8) + self.assertGreater(Vdc.shape[0], 0) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + +if __name__ == "__main__": + unittest.main() From cd2cdb8aa10f6c29ba7913e79754a087d8965a4d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 10:04:46 +0200 Subject: [PATCH 043/166] fix some docstring Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 8 ++++---- .../network/from_pypowsybl/_from_pypowsybl.py | 18 +++++++++--------- .../tests/test_gen_reactive_curve_swap.py | 15 +++++++-------- .../test_remote_control_disconnected_target.py | 14 +++++++------- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a5195448..60a98b05 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -403,15 +403,15 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. point (`min_q > max_q` at that active power, a data-entry error) that, after the curve is interpolated at the generator's `target_p`, yields an inverted interval. OpenLoadFlow tolerates this silently; lightsim2grid now sorts the pair instead of - hard-rejecting it (found on real RTE grids PtFige-20240531-2225 / PtFige-20240601-0030, - generators CSTTT.HG1/CSTTT.HG2). Tested in `test_gen_reactive_curve_swap.py`. + hard-rejecting it (found on a real RTE grid snapshot). Tested in + `test_gen_reactive_curve_swap.py`. - [FIXED] `init_from_pypowsybl` no longer crashes (`KeyError: "[''] not in index"`) when a *connected* generator or SVC remotely regulates the voltage of a terminal that is itself disconnected (e.g. a de-energized voltage level). PowSyBl's bus-view id for a disconnected element is `''`, not NaN, and could not be resolved to any bus; the controller now falls back to local voltage control instead of crashing, matching - OpenLoadFlow's behaviour (found on real RTE grid PtFige-20251102-0905, generators - B.SSBEA1/B.SSBEA2). Tested in `test_remote_control_disconnected_target.py`. + OpenLoadFlow's behaviour (found on a real RTE grid snapshot). Tested in + `test_remote_control_disconnected_target.py`. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 6b6834cb..12678ad9 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -929,10 +929,10 @@ def _uf_union(a, b): # malformed source curve data (eg a reactive capability curve point entered with # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init - # hard-rejects it (real case found on PtFige-20240531-2225 / PtFige-20240601-0030, - # generators CSTTT.HG1/CSTTT.HG2). Restore a valid interval by sorting the pair - # instead of crashing -- this only ever affects the (already tiny) width of the - # interval, never which generators get a reactive constraint at all. + # hard-rejects it (real case found on a real RTE grid snapshot). Restore a valid + # interval by sorting the pair instead of crashing -- this only ever affects the + # (already tiny) width of the interval, never which generators get a reactive + # constraint at all. swapped = min_q_aux > max_q_aux if swapped.any(): min_q_aux[swapped], max_q_aux[swapped] = max_q_aux[swapped], min_q_aux[swapped].copy() @@ -967,11 +967,11 @@ def _uf_union(a, b): remote_idx = np.nonzero(mask_remote_gen)[0] gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) # a *connected* generator can still remotely regulate a busbar section that is - # itself disconnected (found on PtFige-20251102-0905: B.SSBEA1/B.SSBEA2 -> - # B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id for a - # disconnected element is '' , not NaN, and can't be resolved to any bus_df - # row. OLF converges fine on this grid, so it must fall back to local voltage - # control in this situation; mirror that instead of crashing. + # itself disconnected (found on a real RTE grid snapshot: a de-energized + # voltage level). pypowsybl's bus-view id for a disconnected element is '', + # not NaN, and can't be resolved to any bus_df row. OLF converges fine on such + # grids, so it must fall back to local voltage control in this situation; + # mirror that instead of crashing. unresolved = gen_reg_bus_view == "" if unresolved.any(): mask_remote_gen[remote_idx[unresolved]] = False diff --git a/lightsim2grid/tests/test_gen_reactive_curve_swap.py b/lightsim2grid/tests/test_gen_reactive_curve_swap.py index c32898f3..52ea3bd6 100644 --- a/lightsim2grid/tests/test_gen_reactive_curve_swap.py +++ b/lightsim2grid/tests/test_gen_reactive_curve_swap.py @@ -7,12 +7,11 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """Malformed reactive capability curve data (min_q/max_q effectively "swapped" at -one of the curve points) was found on two real RTE grids (PtFige-20240531-2225 and -PtFige-20240601-0030, generators CSTTT.HG1/CSTTT.HG2): pypowsybl's "at target p" -interpolation then yields min_q > max_q, which OpenLoadFlow tolerates silently but -which used to make lightsim2grid's ``GeneratorContainer::init`` hard-crash with -``RuntimeError: ... min_q being above max_q``. `from_pypowsybl.init()` now sorts -the pair instead of crashing. +one of the curve points) was found on a real RTE grid snapshot: pypowsybl's +"at target p" interpolation then yields min_q > max_q, which OpenLoadFlow +tolerates silently but which used to make lightsim2grid's +``GeneratorContainer::init`` hard-crash with ``RuntimeError: ... min_q being +above max_q``. `from_pypowsybl.init()` now sorts the pair instead of crashing. """ import unittest @@ -32,8 +31,8 @@ def setUp(self): self.skipTest("pypowsybl is required") self.net = pn.create_ieee14() self.gid = self.net.get_generators().index[0] - # reproduces the real-world curve found on CSTTT.HG1/CSTTT.HG2: the - # p=0.99 point has min_q (0.053) > max_q (0.04), a data-entry error. + # reproduces the real-world curve found in the wild: the p=0.99 point + # has min_q (0.053) > max_q (0.04), a data-entry error. self.net.create_curve_reactive_limits( id=[self.gid, self.gid], p=[-0.15, 0.99], min_q=[0.0, 0.053], max_q=[0.0, 0.04]) diff --git a/lightsim2grid/tests/test_remote_control_disconnected_target.py b/lightsim2grid/tests/test_remote_control_disconnected_target.py index 8959142e..20e922e6 100644 --- a/lightsim2grid/tests/test_remote_control_disconnected_target.py +++ b/lightsim2grid/tests/test_remote_control_disconnected_target.py @@ -7,13 +7,13 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """A *connected* generator (or SVC) can remotely regulate the voltage of a -terminal that is itself disconnected (found on a real RTE grid, -PtFige-20251102-0905: generators B.SSBEA1/B.SSBEA2 remotely regulate busbar -sections B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id -for a disconnected element is `''`, not NaN, which used to crash -`from_pypowsybl.init()` with `KeyError: "[''] not in index"`. OpenLoadFlow -converges fine on such grids, so `init()` now falls back to local voltage -control for the affected controller instead of crashing. +terminal that is itself disconnected (found on a real RTE grid snapshot: two +generators remotely regulate busbar sections in a de-energized voltage +level). pypowsybl's bus-view id for a disconnected element is `''`, not NaN, +which used to crash `from_pypowsybl.init()` with +`KeyError: "[''] not in index"`. OpenLoadFlow converges fine on such grids, +so `init()` now falls back to local voltage control for the affected +controller instead of crashing. This is a different case from the one covered by `test_disconnected_remote_voltage_control` (a *disconnected* generator From 74e70c00127b0358911d7bd282061437957e13de Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 10:04:46 +0200 Subject: [PATCH 044/166] fix some docstring Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 8 ++++---- .../network/from_pypowsybl/_from_pypowsybl.py | 18 +++++++++--------- .../tests/test_gen_reactive_curve_swap.py | 15 +++++++-------- .../test_remote_control_disconnected_target.py | 14 +++++++------- 4 files changed, 27 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a5195448..60a98b05 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -403,15 +403,15 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. point (`min_q > max_q` at that active power, a data-entry error) that, after the curve is interpolated at the generator's `target_p`, yields an inverted interval. OpenLoadFlow tolerates this silently; lightsim2grid now sorts the pair instead of - hard-rejecting it (found on real RTE grids PtFige-20240531-2225 / PtFige-20240601-0030, - generators CSTTT.HG1/CSTTT.HG2). Tested in `test_gen_reactive_curve_swap.py`. + hard-rejecting it (found on a real RTE grid snapshot). Tested in + `test_gen_reactive_curve_swap.py`. - [FIXED] `init_from_pypowsybl` no longer crashes (`KeyError: "[''] not in index"`) when a *connected* generator or SVC remotely regulates the voltage of a terminal that is itself disconnected (e.g. a de-energized voltage level). PowSyBl's bus-view id for a disconnected element is `''`, not NaN, and could not be resolved to any bus; the controller now falls back to local voltage control instead of crashing, matching - OpenLoadFlow's behaviour (found on real RTE grid PtFige-20251102-0905, generators - B.SSBEA1/B.SSBEA2). Tested in `test_remote_control_disconnected_target.py`. + OpenLoadFlow's behaviour (found on a real RTE grid snapshot). Tested in + `test_remote_control_disconnected_target.py`. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 6b6834cb..12678ad9 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -929,10 +929,10 @@ def _uf_union(a, b): # malformed source curve data (eg a reactive capability curve point entered with # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init - # hard-rejects it (real case found on PtFige-20240531-2225 / PtFige-20240601-0030, - # generators CSTTT.HG1/CSTTT.HG2). Restore a valid interval by sorting the pair - # instead of crashing -- this only ever affects the (already tiny) width of the - # interval, never which generators get a reactive constraint at all. + # hard-rejects it (real case found on a real RTE grid snapshot). Restore a valid + # interval by sorting the pair instead of crashing -- this only ever affects the + # (already tiny) width of the interval, never which generators get a reactive + # constraint at all. swapped = min_q_aux > max_q_aux if swapped.any(): min_q_aux[swapped], max_q_aux[swapped] = max_q_aux[swapped], min_q_aux[swapped].copy() @@ -967,11 +967,11 @@ def _uf_union(a, b): remote_idx = np.nonzero(mask_remote_gen)[0] gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) # a *connected* generator can still remotely regulate a busbar section that is - # itself disconnected (found on PtFige-20251102-0905: B.SSBEA1/B.SSBEA2 -> - # B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id for a - # disconnected element is '' , not NaN, and can't be resolved to any bus_df - # row. OLF converges fine on this grid, so it must fall back to local voltage - # control in this situation; mirror that instead of crashing. + # itself disconnected (found on a real RTE grid snapshot: a de-energized + # voltage level). pypowsybl's bus-view id for a disconnected element is '', + # not NaN, and can't be resolved to any bus_df row. OLF converges fine on such + # grids, so it must fall back to local voltage control in this situation; + # mirror that instead of crashing. unresolved = gen_reg_bus_view == "" if unresolved.any(): mask_remote_gen[remote_idx[unresolved]] = False diff --git a/lightsim2grid/tests/test_gen_reactive_curve_swap.py b/lightsim2grid/tests/test_gen_reactive_curve_swap.py index c32898f3..52ea3bd6 100644 --- a/lightsim2grid/tests/test_gen_reactive_curve_swap.py +++ b/lightsim2grid/tests/test_gen_reactive_curve_swap.py @@ -7,12 +7,11 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """Malformed reactive capability curve data (min_q/max_q effectively "swapped" at -one of the curve points) was found on two real RTE grids (PtFige-20240531-2225 and -PtFige-20240601-0030, generators CSTTT.HG1/CSTTT.HG2): pypowsybl's "at target p" -interpolation then yields min_q > max_q, which OpenLoadFlow tolerates silently but -which used to make lightsim2grid's ``GeneratorContainer::init`` hard-crash with -``RuntimeError: ... min_q being above max_q``. `from_pypowsybl.init()` now sorts -the pair instead of crashing. +one of the curve points) was found on a real RTE grid snapshot: pypowsybl's +"at target p" interpolation then yields min_q > max_q, which OpenLoadFlow +tolerates silently but which used to make lightsim2grid's +``GeneratorContainer::init`` hard-crash with ``RuntimeError: ... min_q being +above max_q``. `from_pypowsybl.init()` now sorts the pair instead of crashing. """ import unittest @@ -32,8 +31,8 @@ def setUp(self): self.skipTest("pypowsybl is required") self.net = pn.create_ieee14() self.gid = self.net.get_generators().index[0] - # reproduces the real-world curve found on CSTTT.HG1/CSTTT.HG2: the - # p=0.99 point has min_q (0.053) > max_q (0.04), a data-entry error. + # reproduces the real-world curve found in the wild: the p=0.99 point + # has min_q (0.053) > max_q (0.04), a data-entry error. self.net.create_curve_reactive_limits( id=[self.gid, self.gid], p=[-0.15, 0.99], min_q=[0.0, 0.053], max_q=[0.0, 0.04]) diff --git a/lightsim2grid/tests/test_remote_control_disconnected_target.py b/lightsim2grid/tests/test_remote_control_disconnected_target.py index 8959142e..20e922e6 100644 --- a/lightsim2grid/tests/test_remote_control_disconnected_target.py +++ b/lightsim2grid/tests/test_remote_control_disconnected_target.py @@ -7,13 +7,13 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """A *connected* generator (or SVC) can remotely regulate the voltage of a -terminal that is itself disconnected (found on a real RTE grid, -PtFige-20251102-0905: generators B.SSBEA1/B.SSBEA2 remotely regulate busbar -sections B.SSBP6_1A/1B, a de-energized voltage level). pypowsybl's bus-view id -for a disconnected element is `''`, not NaN, which used to crash -`from_pypowsybl.init()` with `KeyError: "[''] not in index"`. OpenLoadFlow -converges fine on such grids, so `init()` now falls back to local voltage -control for the affected controller instead of crashing. +terminal that is itself disconnected (found on a real RTE grid snapshot: two +generators remotely regulate busbar sections in a de-energized voltage +level). pypowsybl's bus-view id for a disconnected element is `''`, not NaN, +which used to crash `from_pypowsybl.init()` with +`KeyError: "[''] not in index"`. OpenLoadFlow converges fine on such grids, +so `init()` now falls back to local voltage control for the affected +controller instead of crashing. This is a different case from the one covered by `test_disconnected_remote_voltage_control` (a *disconnected* generator From 29e1621ea07388006586bf59a920eb70b9df7bdf Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 15:53:45 +0200 Subject: [PATCH 045/166] add some convenient accessors for internal NRLedger powerflow variables Signed-off-by: DONNOT Benjamin --- src/bindings/python/binding_lsgrid.cpp | 44 +++++++++++++++++++ src/core/AlgorithmSelector.hpp | 36 +++++++++++++++ src/core/HvdcDroopData.hpp | 15 +++++++ src/core/LSGrid.cpp | 10 +++++ src/core/LSGrid.hpp | 42 ++++++++++++++++++ .../element_container/TwoSidesContainer.hpp | 11 +++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 35 +++++++++++++++ src/core/powerflow_algorithm/NRAlgo.hpp | 13 ++++++ src/core/powerflow_algorithm/NRSystem.hpp | 29 ++++++++++++ 9 files changed, 235 insertions(+) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 0eb0db25..60127691 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -310,6 +310,50 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("get_slack_ids_solver", &LSGrid::get_slack_ids_solver_numpy, DocLSGrid::get_slack_ids_solver.c_str(), py::return_value_policy::reference) .def("get_slack_ids_dc_solver", &LSGrid::get_slack_ids_dc_solver_numpy, DocLSGrid::get_slack_ids_dc_solver.c_str(), py::return_value_policy::reference) .def("get_slack_weights_solver", &LSGrid::get_slack_weights_solver, DocLSGrid::get_slack_weights_solver.c_str(), py::return_value_policy::reference) + .def("get_slack_col_solver", &LSGrid::get_slack_col_solver, + "J column of the MultiSlack slack_absorbed unknown (-1 when " + "distributed slack is inactive).") + .def("get_slack_absorbed_solver", &LSGrid::get_slack_absorbed_solver, + "Converged value (pu) of the MultiSlack slack_absorbed unknown " + "(0 when distributed slack is inactive). This is the GROUND " + "TRUTH after convergence -- not the 0 initial guess an external " + "solver's own linearized derivation starts from.") + .def("get_controller_q_solver", &LSGrid::get_controller_q_solver, py::return_value_policy::reference, + "Converged reactive injection (pu) per VoltageControl controller " + "(remote-regulating generator or voltage-mode SVC), in controller " + "registration order. Empty when the extension is inactive.") + .def("get_controller_kind_solver", &LSGrid::get_controller_kind_solver, py::return_value_policy::reference, + "Kind of each VoltageControl controller (0=GEN, 1=SVC), same " + "order as get_controller_q_solver().") + .def("get_controller_elem_id_solver", &LSGrid::get_controller_elem_id_solver, py::return_value_policy::reference, + "Element id (generator id if GEN, svc id if SVC) of each " + "VoltageControl controller, same order as get_controller_q_solver().") + + .def("get_p_buses_solver", &LSGrid::get_p_buses_solver, py::return_value_policy::reference, + "Compact (bus, row) pair list for P equations -- the row/col " + "counterpart of get_p_to_J_row_solver(), preserving EVERY " + "registration (a bus may appear more than once; see NRLedger's " + "'Multiplicity rules'). Same length as get_p_rows_solver().") + .def("get_p_rows_solver", &LSGrid::get_p_rows_solver, py::return_value_policy::reference, + "J row of each entry in get_p_buses_solver(), same order.") + .def("get_q_buses_solver", &LSGrid::get_q_buses_solver, py::return_value_policy::reference, + "Compact (bus, row) pair list for Q equations, see get_p_buses_solver().") + .def("get_q_rows_solver", &LSGrid::get_q_rows_solver, py::return_value_policy::reference, + "J row of each entry in get_q_buses_solver(), same order.") + .def("get_theta_buses_solver", &LSGrid::get_theta_buses_solver, py::return_value_policy::reference, + "Compact (bus, col) pair list for theta unknowns, see get_p_buses_solver().") + .def("get_theta_cols_solver", &LSGrid::get_theta_cols_solver, py::return_value_policy::reference, + "J col of each entry in get_theta_buses_solver(), same order.") + .def("get_vm_buses_solver", &LSGrid::get_vm_buses_solver, py::return_value_policy::reference, + "Compact (bus, col) pair list for Vm unknowns, see get_p_buses_solver().") + .def("get_vm_cols_solver", &LSGrid::get_vm_cols_solver, py::return_value_policy::reference, + "J col of each entry in get_vm_buses_solver(), same order.") + .def("get_hvdc_droop_data_solver", &LSGrid::get_hvdc_droop_data_solver, + "(bus1, bus2, status, p0, k, lf1, lf2, r, pmax12, pmax21), one " + "entry per CONNECTED droop-enabled hvdc line (solver bus " + "numbering, pu). Ground truth for external solvers re-deriving " + "the theta-dependent droop flow contribution to F independently. " + "See HvdcDroopSolverData for the flow formula.") .def("get_Ybus", &LSGrid::get_Ybus, DocLSGrid::get_Ybus.c_str()) .def("get_dcYbus", &LSGrid::get_dcYbus, DocLSGrid::get_dcYbus.c_str()) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 68743384..c0a67030 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -248,6 +248,39 @@ class LS2G_API AlgorithmSelector final return get_prt_solver("get_q_to_J_row", false)->get_q_to_J_row_python(); } + IntVect get_p_buses_python() const { + check_right_solver("get_p_buses"); + return get_prt_solver("get_p_buses", false)->get_p_buses_python(); + } + IntVect get_p_rows_python() const { + check_right_solver("get_p_rows"); + return get_prt_solver("get_p_rows", false)->get_p_rows_python(); + } + IntVect get_q_buses_python() const { + check_right_solver("get_q_buses"); + return get_prt_solver("get_q_buses", false)->get_q_buses_python(); + } + IntVect get_q_rows_python() const { + check_right_solver("get_q_rows"); + return get_prt_solver("get_q_rows", false)->get_q_rows_python(); + } + IntVect get_theta_buses_python() const { + check_right_solver("get_theta_buses"); + return get_prt_solver("get_theta_buses", false)->get_theta_buses_python(); + } + IntVect get_theta_cols_python() const { + check_right_solver("get_theta_cols"); + return get_prt_solver("get_theta_cols", false)->get_theta_cols_python(); + } + IntVect get_vm_buses_python() const { + check_right_solver("get_vm_buses"); + return get_prt_solver("get_vm_buses", false)->get_vm_buses_python(); + } + IntVect get_vm_cols_python() const { + check_right_solver("get_vm_cols"); + return get_prt_solver("get_vm_cols", false)->get_vm_cols_python(); + } + // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu) + identity, in controller registration order. Empty // for any active solver without the extension (no right-solver check on @@ -264,6 +297,9 @@ class LS2G_API AlgorithmSelector final int get_slack_col() const { return get_prt_solver("get_slack_col", false)->get_slack_col(); } + real_type get_slack_absorbed() const { + return get_prt_solver("get_slack_absorbed", false)->get_slack_absorbed(); + } double get_computation_time() const { return std::get<3>(get_prt_solver("get_computation_time", true)->get_timers()); diff --git a/src/core/HvdcDroopData.hpp b/src/core/HvdcDroopData.hpp index 0cd6dc70..e071a994 100644 --- a/src/core/HvdcDroopData.hpp +++ b/src/core/HvdcDroopData.hpp @@ -38,6 +38,19 @@ struct LS2G_API HvdcDroopSolverData RealVect r; // dc line resistance, pu (r_ohm * sn_mva / nominal_v^2) RealVect pmax12; // pu RealVect pmax21; // pu + // Per-side connectivity: TRUE for a normal, both-ends-connected droop + // line. A line kept `connected_global` with only ONE converter in the + // main synchronous component (see TwoSidesContainer:: + // disconnect_if_not_in_main_component / HVDC_OLF_FINDINGS.md) has the + // OPEN side's flag FALSE here -- its bus1/bus2 solver id is still valid + // (the AC bus itself is in-service), but the theta-dependent droop flow + // formula (raw = p0 + k*(theta1-theta2)) is NOT meaningful across an + // open converter (the remote angle is unknown / unsolved). Consumers + // MUST skip (or fix at the last known/scheduled value, never + // theta-derive) the flow into an open side; see LSGrid::check_solution's + // analogous `station.connected` guard for Q-limit masking. + std::vector connected1; + std::vector connected2; int size() const {return static_cast(bus1.size());} @@ -52,6 +65,8 @@ struct LS2G_API HvdcDroopSolverData r = RealVect(); pmax12 = RealVect(); pmax21 = RealVect(); + connected1.clear(); + connected2.clear(); } /** diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index cefdd5fd..b90f07b1 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -430,6 +430,8 @@ void LSGrid::fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) co data.r = RealVect(nb_droop); data.pmax12 = RealVect(nb_droop); data.pmax21 = RealVect(nb_droop); + data.connected1.assign(nb_droop, true); + data.connected2.assign(nb_droop, true); for(int pos = 0; pos < nb_droop; ++pos){ const int hvdc_id = indices[pos]; const GlobalBusId bus_1 = hvdc_lines_.get_bus_side_1(hvdc_id); @@ -455,6 +457,14 @@ void LSGrid::fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) co data.r(pos) = (v_nom > 0.) ? hvdc_lines_.get_r_ohm(hvdc_id) * sn_mva_ / (v_nom * v_nom) : 0.; data.pmax12(pos) = hvdc_lines_.get_pmax_1to2_mw(hvdc_id) / sn_mva_; data.pmax21(pos) = hvdc_lines_.get_pmax_2to1_mw(hvdc_id) / sn_mva_; + // status_global[hvdc_id] being true only guarantees at least ONE side + // is in the main synchronous component (see + // disconnect_if_not_in_main_component) -- the OTHER side may be + // individually open. bus1/bus2 solver ids stay valid either way (the + // AC bus itself, not the converter, is what id_me_to_solver maps), + // but the theta-dependent flow into an open side is meaningless. + data.connected1[pos] = hvdc_lines_.get_connected_side_1(hvdc_id); + data.connected2[pos] = hvdc_lines_.get_connected_side_2(hvdc_id); } } diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index c077c620..663b7d95 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include // for int32 #include @@ -980,6 +981,19 @@ class LS2G_API LSGrid final * (ie from within a solver's compute_pf). */ void fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) const; + + // Python-facing wrapper around fill_hvdc_droop_solver_data(ac=true): + // (bus1, bus2, status, p0, k, lf1, lf2, r, pmax12, pmax21), one entry + // per CONNECTED droop-enabled hvdc line, solver bus numbering, pu. + // Ground truth for external solvers re-deriving the theta-dependent + // droop flow contribution to F independently. + std::tuple + get_hvdc_droop_data_solver() const { + HvdcDroopSolverData d; + fill_hvdc_droop_solver_data(d, true); + return {d.bus1, d.bus2, d.status, d.p0, d.k, d.lf1, d.lf2, d.r, d.pmax12, d.pmax21}; + } /** * Per-solve data of the ACTIVE voltage-mode controllers (remote-regulating * generators and, later, voltage-mode SVCs), grouped by regulated solver @@ -1485,8 +1499,36 @@ class LS2G_API LSGrid final IntVect get_q_to_J_col_solver() const { return _algo.get_q_to_J_col_python(); } IntVect get_p_to_J_row_solver() const { return _algo.get_p_to_J_row_python(); } IntVect get_q_to_J_row_solver() const { return _algo.get_q_to_J_row_python(); } + + // Compact (bus, row/col) registration pair lists -- the row/col + // counterpart of the *_to_J_col / *_to_J_row maps above, but preserving + // EVERY registration in order (a bus may appear more than once, or be + // absent from the bus-keyed map's current value if a later + // registration shadowed it there -- see NRLedger's "Multiplicity + // rules"). NRSystem::_residual() itself iterates these, not the + // bus-keyed maps; external batched solvers must do the same to + // reproduce every contribution. + IntVect get_p_buses_solver() const { return _algo.get_p_buses_python(); } + IntVect get_p_rows_solver() const { return _algo.get_p_rows_python(); } + IntVect get_q_buses_solver() const { return _algo.get_q_buses_python(); } + IntVect get_q_rows_solver() const { return _algo.get_q_rows_python(); } + IntVect get_theta_buses_solver() const { return _algo.get_theta_buses_python(); } + IntVect get_theta_cols_solver() const { return _algo.get_theta_cols_python(); } + IntVect get_vm_buses_solver() const { return _algo.get_vm_buses_python(); } + IntVect get_vm_cols_solver() const { return _algo.get_vm_cols_python(); } // MultiSlack slack_absorbed J column (-1 when distributed slack inactive). int get_slack_col_solver() const { return _algo.get_slack_col(); } + // MultiSlack converged slack_absorbed VALUE (pu; 0 when inactive). This + // is the ground-truth state after convergence -- NOT the 0 initial + // guess an external solver's own linearized derivation starts from. + real_type get_slack_absorbed_solver() const { return _algo.get_slack_absorbed(); } + // VoltageControl (remote gen + SVC) converged reactive injection per + // controller (pu), + its (kind: 0=GEN,1=SVC; element id) identity, in + // controller registration order (empty when the extension is inactive). + // Ground truth for external solvers deriving their own controller_q. + RealVect get_controller_q_solver() const { return _algo.get_controller_q(); } + IntVect get_controller_kind_solver() const { return _algo.get_controller_kind(); } + IntVect get_controller_elem_id_solver() const { return _algo.get_controller_elem_id(); } real_type get_computation_time() const{ return _algo.get_computation_time();} real_type get_dc_computation_time() const{ return _dc_algo.get_computation_time();} diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index e0f2c1fa..6fd441a4 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -122,6 +122,17 @@ class TwoSidesContainer : public GenericContainer size_t nb() const { return side_1_.nb(); } GridModelBusId get_bus_side_1(int el_id) const {return side_1_.get_bus(el_id);} GridModelBusId get_bus_side_2(int el_id) const {return side_2_.get_bus(el_id);} + // Per-side connectivity: an element can be `connected_global` (the + // TwoSidesContainer-level status, e.g. an HVDC line kept active + // because at least one converter is in the main synchronous + // component -- see disconnect_if_not_in_main_component) while ONE + // side is individually open (real RTE grids: a half-open HVDC line + // with its remote converter in another synchronous island). Callers + // that pull per-side data (e.g. droop flows, Q-limit masking) MUST + // check these, not just nb()/connected_global, or they will silently + // treat an open side as a normal, both-ends-connected element. + bool get_connected_side_1(int el_id) const {return side_1_.get_status(el_id);} + bool get_connected_side_2(int el_id) const {return side_2_.get_status(el_id);} void init_tsc( const Eigen::VectorXi & els_bus1_id, diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 141b07c4..7a09b988 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -112,6 +112,37 @@ class LS2G_API BaseAlgo : public BaseConstants throw std::runtime_error("get_q_to_J_row: only available for Newton-Raphson solvers."); } + // Compact (bus, row/col) registration pair lists -- the row/col + // counterpart of the *_to_J_col / *_to_J_row bus-keyed maps, but + // preserving every registration (a bus may appear more than once, or + // be absent from the bus-keyed map's CURRENT value if a later + // registration shadowed it there). Only Newton-Raphson solvers define + // a Jacobian, hence these throw by default. + virtual IntVect get_p_buses_python() const { + throw std::runtime_error("get_p_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_p_rows_python() const { + throw std::runtime_error("get_p_rows: only available for Newton-Raphson solvers."); + } + virtual IntVect get_q_buses_python() const { + throw std::runtime_error("get_q_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_q_rows_python() const { + throw std::runtime_error("get_q_rows: only available for Newton-Raphson solvers."); + } + virtual IntVect get_theta_buses_python() const { + throw std::runtime_error("get_theta_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_theta_cols_python() const { + throw std::runtime_error("get_theta_cols: only available for Newton-Raphson solvers."); + } + virtual IntVect get_vm_buses_python() const { + throw std::runtime_error("get_vm_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_vm_cols_python() const { + throw std::runtime_error("get_vm_cols: only available for Newton-Raphson solvers."); + } + // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu) and its (kind, element id) identity, in controller // registration order. Empty for algorithms without the extension (DC, @@ -123,6 +154,10 @@ class LS2G_API BaseAlgo : public BaseConstants // MultiSlack: J column of the slack_absorbed unknown (-1 when the // distributed-slack-in-Jacobian extension is not active). virtual int get_slack_col() const { return -1; } + // MultiSlack: converged VALUE of the slack_absorbed unknown (pu; 0 + // when the extension is not active). NOT the same as the per-solve + // initial guess of 0 -- this is the ground truth after convergence. + virtual real_type get_slack_absorbed() const { return static_cast(0.); } Eigen::Ref get_Va() const{ return Va_; diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 6491c303..f1495918 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -81,11 +81,24 @@ class NRAlgo : public BaseAlgo virtual IntVect get_p_to_J_row_python() const override { return _to_intvect(_system.p_to_J_row()); } virtual IntVect get_q_to_J_row_python() const override { return _to_intvect(_system.q_to_J_row()); } + // ----- compact (bus, row/col) registration pair lists (NR-only) ------------ + // Row/col counterpart of the *_to_J_col / *_to_J_row bus-keyed maps above, + // but preserving every registration (see NRSystem::p_buses() etc.). + virtual IntVect get_p_buses_python() const override { return _to_intvect(_system.p_buses()); } + virtual IntVect get_p_rows_python() const override { return _to_intvect(_system.p_rows()); } + virtual IntVect get_q_buses_python() const override { return _to_intvect(_system.q_buses()); } + virtual IntVect get_q_rows_python() const override { return _to_intvect(_system.q_rows()); } + virtual IntVect get_theta_buses_python() const override { return _to_intvect(_system.theta_buses()); } + virtual IntVect get_theta_cols_python() const override { return _to_intvect(_system.theta_cols()); } + virtual IntVect get_vm_buses_python() const override { return _to_intvect(_system.vm_buses()); } + virtual IntVect get_vm_cols_python() const override { return _to_intvect(_system.vm_cols()); } + // ----- VoltageControl (remote gen + SVC) converged results ----------------- virtual RealVect get_controller_q() const override { return _system.controller_q(); } virtual IntVect get_controller_kind() const override { return _system.controller_kind(); } virtual IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } virtual int get_slack_col() const override { return _system.slack_col(); } + virtual real_type get_slack_absorbed() const override { return _system.slack_absorbed(); } // ----- timers -------------------------------------------------------------- diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index a0c4a822..5dc48274 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -376,6 +376,12 @@ class LS2G_API MultiSlack // distributed-slack extension // any bus-keyed map). -1 before register_in. Consumed by external batched // solvers that re-stamp the slack feature entries on the GPU. int slack_col() const { return slack_col_; } + // Converged value of the slack_absorbed unknown (pu), i.e. the TRUE + // per-solve state -- NOT 0, which is only the per-solve INITIAL guess + // (see update_state). External batched solvers that re-derive this + // state via a linearized correction from 0 can use this ground truth + // to check their derivation independently. + real_type slack_absorbed() const { return slack_absorbed_; } private: int my_size_; @@ -906,6 +912,24 @@ class NRSystem const std::vector& p_to_J_row() const { return ledger_.p_row_of_bus(); } const std::vector& q_to_J_row() const { return ledger_.q_row_of_bus(); } + // Compact (bus, row/col) registration pair lists -- the row/col counterpart + // of the *_to_J_col / *_to_J_row bus-keyed maps above. Unlike those maps + // (one slot per bus, "last registration wins"), these preserve EVERY + // registration in order: a bus may appear more than once (or not appear + // in the bus-keyed map's current value at all, if a later registration + // shadowed it there -- see NRLedger's "Multiplicity rules"). External + // batched solvers (e.g. gpusim2grid) that rebuild the dS scatter maps and + // the per-row residual assembly MUST iterate these, not the bus-keyed + // maps, to get every contribution NRSystem::_residual() itself sums. + const std::vector& p_buses() const { return ledger_.p_buses(); } + const std::vector& p_rows() const { return ledger_.p_rows(); } + const std::vector& q_buses() const { return ledger_.q_buses(); } + const std::vector& q_rows() const { return ledger_.q_rows(); } + const std::vector& theta_buses() const { return ledger_.theta_buses(); } + const std::vector& theta_cols() const { return ledger_.theta_cols(); } + const std::vector& vm_buses() const { return ledger_.vm_buses(); } + const std::vector& vm_cols() const { return ledger_.vm_cols(); } + size_t total_state_variables() const { return static_cast(ledger_.size()); } // ----- VoltageControl results (empty when the extension is not in the tuple) -- @@ -929,6 +953,11 @@ class NRSystem const MultiSlack* ms = _find_extension(); return ms ? ms->slack_col() : -1; } + // ----- MultiSlack: converged slack_absorbed VALUE (0 when the extension is absent) -- + real_type slack_absorbed() const { + const MultiSlack* ms = _find_extension(); + return ms ? ms->slack_absorbed() : static_cast(0.); + } // ----- Scaling reductions ---------------------------------------------------- // max |angle step| / max |voltage-magnitude step| across all state variables. From a674b2a8842790ecb2bf378c935060951feb86e5 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 15:53:45 +0200 Subject: [PATCH 046/166] add some convenient accessors for internal NRLedger powerflow variables Signed-off-by: DONNOT Benjamin --- src/bindings/python/binding_lsgrid.cpp | 44 +++++++++++++++++++ src/core/AlgorithmSelector.hpp | 36 +++++++++++++++ src/core/HvdcDroopData.hpp | 15 +++++++ src/core/LSGrid.cpp | 10 +++++ src/core/LSGrid.hpp | 42 ++++++++++++++++++ .../element_container/TwoSidesContainer.hpp | 11 +++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 35 +++++++++++++++ src/core/powerflow_algorithm/NRAlgo.hpp | 13 ++++++ src/core/powerflow_algorithm/NRSystem.hpp | 29 ++++++++++++ 9 files changed, 235 insertions(+) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 0eb0db25..60127691 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -310,6 +310,50 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` .def("get_slack_ids_solver", &LSGrid::get_slack_ids_solver_numpy, DocLSGrid::get_slack_ids_solver.c_str(), py::return_value_policy::reference) .def("get_slack_ids_dc_solver", &LSGrid::get_slack_ids_dc_solver_numpy, DocLSGrid::get_slack_ids_dc_solver.c_str(), py::return_value_policy::reference) .def("get_slack_weights_solver", &LSGrid::get_slack_weights_solver, DocLSGrid::get_slack_weights_solver.c_str(), py::return_value_policy::reference) + .def("get_slack_col_solver", &LSGrid::get_slack_col_solver, + "J column of the MultiSlack slack_absorbed unknown (-1 when " + "distributed slack is inactive).") + .def("get_slack_absorbed_solver", &LSGrid::get_slack_absorbed_solver, + "Converged value (pu) of the MultiSlack slack_absorbed unknown " + "(0 when distributed slack is inactive). This is the GROUND " + "TRUTH after convergence -- not the 0 initial guess an external " + "solver's own linearized derivation starts from.") + .def("get_controller_q_solver", &LSGrid::get_controller_q_solver, py::return_value_policy::reference, + "Converged reactive injection (pu) per VoltageControl controller " + "(remote-regulating generator or voltage-mode SVC), in controller " + "registration order. Empty when the extension is inactive.") + .def("get_controller_kind_solver", &LSGrid::get_controller_kind_solver, py::return_value_policy::reference, + "Kind of each VoltageControl controller (0=GEN, 1=SVC), same " + "order as get_controller_q_solver().") + .def("get_controller_elem_id_solver", &LSGrid::get_controller_elem_id_solver, py::return_value_policy::reference, + "Element id (generator id if GEN, svc id if SVC) of each " + "VoltageControl controller, same order as get_controller_q_solver().") + + .def("get_p_buses_solver", &LSGrid::get_p_buses_solver, py::return_value_policy::reference, + "Compact (bus, row) pair list for P equations -- the row/col " + "counterpart of get_p_to_J_row_solver(), preserving EVERY " + "registration (a bus may appear more than once; see NRLedger's " + "'Multiplicity rules'). Same length as get_p_rows_solver().") + .def("get_p_rows_solver", &LSGrid::get_p_rows_solver, py::return_value_policy::reference, + "J row of each entry in get_p_buses_solver(), same order.") + .def("get_q_buses_solver", &LSGrid::get_q_buses_solver, py::return_value_policy::reference, + "Compact (bus, row) pair list for Q equations, see get_p_buses_solver().") + .def("get_q_rows_solver", &LSGrid::get_q_rows_solver, py::return_value_policy::reference, + "J row of each entry in get_q_buses_solver(), same order.") + .def("get_theta_buses_solver", &LSGrid::get_theta_buses_solver, py::return_value_policy::reference, + "Compact (bus, col) pair list for theta unknowns, see get_p_buses_solver().") + .def("get_theta_cols_solver", &LSGrid::get_theta_cols_solver, py::return_value_policy::reference, + "J col of each entry in get_theta_buses_solver(), same order.") + .def("get_vm_buses_solver", &LSGrid::get_vm_buses_solver, py::return_value_policy::reference, + "Compact (bus, col) pair list for Vm unknowns, see get_p_buses_solver().") + .def("get_vm_cols_solver", &LSGrid::get_vm_cols_solver, py::return_value_policy::reference, + "J col of each entry in get_vm_buses_solver(), same order.") + .def("get_hvdc_droop_data_solver", &LSGrid::get_hvdc_droop_data_solver, + "(bus1, bus2, status, p0, k, lf1, lf2, r, pmax12, pmax21), one " + "entry per CONNECTED droop-enabled hvdc line (solver bus " + "numbering, pu). Ground truth for external solvers re-deriving " + "the theta-dependent droop flow contribution to F independently. " + "See HvdcDroopSolverData for the flow formula.") .def("get_Ybus", &LSGrid::get_Ybus, DocLSGrid::get_Ybus.c_str()) .def("get_dcYbus", &LSGrid::get_dcYbus, DocLSGrid::get_dcYbus.c_str()) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 68743384..c0a67030 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -248,6 +248,39 @@ class LS2G_API AlgorithmSelector final return get_prt_solver("get_q_to_J_row", false)->get_q_to_J_row_python(); } + IntVect get_p_buses_python() const { + check_right_solver("get_p_buses"); + return get_prt_solver("get_p_buses", false)->get_p_buses_python(); + } + IntVect get_p_rows_python() const { + check_right_solver("get_p_rows"); + return get_prt_solver("get_p_rows", false)->get_p_rows_python(); + } + IntVect get_q_buses_python() const { + check_right_solver("get_q_buses"); + return get_prt_solver("get_q_buses", false)->get_q_buses_python(); + } + IntVect get_q_rows_python() const { + check_right_solver("get_q_rows"); + return get_prt_solver("get_q_rows", false)->get_q_rows_python(); + } + IntVect get_theta_buses_python() const { + check_right_solver("get_theta_buses"); + return get_prt_solver("get_theta_buses", false)->get_theta_buses_python(); + } + IntVect get_theta_cols_python() const { + check_right_solver("get_theta_cols"); + return get_prt_solver("get_theta_cols", false)->get_theta_cols_python(); + } + IntVect get_vm_buses_python() const { + check_right_solver("get_vm_buses"); + return get_prt_solver("get_vm_buses", false)->get_vm_buses_python(); + } + IntVect get_vm_cols_python() const { + check_right_solver("get_vm_cols"); + return get_prt_solver("get_vm_cols", false)->get_vm_cols_python(); + } + // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu) + identity, in controller registration order. Empty // for any active solver without the extension (no right-solver check on @@ -264,6 +297,9 @@ class LS2G_API AlgorithmSelector final int get_slack_col() const { return get_prt_solver("get_slack_col", false)->get_slack_col(); } + real_type get_slack_absorbed() const { + return get_prt_solver("get_slack_absorbed", false)->get_slack_absorbed(); + } double get_computation_time() const { return std::get<3>(get_prt_solver("get_computation_time", true)->get_timers()); diff --git a/src/core/HvdcDroopData.hpp b/src/core/HvdcDroopData.hpp index 0cd6dc70..e071a994 100644 --- a/src/core/HvdcDroopData.hpp +++ b/src/core/HvdcDroopData.hpp @@ -38,6 +38,19 @@ struct LS2G_API HvdcDroopSolverData RealVect r; // dc line resistance, pu (r_ohm * sn_mva / nominal_v^2) RealVect pmax12; // pu RealVect pmax21; // pu + // Per-side connectivity: TRUE for a normal, both-ends-connected droop + // line. A line kept `connected_global` with only ONE converter in the + // main synchronous component (see TwoSidesContainer:: + // disconnect_if_not_in_main_component / HVDC_OLF_FINDINGS.md) has the + // OPEN side's flag FALSE here -- its bus1/bus2 solver id is still valid + // (the AC bus itself is in-service), but the theta-dependent droop flow + // formula (raw = p0 + k*(theta1-theta2)) is NOT meaningful across an + // open converter (the remote angle is unknown / unsolved). Consumers + // MUST skip (or fix at the last known/scheduled value, never + // theta-derive) the flow into an open side; see LSGrid::check_solution's + // analogous `station.connected` guard for Q-limit masking. + std::vector connected1; + std::vector connected2; int size() const {return static_cast(bus1.size());} @@ -52,6 +65,8 @@ struct LS2G_API HvdcDroopSolverData r = RealVect(); pmax12 = RealVect(); pmax21 = RealVect(); + connected1.clear(); + connected2.clear(); } /** diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index cefdd5fd..b90f07b1 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -430,6 +430,8 @@ void LSGrid::fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) co data.r = RealVect(nb_droop); data.pmax12 = RealVect(nb_droop); data.pmax21 = RealVect(nb_droop); + data.connected1.assign(nb_droop, true); + data.connected2.assign(nb_droop, true); for(int pos = 0; pos < nb_droop; ++pos){ const int hvdc_id = indices[pos]; const GlobalBusId bus_1 = hvdc_lines_.get_bus_side_1(hvdc_id); @@ -455,6 +457,14 @@ void LSGrid::fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) co data.r(pos) = (v_nom > 0.) ? hvdc_lines_.get_r_ohm(hvdc_id) * sn_mva_ / (v_nom * v_nom) : 0.; data.pmax12(pos) = hvdc_lines_.get_pmax_1to2_mw(hvdc_id) / sn_mva_; data.pmax21(pos) = hvdc_lines_.get_pmax_2to1_mw(hvdc_id) / sn_mva_; + // status_global[hvdc_id] being true only guarantees at least ONE side + // is in the main synchronous component (see + // disconnect_if_not_in_main_component) -- the OTHER side may be + // individually open. bus1/bus2 solver ids stay valid either way (the + // AC bus itself, not the converter, is what id_me_to_solver maps), + // but the theta-dependent flow into an open side is meaningless. + data.connected1[pos] = hvdc_lines_.get_connected_side_1(hvdc_id); + data.connected2[pos] = hvdc_lines_.get_connected_side_2(hvdc_id); } } diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index c077c620..663b7d95 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include // for int32 #include @@ -980,6 +981,19 @@ class LS2G_API LSGrid final * (ie from within a solver's compute_pf). */ void fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) const; + + // Python-facing wrapper around fill_hvdc_droop_solver_data(ac=true): + // (bus1, bus2, status, p0, k, lf1, lf2, r, pmax12, pmax21), one entry + // per CONNECTED droop-enabled hvdc line, solver bus numbering, pu. + // Ground truth for external solvers re-deriving the theta-dependent + // droop flow contribution to F independently. + std::tuple + get_hvdc_droop_data_solver() const { + HvdcDroopSolverData d; + fill_hvdc_droop_solver_data(d, true); + return {d.bus1, d.bus2, d.status, d.p0, d.k, d.lf1, d.lf2, d.r, d.pmax12, d.pmax21}; + } /** * Per-solve data of the ACTIVE voltage-mode controllers (remote-regulating * generators and, later, voltage-mode SVCs), grouped by regulated solver @@ -1485,8 +1499,36 @@ class LS2G_API LSGrid final IntVect get_q_to_J_col_solver() const { return _algo.get_q_to_J_col_python(); } IntVect get_p_to_J_row_solver() const { return _algo.get_p_to_J_row_python(); } IntVect get_q_to_J_row_solver() const { return _algo.get_q_to_J_row_python(); } + + // Compact (bus, row/col) registration pair lists -- the row/col + // counterpart of the *_to_J_col / *_to_J_row maps above, but preserving + // EVERY registration in order (a bus may appear more than once, or be + // absent from the bus-keyed map's current value if a later + // registration shadowed it there -- see NRLedger's "Multiplicity + // rules"). NRSystem::_residual() itself iterates these, not the + // bus-keyed maps; external batched solvers must do the same to + // reproduce every contribution. + IntVect get_p_buses_solver() const { return _algo.get_p_buses_python(); } + IntVect get_p_rows_solver() const { return _algo.get_p_rows_python(); } + IntVect get_q_buses_solver() const { return _algo.get_q_buses_python(); } + IntVect get_q_rows_solver() const { return _algo.get_q_rows_python(); } + IntVect get_theta_buses_solver() const { return _algo.get_theta_buses_python(); } + IntVect get_theta_cols_solver() const { return _algo.get_theta_cols_python(); } + IntVect get_vm_buses_solver() const { return _algo.get_vm_buses_python(); } + IntVect get_vm_cols_solver() const { return _algo.get_vm_cols_python(); } // MultiSlack slack_absorbed J column (-1 when distributed slack inactive). int get_slack_col_solver() const { return _algo.get_slack_col(); } + // MultiSlack converged slack_absorbed VALUE (pu; 0 when inactive). This + // is the ground-truth state after convergence -- NOT the 0 initial + // guess an external solver's own linearized derivation starts from. + real_type get_slack_absorbed_solver() const { return _algo.get_slack_absorbed(); } + // VoltageControl (remote gen + SVC) converged reactive injection per + // controller (pu), + its (kind: 0=GEN,1=SVC; element id) identity, in + // controller registration order (empty when the extension is inactive). + // Ground truth for external solvers deriving their own controller_q. + RealVect get_controller_q_solver() const { return _algo.get_controller_q(); } + IntVect get_controller_kind_solver() const { return _algo.get_controller_kind(); } + IntVect get_controller_elem_id_solver() const { return _algo.get_controller_elem_id(); } real_type get_computation_time() const{ return _algo.get_computation_time();} real_type get_dc_computation_time() const{ return _dc_algo.get_computation_time();} diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index e0f2c1fa..6fd441a4 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -122,6 +122,17 @@ class TwoSidesContainer : public GenericContainer size_t nb() const { return side_1_.nb(); } GridModelBusId get_bus_side_1(int el_id) const {return side_1_.get_bus(el_id);} GridModelBusId get_bus_side_2(int el_id) const {return side_2_.get_bus(el_id);} + // Per-side connectivity: an element can be `connected_global` (the + // TwoSidesContainer-level status, e.g. an HVDC line kept active + // because at least one converter is in the main synchronous + // component -- see disconnect_if_not_in_main_component) while ONE + // side is individually open (real RTE grids: a half-open HVDC line + // with its remote converter in another synchronous island). Callers + // that pull per-side data (e.g. droop flows, Q-limit masking) MUST + // check these, not just nb()/connected_global, or they will silently + // treat an open side as a normal, both-ends-connected element. + bool get_connected_side_1(int el_id) const {return side_1_.get_status(el_id);} + bool get_connected_side_2(int el_id) const {return side_2_.get_status(el_id);} void init_tsc( const Eigen::VectorXi & els_bus1_id, diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 141b07c4..7a09b988 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -112,6 +112,37 @@ class LS2G_API BaseAlgo : public BaseConstants throw std::runtime_error("get_q_to_J_row: only available for Newton-Raphson solvers."); } + // Compact (bus, row/col) registration pair lists -- the row/col + // counterpart of the *_to_J_col / *_to_J_row bus-keyed maps, but + // preserving every registration (a bus may appear more than once, or + // be absent from the bus-keyed map's CURRENT value if a later + // registration shadowed it there). Only Newton-Raphson solvers define + // a Jacobian, hence these throw by default. + virtual IntVect get_p_buses_python() const { + throw std::runtime_error("get_p_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_p_rows_python() const { + throw std::runtime_error("get_p_rows: only available for Newton-Raphson solvers."); + } + virtual IntVect get_q_buses_python() const { + throw std::runtime_error("get_q_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_q_rows_python() const { + throw std::runtime_error("get_q_rows: only available for Newton-Raphson solvers."); + } + virtual IntVect get_theta_buses_python() const { + throw std::runtime_error("get_theta_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_theta_cols_python() const { + throw std::runtime_error("get_theta_cols: only available for Newton-Raphson solvers."); + } + virtual IntVect get_vm_buses_python() const { + throw std::runtime_error("get_vm_buses: only available for Newton-Raphson solvers."); + } + virtual IntVect get_vm_cols_python() const { + throw std::runtime_error("get_vm_cols: only available for Newton-Raphson solvers."); + } + // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu) and its (kind, element id) identity, in controller // registration order. Empty for algorithms without the extension (DC, @@ -123,6 +154,10 @@ class LS2G_API BaseAlgo : public BaseConstants // MultiSlack: J column of the slack_absorbed unknown (-1 when the // distributed-slack-in-Jacobian extension is not active). virtual int get_slack_col() const { return -1; } + // MultiSlack: converged VALUE of the slack_absorbed unknown (pu; 0 + // when the extension is not active). NOT the same as the per-solve + // initial guess of 0 -- this is the ground truth after convergence. + virtual real_type get_slack_absorbed() const { return static_cast(0.); } Eigen::Ref get_Va() const{ return Va_; diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 6491c303..f1495918 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -81,11 +81,24 @@ class NRAlgo : public BaseAlgo virtual IntVect get_p_to_J_row_python() const override { return _to_intvect(_system.p_to_J_row()); } virtual IntVect get_q_to_J_row_python() const override { return _to_intvect(_system.q_to_J_row()); } + // ----- compact (bus, row/col) registration pair lists (NR-only) ------------ + // Row/col counterpart of the *_to_J_col / *_to_J_row bus-keyed maps above, + // but preserving every registration (see NRSystem::p_buses() etc.). + virtual IntVect get_p_buses_python() const override { return _to_intvect(_system.p_buses()); } + virtual IntVect get_p_rows_python() const override { return _to_intvect(_system.p_rows()); } + virtual IntVect get_q_buses_python() const override { return _to_intvect(_system.q_buses()); } + virtual IntVect get_q_rows_python() const override { return _to_intvect(_system.q_rows()); } + virtual IntVect get_theta_buses_python() const override { return _to_intvect(_system.theta_buses()); } + virtual IntVect get_theta_cols_python() const override { return _to_intvect(_system.theta_cols()); } + virtual IntVect get_vm_buses_python() const override { return _to_intvect(_system.vm_buses()); } + virtual IntVect get_vm_cols_python() const override { return _to_intvect(_system.vm_cols()); } + // ----- VoltageControl (remote gen + SVC) converged results ----------------- virtual RealVect get_controller_q() const override { return _system.controller_q(); } virtual IntVect get_controller_kind() const override { return _system.controller_kind(); } virtual IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } virtual int get_slack_col() const override { return _system.slack_col(); } + virtual real_type get_slack_absorbed() const override { return _system.slack_absorbed(); } // ----- timers -------------------------------------------------------------- diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index a0c4a822..5dc48274 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -376,6 +376,12 @@ class LS2G_API MultiSlack // distributed-slack extension // any bus-keyed map). -1 before register_in. Consumed by external batched // solvers that re-stamp the slack feature entries on the GPU. int slack_col() const { return slack_col_; } + // Converged value of the slack_absorbed unknown (pu), i.e. the TRUE + // per-solve state -- NOT 0, which is only the per-solve INITIAL guess + // (see update_state). External batched solvers that re-derive this + // state via a linearized correction from 0 can use this ground truth + // to check their derivation independently. + real_type slack_absorbed() const { return slack_absorbed_; } private: int my_size_; @@ -906,6 +912,24 @@ class NRSystem const std::vector& p_to_J_row() const { return ledger_.p_row_of_bus(); } const std::vector& q_to_J_row() const { return ledger_.q_row_of_bus(); } + // Compact (bus, row/col) registration pair lists -- the row/col counterpart + // of the *_to_J_col / *_to_J_row bus-keyed maps above. Unlike those maps + // (one slot per bus, "last registration wins"), these preserve EVERY + // registration in order: a bus may appear more than once (or not appear + // in the bus-keyed map's current value at all, if a later registration + // shadowed it there -- see NRLedger's "Multiplicity rules"). External + // batched solvers (e.g. gpusim2grid) that rebuild the dS scatter maps and + // the per-row residual assembly MUST iterate these, not the bus-keyed + // maps, to get every contribution NRSystem::_residual() itself sums. + const std::vector& p_buses() const { return ledger_.p_buses(); } + const std::vector& p_rows() const { return ledger_.p_rows(); } + const std::vector& q_buses() const { return ledger_.q_buses(); } + const std::vector& q_rows() const { return ledger_.q_rows(); } + const std::vector& theta_buses() const { return ledger_.theta_buses(); } + const std::vector& theta_cols() const { return ledger_.theta_cols(); } + const std::vector& vm_buses() const { return ledger_.vm_buses(); } + const std::vector& vm_cols() const { return ledger_.vm_cols(); } + size_t total_state_variables() const { return static_cast(ledger_.size()); } // ----- VoltageControl results (empty when the extension is not in the tuple) -- @@ -929,6 +953,11 @@ class NRSystem const MultiSlack* ms = _find_extension(); return ms ? ms->slack_col() : -1; } + // ----- MultiSlack: converged slack_absorbed VALUE (0 when the extension is absent) -- + real_type slack_absorbed() const { + const MultiSlack* ms = _find_extension(); + return ms ? ms->slack_absorbed() : static_cast(0.); + } // ----- Scaling reductions ---------------------------------------------------- // max |angle step| / max |voltage-magnitude step| across all state variables. From 0448c9e128b59b18facd4b770f484fca2d524058 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 17:09:20 +0200 Subject: [PATCH 047/166] fix voltage control for NR single slack Signed-off-by: DONNOT Benjamin --- .../tests/test_voltage_control_pypowsybl.py | 55 +++++++++++++ src/core/powerflow_algorithm/CMakeLists.txt | 1 + src/core/powerflow_algorithm/NRSystem.hpp | 82 ++++++++++--------- src/core/powerflow_algorithm/NRSystemBase.cpp | 39 +++++++++ .../NRSystemMultiSlack.cpp | 13 +-- 5 files changed, 141 insertions(+), 49 deletions(-) create mode 100644 src/core/powerflow_algorithm/NRSystemBase.cpp diff --git a/lightsim2grid/tests/test_voltage_control_pypowsybl.py b/lightsim2grid/tests/test_voltage_control_pypowsybl.py index 9252a4c8..adc2369c 100644 --- a/lightsim2grid/tests/test_voltage_control_pypowsybl.py +++ b/lightsim2grid/tests/test_voltage_control_pypowsybl.py @@ -19,6 +19,8 @@ import unittest import numpy as np +from lightsim2grid.algorithm import AlgorithmType + try: import pypowsybl as pp from lightsim2grid.network import init_from_pypowsybl @@ -126,6 +128,19 @@ def _dist_slack_pq_net(): return n +def _single_slack_algos(model): + """(name, AlgorithmType) pairs to exercise every NRSing_* build actually + available, mirroring the KLU-preferred / SparseLU-fallback guard used in + LightSimBackend._assign_right_solver. NRSing_SparseLU has no external + dependency, so it is always exercised; NRSing_KLU is added only when + available, so its absence never fails the test.""" + algos = [("NRSing_SparseLU", AlgorithmType.NRSing_SparseLU)] + avail = model.available_default_algorithms() + if AlgorithmType.NRSing_KLU in avail: + algos.append(("NRSing_KLU", AlgorithmType.NRSing_KLU)) + return algos + + @unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") class TestVoltageControlPypowsybl(unittest.TestCase): def setUp(self): @@ -242,6 +257,46 @@ def test_dist_slack_pq_participant(self): q = {g.name: g.res_q_mvar for g in model.get_generators()} self.assertAlmostEqual(q["G1"], 10.0, places=4) + def test_dist_slack_pq_participant_single_slack_solver(self): + # Regression for the SingleSlackNRSystem bug: with gen_slack_id="G1" + # (a single string, not a dict) G1 is the SOLE slack bus, still not + # pinned by a local PV gen (voltage_regulator_on=False). Switching to + # NRSing_* must NOT freeze its Vm at the init value the way the + # unpatched SingleSlackNRSystem (no MultiSlack extension at all) did. + n = _dist_slack_pq_net() + n.per_unit = False + model = init_from_pypowsybl(n, gen_slack_id="G1", sort_index=True) + nb = len(model.get_bus_status()) + vl1_bus = list(n.get_buses().index).index( + n.get_buses().index[n.get_buses()["voltage_level_id"] == "VL1"][0]) + for name, algo in _single_slack_algos(model): + model.change_algorithm(algo) + V0 = np.ones(nb, dtype=np.complex128) + V = model.ac_pf(V0, 30, 1e-11) + self.assertGreater(V.shape[0], 0, f"{name}: diverged") + self.assertGreater(abs(abs(V[vl1_bus]) - 1.0), 1e-3, + f"{name}: slack bus (VL1) magnitude was frozen at init") + q = {g.name: g.res_q_mvar for g in model.get_generators()} + self.assertAlmostEqual(q["G1"], 10.0, places=4, msg=f"{name}: wrong Q on G1") + + def test_remote_gen_on_slack_single_slack_solver(self): + # test_remote_gen_on_slack's fixture: G1's own bus IS the (sole) slack, + # regulated only remotely (target on "LD"). Same regression, on the + # "remote-voltage-controller-on-slack" variant of the bug. + n = _star(g2_reg=None, g1_reg="LD", g1_tv=405.0) + n.per_unit = False + model = init_from_pypowsybl(n, gen_slack_id="G1", sort_index=True) + nb = len(model.get_bus_status()) + vl1_bus = list(n.get_buses().index).index( + n.get_buses().index[n.get_buses()["voltage_level_id"] == "VL1"][0]) + for name, algo in _single_slack_algos(model): + model.change_algorithm(algo) + V0 = np.ones(nb, dtype=np.complex128) + V = model.ac_pf(V0, 30, 1e-11) + self.assertGreater(V.shape[0], 0, f"{name}: diverged") + self.assertGreater(abs(abs(V[vl1_bus]) - 1.0), 1e-3, + f"{name}: slack bus (VL1) magnitude was frozen at init") + # ----- SVC -------------------------------------------------------------- def test_svc_local_voltage(self): self._compare(_svc_net(mode=VOLTAGE, target_v=398.0), "VL0", "G", "svc-local-V") diff --git a/src/core/powerflow_algorithm/CMakeLists.txt b/src/core/powerflow_algorithm/CMakeLists.txt index 8fc2d564..a43f7dd5 100644 --- a/src/core/powerflow_algorithm/CMakeLists.txt +++ b/src/core/powerflow_algorithm/CMakeLists.txt @@ -2,6 +2,7 @@ list(APPEND SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/GaussSeidelAlgo.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/GaussSeidelSynchAlgo.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/BaseAlgo.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemBase.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemHvdc.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemVoltageControl.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemMultiSlack.cpp" diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 5dc48274..8143e734 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -137,7 +137,14 @@ class LS2G_API Contrib final * - theta unknowns for sorted(pv ∪ pq), * - vm unknowns for sorted(pq), * - P equations for sorted(pv ∪ pq), - * - Q equations for sorted(pq). + * - Q equations for sorted(pq), + * - vm unknowns + Q equations for slack buses NOT pinned by a LOCAL + * voltage-regulating generator (increasing bus-id order, appended last): + * PQ distributed-slack participants, remote-voltage / SVC-controlled + * slack buses. This is unconditional -- present whether or not the + * NRSystem instantiation has a MultiSlack extension -- because it is a + * property of ANY bus' voltage pinning, not of distributed-slack + * bookkeeping (see update_state, defined out-of-line in NRSystemBase.cpp). */ class LS2G_API Base { @@ -160,13 +167,18 @@ class LS2G_API Base return (int) (it - inner); }; - // call at the beginning of each solve + // call at the beginning of each solve. Defined out-of-line + // (NRSystemBase.cpp) because it needs the full LSGrid type (this + // header only forward-declares it) to query the slack buses that + // need a free Vm unknown + Q equation -- shared by EVERY NRSystem + // instantiation (SingleSlackNRSystem has no MultiSlack extension to + // do this, so Base must own it). void update_state( - const LSGrid * /*lsgrid_ptr*/, - const Eigen::SparseMatrix& /*Ybus*/, - const CplxVect& /*Sbus*/, - const RealVect& /*slack_weights*/ - ){} + const LSGrid * lsgrid_ptr, + const Eigen::SparseMatrix& Ybus, + const CplxVect& Sbus, + const RealVect& slack_weights + ); // call after update_state // at the beginning of each solve @@ -200,6 +212,21 @@ class LS2G_API Base for (int bus : pq_sorted) ledger.add_vm_unknown(bus); for (int bus : pvpq_sorted) ledger.add_p_equation(bus); for (int bus : pq_sorted) ledger.add_q_equation(bus); + + // A slack bus whose magnitude is NOT pinned by a local PV + // generator must keep a free Vm unknown and a Q equation -- + // exactly like an ordinary PQ bus (a PQ distributed-slack + // participant, or a slack bus regulated only remotely / by an + // SVC, to which the VoltageControl extension then attaches via + // vm_col(reg_bus) / q_row(bus)). free_vm_slack_buses_ is a + // std::set, so this iterates in increasing bus-id order. This + // set is empty (loop is a no-op) whenever every slack bus is + // pinned by a local PV generator -- the common case -- leaving + // the J layout bit-identical to before this change. + for (int bus : free_vm_slack_buses_) { + ledger.add_vm_unknown(bus); + ledger.add_q_equation(bus); + } } void declare_feature_entries(FeatureSink& /*sink*/) {} @@ -218,11 +245,16 @@ class LS2G_API Base nb_pv_ = 0; nb_pq_ = 0; nb_pvpq_ = 0; + free_vm_slack_buses_.clear(); } private: IntVect pv_, pq_, pvpq_; int nb_pv_, nb_pq_, nb_pvpq_; + // solver-bus ids of slack buses NOT pinned by a local PV generator; + // set by update_state (needs LSGrid, hence out-of-line), consumed by + // register_in. Empty in the common "slack is locally PV-pinned" case. + std::set free_vm_slack_buses_; public: Eigen::Ref pv() const { return pv_; } @@ -245,6 +277,11 @@ class LS2G_API Base * - a P equation for the ref slack bus (slack_ids[0]); * - one custom column: the slack_absorbed unknown. * + * A slack bus not locally voltage-pinned also gets a free Vm unknown + Q + * equation -- that is now Base's responsibility (Base::register_in), not + * this extension's: it is a property of bus voltage pinning shared by every + * NRSystem instantiation, single- or multi-slack (see Base's doc comment). + * * All the dS-derived Jacobian entries of those rows / columns are generated by * the generic dS pass of NRSystem. The only feature-specific entries are the * slack_absorbed column coefficients: slack_weights(bus) at each slack bus' @@ -260,10 +297,7 @@ class LS2G_API MultiSlack // distributed-slack extension slack_absorbed_(static_cast(0.)) {} // call at the beginning of each NR solve once. It is not called for NR - // iterations. Defined out-of-line (NRSystemMultiSlack.cpp) because it needs - // the full LSGrid type (NRSystem.hpp only forward-declares it): besides - // caching slack_weights / slack_absorbed it pulls the set of slack buses - // that need a free Vm unknown + Q equation, used by register_in. + // iterations. Caches slack_weights / initial slack_absorbed. void update_state( const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, @@ -299,27 +333,6 @@ class LS2G_API MultiSlack // distributed-slack extension slack_p_rows_.push_back(ledger.add_p_equation(slack_buses_[k])); } slack_p_rows_.push_back(ledger.add_p_equation(ref_slack_id_)); - // A slack bus whose magnitude is NOT pinned by a local PV generator must - // keep a free Vm unknown and a Q equation. This is the common case for a - // *distributed* slack: a participant whose generator is PQ - // (voltage_regulator_on == false) carries the active-power slack role on - // its P row, but its magnitude is still an unknown solved through its Q - // equation -- exactly like an ordinary PQ bus. It also covers slack buses - // hosting a remote-voltage controller or an SVC (the VoltageControl - // extension looks up q_row(bus) / vm_col(reg_bus) and attaches here). - // Without this the bus would be Vm-fixed (PV-like) at its init value, - // which is wrong unless a local PV generator actually holds it. - // The reference slack keeps its angle fixed (no theta unknown), so adding - // Vm + Q turns it into a "theta-fixed PQ" bus (fixed angle, free Vm). The - // new rows/cols are filled by the generic dS pass and the ledger-driven - // residual loop of NRSystem; nothing extra is needed here. (+1 Vm col and - // +1 Q row per such bus keep the Jacobian square.) - for (int k = 0; k < my_size_; ++k) { - if (free_vm_slack_buses_.count(slack_buses_[k])) { - ledger.add_vm_unknown(slack_buses_[k]); - ledger.add_q_equation(slack_buses_[k]); - } - } slack_col_ = ledger.add_custom_col(); // slack_absorbed unknown } @@ -368,7 +381,6 @@ class LS2G_API MultiSlack // distributed-slack extension feature_handles_.clear(); slack_weights_ = RealVect(); slack_absorbed_ = static_cast(0.); - free_vm_slack_buses_.clear(); } public: @@ -391,10 +403,6 @@ class LS2G_API MultiSlack // distributed-slack extension std::vector slack_p_rows_; // P row of each slack bus (same order) std::vector feature_handles_; // FeatureSink handles (same order) RealVect slack_weights_; // size: nb_bus - // solver-bus ids of slack buses NOT pinned by a local PV generator (PQ - // distributed-slack participants, remote-voltage / SVC controllers); those - // get an extra Vm unknown + Q equation in register_in (set by update_state) - std::set free_vm_slack_buses_; real_type slack_absorbed_; }; diff --git a/src/core/powerflow_algorithm/NRSystemBase.cpp b/src/core/powerflow_algorithm/NRSystemBase.cpp new file mode 100644 index 00000000..4f9355fb --- /dev/null +++ b/src/core/powerflow_algorithm/NRSystemBase.cpp @@ -0,0 +1,39 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#include "NRSystem.hpp" + +// out-of-line on purpose: NRSystem.hpp only forward-declares LSGrid, the full +// type is needed to query the slack buses that need a free Vm unknown + Q +// equation. Base is the one component present in EVERY NRSystem instantiation +// (single- or multi-slack), so this must live here rather than in MultiSlack: +// a grid with a single slack bus that is not locally voltage-pinned (a PQ +// distributed-slack-style participant, or a remote-voltage / SVC-controlled +// slack) needs the exact same free Vm unknown + Q equation whether solved +// with NR_KLU (MultiSlack present) or NRSing_KLU (no MultiSlack extension). +#include "LSGrid.hpp" + +namespace ls2g { + +void Base::update_state( + const LSGrid * lsgrid_ptr, + const Eigen::SparseMatrix& /*Ybus*/, + const CplxVect& /*Sbus*/, + const RealVect& /*slack_weights*/ +) +{ + // Slack buses not pinned by a LOCAL voltage-regulating generator need a + // free Vm unknown + Q equation (added in register_in), exactly like an + // ordinary PQ bus. See LSGrid::get_free_vm_slack_solver_buses for the + // exact criterion (GeneratorContainer::gen_is_local_voltage_controller). + free_vm_slack_buses_.clear(); + if (lsgrid_ptr != nullptr) + free_vm_slack_buses_ = lsgrid_ptr->get_free_vm_slack_solver_buses(); +} + +} // namespace ls2g diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp index a036a0bc..e5c90707 100644 --- a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -8,16 +8,11 @@ #include "NRSystem.hpp" -// out-of-line on purpose: NRSystem.hpp only forward-declares LSGrid (it is -// included BY LSGrid.hpp), the full type is needed to query the slack buses that -// need a free Vm unknown + Q equation. -#include "LSGrid.hpp" - namespace ls2g { void MultiSlack::update_state( const Base * /*nr_system_base_ptr*/, - const LSGrid * lsgrid_ptr, + const LSGrid * /*lsgrid_ptr*/, const Eigen::SparseMatrix& /*Ybus*/, const CplxVect& Sbus, const RealVect& slack_weights @@ -26,12 +21,6 @@ void MultiSlack::update_state( slack_weights_ = slack_weights; // initial slack absorbed (see MultiSlackPolicy::initial_slack_absorbed) slack_absorbed_ = std::real(Sbus.sum()); - // slack buses not pinned by a local PV generator need a free Vm + Q equation - // (added in register_in): PQ distributed-slack participants, and remote-voltage - // / SVC controllers (to which the VoltageControl extension then attaches). - free_vm_slack_buses_.clear(); - if(lsgrid_ptr != nullptr) - free_vm_slack_buses_ = lsgrid_ptr->get_free_vm_slack_solver_buses(); } } // namespace ls2g From f080fb8fa97c847d552030485eb157b65f8041f7 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jul 2026 17:09:20 +0200 Subject: [PATCH 048/166] fix voltage control for NR single slack Signed-off-by: DONNOT Benjamin --- .../tests/test_voltage_control_pypowsybl.py | 55 +++++++++++++ src/core/powerflow_algorithm/CMakeLists.txt | 1 + src/core/powerflow_algorithm/NRSystem.hpp | 82 ++++++++++--------- src/core/powerflow_algorithm/NRSystemBase.cpp | 39 +++++++++ .../NRSystemMultiSlack.cpp | 13 +-- 5 files changed, 141 insertions(+), 49 deletions(-) create mode 100644 src/core/powerflow_algorithm/NRSystemBase.cpp diff --git a/lightsim2grid/tests/test_voltage_control_pypowsybl.py b/lightsim2grid/tests/test_voltage_control_pypowsybl.py index 9252a4c8..adc2369c 100644 --- a/lightsim2grid/tests/test_voltage_control_pypowsybl.py +++ b/lightsim2grid/tests/test_voltage_control_pypowsybl.py @@ -19,6 +19,8 @@ import unittest import numpy as np +from lightsim2grid.algorithm import AlgorithmType + try: import pypowsybl as pp from lightsim2grid.network import init_from_pypowsybl @@ -126,6 +128,19 @@ def _dist_slack_pq_net(): return n +def _single_slack_algos(model): + """(name, AlgorithmType) pairs to exercise every NRSing_* build actually + available, mirroring the KLU-preferred / SparseLU-fallback guard used in + LightSimBackend._assign_right_solver. NRSing_SparseLU has no external + dependency, so it is always exercised; NRSing_KLU is added only when + available, so its absence never fails the test.""" + algos = [("NRSing_SparseLU", AlgorithmType.NRSing_SparseLU)] + avail = model.available_default_algorithms() + if AlgorithmType.NRSing_KLU in avail: + algos.append(("NRSing_KLU", AlgorithmType.NRSing_KLU)) + return algos + + @unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") class TestVoltageControlPypowsybl(unittest.TestCase): def setUp(self): @@ -242,6 +257,46 @@ def test_dist_slack_pq_participant(self): q = {g.name: g.res_q_mvar for g in model.get_generators()} self.assertAlmostEqual(q["G1"], 10.0, places=4) + def test_dist_slack_pq_participant_single_slack_solver(self): + # Regression for the SingleSlackNRSystem bug: with gen_slack_id="G1" + # (a single string, not a dict) G1 is the SOLE slack bus, still not + # pinned by a local PV gen (voltage_regulator_on=False). Switching to + # NRSing_* must NOT freeze its Vm at the init value the way the + # unpatched SingleSlackNRSystem (no MultiSlack extension at all) did. + n = _dist_slack_pq_net() + n.per_unit = False + model = init_from_pypowsybl(n, gen_slack_id="G1", sort_index=True) + nb = len(model.get_bus_status()) + vl1_bus = list(n.get_buses().index).index( + n.get_buses().index[n.get_buses()["voltage_level_id"] == "VL1"][0]) + for name, algo in _single_slack_algos(model): + model.change_algorithm(algo) + V0 = np.ones(nb, dtype=np.complex128) + V = model.ac_pf(V0, 30, 1e-11) + self.assertGreater(V.shape[0], 0, f"{name}: diverged") + self.assertGreater(abs(abs(V[vl1_bus]) - 1.0), 1e-3, + f"{name}: slack bus (VL1) magnitude was frozen at init") + q = {g.name: g.res_q_mvar for g in model.get_generators()} + self.assertAlmostEqual(q["G1"], 10.0, places=4, msg=f"{name}: wrong Q on G1") + + def test_remote_gen_on_slack_single_slack_solver(self): + # test_remote_gen_on_slack's fixture: G1's own bus IS the (sole) slack, + # regulated only remotely (target on "LD"). Same regression, on the + # "remote-voltage-controller-on-slack" variant of the bug. + n = _star(g2_reg=None, g1_reg="LD", g1_tv=405.0) + n.per_unit = False + model = init_from_pypowsybl(n, gen_slack_id="G1", sort_index=True) + nb = len(model.get_bus_status()) + vl1_bus = list(n.get_buses().index).index( + n.get_buses().index[n.get_buses()["voltage_level_id"] == "VL1"][0]) + for name, algo in _single_slack_algos(model): + model.change_algorithm(algo) + V0 = np.ones(nb, dtype=np.complex128) + V = model.ac_pf(V0, 30, 1e-11) + self.assertGreater(V.shape[0], 0, f"{name}: diverged") + self.assertGreater(abs(abs(V[vl1_bus]) - 1.0), 1e-3, + f"{name}: slack bus (VL1) magnitude was frozen at init") + # ----- SVC -------------------------------------------------------------- def test_svc_local_voltage(self): self._compare(_svc_net(mode=VOLTAGE, target_v=398.0), "VL0", "G", "svc-local-V") diff --git a/src/core/powerflow_algorithm/CMakeLists.txt b/src/core/powerflow_algorithm/CMakeLists.txt index 8fc2d564..a43f7dd5 100644 --- a/src/core/powerflow_algorithm/CMakeLists.txt +++ b/src/core/powerflow_algorithm/CMakeLists.txt @@ -2,6 +2,7 @@ list(APPEND SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/GaussSeidelAlgo.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/GaussSeidelSynchAlgo.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/BaseAlgo.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemBase.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemHvdc.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemVoltageControl.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/NRSystemMultiSlack.cpp" diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 5dc48274..8143e734 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -137,7 +137,14 @@ class LS2G_API Contrib final * - theta unknowns for sorted(pv ∪ pq), * - vm unknowns for sorted(pq), * - P equations for sorted(pv ∪ pq), - * - Q equations for sorted(pq). + * - Q equations for sorted(pq), + * - vm unknowns + Q equations for slack buses NOT pinned by a LOCAL + * voltage-regulating generator (increasing bus-id order, appended last): + * PQ distributed-slack participants, remote-voltage / SVC-controlled + * slack buses. This is unconditional -- present whether or not the + * NRSystem instantiation has a MultiSlack extension -- because it is a + * property of ANY bus' voltage pinning, not of distributed-slack + * bookkeeping (see update_state, defined out-of-line in NRSystemBase.cpp). */ class LS2G_API Base { @@ -160,13 +167,18 @@ class LS2G_API Base return (int) (it - inner); }; - // call at the beginning of each solve + // call at the beginning of each solve. Defined out-of-line + // (NRSystemBase.cpp) because it needs the full LSGrid type (this + // header only forward-declares it) to query the slack buses that + // need a free Vm unknown + Q equation -- shared by EVERY NRSystem + // instantiation (SingleSlackNRSystem has no MultiSlack extension to + // do this, so Base must own it). void update_state( - const LSGrid * /*lsgrid_ptr*/, - const Eigen::SparseMatrix& /*Ybus*/, - const CplxVect& /*Sbus*/, - const RealVect& /*slack_weights*/ - ){} + const LSGrid * lsgrid_ptr, + const Eigen::SparseMatrix& Ybus, + const CplxVect& Sbus, + const RealVect& slack_weights + ); // call after update_state // at the beginning of each solve @@ -200,6 +212,21 @@ class LS2G_API Base for (int bus : pq_sorted) ledger.add_vm_unknown(bus); for (int bus : pvpq_sorted) ledger.add_p_equation(bus); for (int bus : pq_sorted) ledger.add_q_equation(bus); + + // A slack bus whose magnitude is NOT pinned by a local PV + // generator must keep a free Vm unknown and a Q equation -- + // exactly like an ordinary PQ bus (a PQ distributed-slack + // participant, or a slack bus regulated only remotely / by an + // SVC, to which the VoltageControl extension then attaches via + // vm_col(reg_bus) / q_row(bus)). free_vm_slack_buses_ is a + // std::set, so this iterates in increasing bus-id order. This + // set is empty (loop is a no-op) whenever every slack bus is + // pinned by a local PV generator -- the common case -- leaving + // the J layout bit-identical to before this change. + for (int bus : free_vm_slack_buses_) { + ledger.add_vm_unknown(bus); + ledger.add_q_equation(bus); + } } void declare_feature_entries(FeatureSink& /*sink*/) {} @@ -218,11 +245,16 @@ class LS2G_API Base nb_pv_ = 0; nb_pq_ = 0; nb_pvpq_ = 0; + free_vm_slack_buses_.clear(); } private: IntVect pv_, pq_, pvpq_; int nb_pv_, nb_pq_, nb_pvpq_; + // solver-bus ids of slack buses NOT pinned by a local PV generator; + // set by update_state (needs LSGrid, hence out-of-line), consumed by + // register_in. Empty in the common "slack is locally PV-pinned" case. + std::set free_vm_slack_buses_; public: Eigen::Ref pv() const { return pv_; } @@ -245,6 +277,11 @@ class LS2G_API Base * - a P equation for the ref slack bus (slack_ids[0]); * - one custom column: the slack_absorbed unknown. * + * A slack bus not locally voltage-pinned also gets a free Vm unknown + Q + * equation -- that is now Base's responsibility (Base::register_in), not + * this extension's: it is a property of bus voltage pinning shared by every + * NRSystem instantiation, single- or multi-slack (see Base's doc comment). + * * All the dS-derived Jacobian entries of those rows / columns are generated by * the generic dS pass of NRSystem. The only feature-specific entries are the * slack_absorbed column coefficients: slack_weights(bus) at each slack bus' @@ -260,10 +297,7 @@ class LS2G_API MultiSlack // distributed-slack extension slack_absorbed_(static_cast(0.)) {} // call at the beginning of each NR solve once. It is not called for NR - // iterations. Defined out-of-line (NRSystemMultiSlack.cpp) because it needs - // the full LSGrid type (NRSystem.hpp only forward-declares it): besides - // caching slack_weights / slack_absorbed it pulls the set of slack buses - // that need a free Vm unknown + Q equation, used by register_in. + // iterations. Caches slack_weights / initial slack_absorbed. void update_state( const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, @@ -299,27 +333,6 @@ class LS2G_API MultiSlack // distributed-slack extension slack_p_rows_.push_back(ledger.add_p_equation(slack_buses_[k])); } slack_p_rows_.push_back(ledger.add_p_equation(ref_slack_id_)); - // A slack bus whose magnitude is NOT pinned by a local PV generator must - // keep a free Vm unknown and a Q equation. This is the common case for a - // *distributed* slack: a participant whose generator is PQ - // (voltage_regulator_on == false) carries the active-power slack role on - // its P row, but its magnitude is still an unknown solved through its Q - // equation -- exactly like an ordinary PQ bus. It also covers slack buses - // hosting a remote-voltage controller or an SVC (the VoltageControl - // extension looks up q_row(bus) / vm_col(reg_bus) and attaches here). - // Without this the bus would be Vm-fixed (PV-like) at its init value, - // which is wrong unless a local PV generator actually holds it. - // The reference slack keeps its angle fixed (no theta unknown), so adding - // Vm + Q turns it into a "theta-fixed PQ" bus (fixed angle, free Vm). The - // new rows/cols are filled by the generic dS pass and the ledger-driven - // residual loop of NRSystem; nothing extra is needed here. (+1 Vm col and - // +1 Q row per such bus keep the Jacobian square.) - for (int k = 0; k < my_size_; ++k) { - if (free_vm_slack_buses_.count(slack_buses_[k])) { - ledger.add_vm_unknown(slack_buses_[k]); - ledger.add_q_equation(slack_buses_[k]); - } - } slack_col_ = ledger.add_custom_col(); // slack_absorbed unknown } @@ -368,7 +381,6 @@ class LS2G_API MultiSlack // distributed-slack extension feature_handles_.clear(); slack_weights_ = RealVect(); slack_absorbed_ = static_cast(0.); - free_vm_slack_buses_.clear(); } public: @@ -391,10 +403,6 @@ class LS2G_API MultiSlack // distributed-slack extension std::vector slack_p_rows_; // P row of each slack bus (same order) std::vector feature_handles_; // FeatureSink handles (same order) RealVect slack_weights_; // size: nb_bus - // solver-bus ids of slack buses NOT pinned by a local PV generator (PQ - // distributed-slack participants, remote-voltage / SVC controllers); those - // get an extra Vm unknown + Q equation in register_in (set by update_state) - std::set free_vm_slack_buses_; real_type slack_absorbed_; }; diff --git a/src/core/powerflow_algorithm/NRSystemBase.cpp b/src/core/powerflow_algorithm/NRSystemBase.cpp new file mode 100644 index 00000000..4f9355fb --- /dev/null +++ b/src/core/powerflow_algorithm/NRSystemBase.cpp @@ -0,0 +1,39 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#include "NRSystem.hpp" + +// out-of-line on purpose: NRSystem.hpp only forward-declares LSGrid, the full +// type is needed to query the slack buses that need a free Vm unknown + Q +// equation. Base is the one component present in EVERY NRSystem instantiation +// (single- or multi-slack), so this must live here rather than in MultiSlack: +// a grid with a single slack bus that is not locally voltage-pinned (a PQ +// distributed-slack-style participant, or a remote-voltage / SVC-controlled +// slack) needs the exact same free Vm unknown + Q equation whether solved +// with NR_KLU (MultiSlack present) or NRSing_KLU (no MultiSlack extension). +#include "LSGrid.hpp" + +namespace ls2g { + +void Base::update_state( + const LSGrid * lsgrid_ptr, + const Eigen::SparseMatrix& /*Ybus*/, + const CplxVect& /*Sbus*/, + const RealVect& /*slack_weights*/ +) +{ + // Slack buses not pinned by a LOCAL voltage-regulating generator need a + // free Vm unknown + Q equation (added in register_in), exactly like an + // ordinary PQ bus. See LSGrid::get_free_vm_slack_solver_buses for the + // exact criterion (GeneratorContainer::gen_is_local_voltage_controller). + free_vm_slack_buses_.clear(); + if (lsgrid_ptr != nullptr) + free_vm_slack_buses_ = lsgrid_ptr->get_free_vm_slack_solver_buses(); +} + +} // namespace ls2g diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp index a036a0bc..e5c90707 100644 --- a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -8,16 +8,11 @@ #include "NRSystem.hpp" -// out-of-line on purpose: NRSystem.hpp only forward-declares LSGrid (it is -// included BY LSGrid.hpp), the full type is needed to query the slack buses that -// need a free Vm unknown + Q equation. -#include "LSGrid.hpp" - namespace ls2g { void MultiSlack::update_state( const Base * /*nr_system_base_ptr*/, - const LSGrid * lsgrid_ptr, + const LSGrid * /*lsgrid_ptr*/, const Eigen::SparseMatrix& /*Ybus*/, const CplxVect& Sbus, const RealVect& slack_weights @@ -26,12 +21,6 @@ void MultiSlack::update_state( slack_weights_ = slack_weights; // initial slack absorbed (see MultiSlackPolicy::initial_slack_absorbed) slack_absorbed_ = std::real(Sbus.sum()); - // slack buses not pinned by a local PV generator need a free Vm + Q equation - // (added in register_in): PQ distributed-slack participants, and remote-voltage - // / SVC controllers (to which the VoltageControl extension then attaches). - free_vm_slack_buses_.clear(); - if(lsgrid_ptr != nullptr) - free_vm_slack_buses_ = lsgrid_ptr->get_free_vm_slack_solver_buses(); } } // namespace ls2g From 107a483fd168ca04b677a1b5c0218e64b14177f5 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 8 Jul 2026 14:03:24 +0200 Subject: [PATCH 049/166] fix an issue in batch solvers when line is half disconnected' Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 19 +++++ .../tests/test_line_disco_one_side.py | 65 ++++++++++++++++ .../batch_algorithm/BaseBatchSolverSynch.hpp | 75 +++++++++++++++---- 3 files changed, 145 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 60a98b05..d2510ef4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -412,6 +412,25 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. controller now falls back to local voltage control instead of crashing, matching OpenLoadFlow's behaviour (found on a real RTE grid snapshot). Tested in `test_remote_control_disconnected_target.py`. +- [FIXED] `TimeSeriesCPP` / `ContingencyAnalysisCPP` (and so the python `TimeSerie`, + `ContingencyAnalysis` and `SecurityAnalysis` wrappers) could report `NaN` (AC) or + wildly wrong, physically meaningless flows of several GW / `Inf` (DC) for a line or + transformer imported as "half-open" (one side open, see `keep_half_open_lines` in + `init_from_pypowsybl`). `BaseBatchSolverSynch::compute_amps_flows` / + `compute_active_power_flows` only checked the branch's *global* status before indexing + `_voltages` / `bus_vn_kv` with each side's bus id, never each side's own status; for a + half-open branch the open side's bus id is the `_deactivated_bus_id` (`-1`) sentinel, + so this read out of bounds. AC results happened to often be numerically 0 by + incidental cancellation (the open side's Kron-reduced `yac_eff_*` coefficient is + already 0), except for the amps of a branch whose *measured* side ("or"/side 1) was + itself the open one, which divided by a bogus voltage base and produced `NaN`; the DC + path has no such Kron reduction (`ydc_11`/`ydc_12` are not reduced) and mixed in an + arbitrary, disconnected bus's angle unconditionally. Both functions now substitute an + explicit `0` for an open side's voltage (matching + `TwoSidesContainer_rxh_A::compute_results_tsc_rxha_no_amps`) instead of indexing with + `-1`, and force the DC flow to `0` whenever either side is open (matching `fillBdc`'s + "disco on one side == disco on both sides" DC convention). Tested in + `test_line_disco_one_side.py`. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/tests/test_line_disco_one_side.py b/lightsim2grid/tests/test_line_disco_one_side.py index c68b3a0d..68452e6b 100644 --- a/lightsim2grid/tests/test_line_disco_one_side.py +++ b/lightsim2grid/tests/test_line_disco_one_side.py @@ -21,6 +21,7 @@ from lightsim2grid import LightSimBackend from lightsim2grid.network import init_from_pypowsybl +from lightsim2grid.lightsim2grid_cpp import TimeSeriesCPP, AlgorithmType from test_match_with_pypowsybl.utils_for_slack import ( @@ -704,5 +705,69 @@ def test_import_half_open_ac_matches_olf(self): def test_import_half_open_dc_matches_olf(self): self._check_vs_olf(is_dc=True) + def _check_batch_algo_matches_direct_pf(self, is_dc): + """Regression test for a bug in BaseBatchSolverSynch::compute_amps_flows / + compute_active_power_flows: for a half-open branch (this class's setup), the + open side's bus id is the `_deactivated_bus_id` (-1) sentinel, and these two + functions used to index `_voltages` / `bus_vn_kv` with it directly (only the + branch's *global* status was checked, never each side's own status). This used + to report `NaN` (AC amps, when the open side was the one being measured) or huge + bogus flows / `Inf` (DC, which has no Kron-reduced coefficients to cancel the + garbage read). `TimeSeriesCPP` here stands in for the shared code path also used + by `ContingencyAnalysisCPP` / `SecurityAnalysis`.""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + nb_bus = model.total_bus() + V0 = np.full(nb_bus, 1.0, dtype=complex) + + # reference: direct powerflow, using the already-correct per-branch + # compute_results path (TwoSidesContainer_rxh_A::compute_results_tsc_rxha) + V = model.dc_pf(V0, 1, 1e-8) if is_dc else model.ac_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + line_ref = self._line(model) + trafo_ref = self._trafo(model) + + # same case through the batch path (TimeSeriesCPP), which is what + # TimeSerie / ContingencyAnalysis / SecurityAnalysis use under the hood + gens = model.get_generators() + loads = model.get_loads() + sgens = model.get_static_generators() + gen_p = np.array([[g.target_p_mw for g in gens]]) + sgen_p = np.array([[s.target_p_mw for s in sgens]]) if len(sgens) else np.zeros((1, 0)) + load_p = np.array([[ld.target_p_mw for ld in loads]]) + load_q = np.array([[ld.target_q_mvar for ld in loads]]) + + ts = TimeSeriesCPP(model) + if is_dc: + ts.change_algorithm(AlgorithmType.DC_SparseLU) + status = ts.compute_Vs(gen_p, sgen_p, load_p, load_q, V0, 10, 1e-8) + assert status == 1, "TimeSeriesCPP powerflow diverged" + P = ts.compute_power_flows() + A = ts.compute_flows() + + # no NaN / Inf anywhere: this is exactly what the bug produced + assert np.isfinite(P).all(), "non-finite active power flow in the batch result" + assert np.isfinite(A).all(), "non-finite amps flow in the batch result" + + n_line = len(model.get_lines()) + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + + # and the values match the reference (per-branch, already-correct) computation + assert np.isclose(P[0, li], line_ref.res_p1_mw, atol=1e-6), f"{P[0, li]} vs {line_ref.res_p1_mw}" + assert np.isclose(A[0, li], line_ref.res_a1_ka, atol=1e-6), f"{A[0, li]} vs {line_ref.res_a1_ka}" + assert np.isclose(P[0, n_line + ti], trafo_ref.res_p1_mw, atol=1e-6), f"{P[0, n_line + ti]} vs {trafo_ref.res_p1_mw}" + assert np.isclose(A[0, n_line + ti], trafo_ref.res_a1_ka, atol=1e-6), f"{A[0, n_line + ti]} vs {trafo_ref.res_a1_ka}" + + def test_batch_algo_half_open_ac(self): + """TimeSeriesCPP (AC) must not return NaN for a half-open line/trafo""" + self._check_batch_algo_matches_direct_pf(is_dc=False) + + def test_batch_algo_half_open_dc(self): + """TimeSeriesCPP (DC) must not return huge bogus flows for a half-open line/trafo""" + self._check_batch_algo_matches_direct_pf(is_dc=True) + # TODO trafo with alpha (phase shift) # TODO FDPF powerflow too diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index d53e6b2f..478bf23f 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -121,10 +121,12 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants void compute_amps_flows(const T & structure_data, real_type sn_mva, size_t lag_id, - bool is_trafo) + bool is_trafo) { const auto & bus_vn_kv = _grid_model.get_bus_vn_kv(); const auto & el_status = structure_data.get_status_global(); + const auto & status1 = structure_data.get_status_side_1(); + const auto & status2 = structure_data.get_status_side_2(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); bool is_ac = _algo.ac_solver_used(); @@ -138,21 +140,36 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants size_t nb_el = structure_data.nb(); real_type sqrt_3 = sqrt(3.); + const Eigen::Index nb_steps = _voltages.rows(); RealVect res; for(size_t el_id = 0; el_id < nb_el; ++el_id){ if(!el_status[el_id]) continue; - // retrieve which buses are used + const bool s1 = status1[el_id]; + const bool s2 = status2[el_id]; + + // retrieve which buses are used; a half-open branch (see + // keep_half_open_lines) has bus_id == _deactivated_bus_id on + // its open side and must not be used to index _voltages / + // bus_vn_kv -- substitute an open-end voltage of exactly 0 + // instead (both sides treated the same way). For AC, yac_eff_* + // is already Kron-reduced for whichever side is open, so this + // alone gives the correct "or"-side (side 1) flow either way; + // DC has no such reduction (handled explicitly below). GlobalBusId bus_from_me = bus_from(el_id); GlobalBusId bus_to_me = bus_to(el_id); - - // retrieve voltages - const auto Efrom = _voltages.col(bus_from_me.cast_int()); // vector (one voltages per step) - const auto Eto = _voltages.col(bus_to_me.cast_int()); - - const real_type bus_vn_kv_f = bus_vn_kv(bus_from_me.cast_int()); - const RealVect v_f_kv = Efrom.array().abs() * bus_vn_kv_f; + const CplxVect Efrom = s1 ? CplxVect(_voltages.col(bus_from_me.cast_int())) : CplxVect::Zero(nb_steps); + const CplxVect Eto = s2 ? CplxVect(_voltages.col(bus_to_me.cast_int())) : CplxVect::Zero(nb_steps); + + // vn_kv base for the amps conversion: use whichever side is + // actually energized. If side 1 (the one being measured) is + // open the numerator below is exactly 0 regardless (AC: Efrom + // == 0 forces S_ft == 0; DC: forced explicitly), so the choice + // of base here only has to avoid a spurious 0/0 division. + const real_type bus_vn_kv_f = s1 ? bus_vn_kv(bus_from_me.cast_int()) + : (s2 ? bus_vn_kv(bus_to_me.cast_int()) : real_type(1.)); + const RealVect v_f_kv = (s1 ? Efrom.array().abs() : Eto.array().abs()) * bus_vn_kv_f; if(is_ac){ // retrieve physical parameters (complex) @@ -166,6 +183,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // now compute the current flow res = S_ft.array().abs() * sn_mva; }else{ + // unlike yac_eff_*, ydc_11/ydc_12 are NOT Kron-reduced for a + // half-open branch: DC treats one side open as fully + // disconnected (see fillBdc: "disco on one side == disco on + // both sides"), so report 0 rather than mixing ydc_ff/ydc_ft + // with a meaningless open-end angle. + if(!(s1 && s2)){ + _amps_flows.col(el_id + lag_id).setZero(); + continue; + } // DC active flow from the bus angles (theta) directly, like the gridmodel // results: P = ydc_ff . theta_from + ydc_ft . theta_to (theta = bus voltage angle) const real_type y_ff = vect_ydc_ff(el_id); @@ -187,6 +213,8 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants bool is_trafo) { const auto & el_status = structure_data.get_status_global(); + const auto & status1 = structure_data.get_status_side_1(); + const auto & status2 = structure_data.get_status_side_2(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); const bool is_ac = _algo.ac_solver_used(); @@ -199,18 +227,28 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants Eigen::Ref dc_x_tau_shift = structure_data.dc_x_tau_shift(); // not used in AC nor if it's powerline anyway size_t nb_el = structure_data.nb(); + const Eigen::Index nb_steps = _voltages.rows(); RealVect res; for(size_t el_id = 0; el_id < nb_el; ++el_id){ if(!el_status[el_id]) continue; - // retrieve which buses are used + const bool s1 = status1[el_id]; + const bool s2 = status2[el_id]; + + // a half-open branch (see keep_half_open_lines) has bus_id == + // _deactivated_bus_id on its open side; substitute an open-end + // voltage of exactly 0 rather than indexing _voltages with it + // (both sides treated the same way). For AC, yac_eff_* is + // already Kron-reduced for whichever side is open, so this + // alone gives the correct "or"-side (side 1) flow either way + // (0 when side 1 itself is open, matching + // TwoSidesContainer_rxh_A::compute_results_tsc_rxha_no_amps); + // DC has no such reduction (handled explicitly below). GlobalBusId bus_from_me = bus_from(el_id); GlobalBusId bus_to_me = bus_to(el_id); - - // retrieve voltages - const auto & Efrom = _voltages.col(bus_from_me.cast_int()); // vector (one voltages per step) - const auto & Eto = _voltages.col(bus_to_me.cast_int()); + const CplxVect Efrom = s1 ? CplxVect(_voltages.col(bus_from_me.cast_int())) : CplxVect::Zero(nb_steps); + const CplxVect Eto = s2 ? CplxVect(_voltages.col(bus_to_me.cast_int())) : CplxVect::Zero(nb_steps); // trafo equations (to get the power at the "from" side) if(is_ac){ @@ -224,6 +262,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // now compute the active flow res = S_ft.array().real() * sn_mva; }else{ + // unlike yac_eff_*, ydc_11/ydc_12 are NOT Kron-reduced for a + // half-open branch: DC treats one side open as fully + // disconnected (see fillBdc: "disco on one side == disco on + // both sides"), so report 0 rather than mixing ydc_ff/ydc_ft + // with a meaningless open-end angle. + if(!(s1 && s2)){ + _active_power_flows.col(el_id + lag_id).setZero(); + continue; + } // DC active flow from the bus angles (theta) directly, like the gridmodel // results: P = ydc_ff . theta_from + ydc_ft . theta_to (theta = bus voltage angle) const real_type y_ff = vect_ydc_ff(el_id); From 8c6a7242663953477ba8a4c29260dcf1bbdbf957 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 8 Jul 2026 14:03:24 +0200 Subject: [PATCH 050/166] fix an issue in batch solvers when line is half disconnected' Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 19 +++++ .../tests/test_line_disco_one_side.py | 65 ++++++++++++++++ .../batch_algorithm/BaseBatchSolverSynch.hpp | 75 +++++++++++++++---- 3 files changed, 145 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 60a98b05..d2510ef4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -412,6 +412,25 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. controller now falls back to local voltage control instead of crashing, matching OpenLoadFlow's behaviour (found on a real RTE grid snapshot). Tested in `test_remote_control_disconnected_target.py`. +- [FIXED] `TimeSeriesCPP` / `ContingencyAnalysisCPP` (and so the python `TimeSerie`, + `ContingencyAnalysis` and `SecurityAnalysis` wrappers) could report `NaN` (AC) or + wildly wrong, physically meaningless flows of several GW / `Inf` (DC) for a line or + transformer imported as "half-open" (one side open, see `keep_half_open_lines` in + `init_from_pypowsybl`). `BaseBatchSolverSynch::compute_amps_flows` / + `compute_active_power_flows` only checked the branch's *global* status before indexing + `_voltages` / `bus_vn_kv` with each side's bus id, never each side's own status; for a + half-open branch the open side's bus id is the `_deactivated_bus_id` (`-1`) sentinel, + so this read out of bounds. AC results happened to often be numerically 0 by + incidental cancellation (the open side's Kron-reduced `yac_eff_*` coefficient is + already 0), except for the amps of a branch whose *measured* side ("or"/side 1) was + itself the open one, which divided by a bogus voltage base and produced `NaN`; the DC + path has no such Kron reduction (`ydc_11`/`ydc_12` are not reduced) and mixed in an + arbitrary, disconnected bus's angle unconditionally. Both functions now substitute an + explicit `0` for an open side's voltage (matching + `TwoSidesContainer_rxh_A::compute_results_tsc_rxha_no_amps`) instead of indexing with + `-1`, and force the DC flow to `0` whenever either side is open (matching `fillBdc`'s + "disco on one side == disco on both sides" DC convention). Tested in + `test_line_disco_one_side.py`. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/tests/test_line_disco_one_side.py b/lightsim2grid/tests/test_line_disco_one_side.py index c68b3a0d..68452e6b 100644 --- a/lightsim2grid/tests/test_line_disco_one_side.py +++ b/lightsim2grid/tests/test_line_disco_one_side.py @@ -21,6 +21,7 @@ from lightsim2grid import LightSimBackend from lightsim2grid.network import init_from_pypowsybl +from lightsim2grid.lightsim2grid_cpp import TimeSeriesCPP, AlgorithmType from test_match_with_pypowsybl.utils_for_slack import ( @@ -704,5 +705,69 @@ def test_import_half_open_ac_matches_olf(self): def test_import_half_open_dc_matches_olf(self): self._check_vs_olf(is_dc=True) + def _check_batch_algo_matches_direct_pf(self, is_dc): + """Regression test for a bug in BaseBatchSolverSynch::compute_amps_flows / + compute_active_power_flows: for a half-open branch (this class's setup), the + open side's bus id is the `_deactivated_bus_id` (-1) sentinel, and these two + functions used to index `_voltages` / `bus_vn_kv` with it directly (only the + branch's *global* status was checked, never each side's own status). This used + to report `NaN` (AC amps, when the open side was the one being measured) or huge + bogus flows / `Inf` (DC, which has no Kron-reduced coefficients to cancel the + garbage read). `TimeSeriesCPP` here stands in for the shared code path also used + by `ContingencyAnalysisCPP` / `SecurityAnalysis`.""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + nb_bus = model.total_bus() + V0 = np.full(nb_bus, 1.0, dtype=complex) + + # reference: direct powerflow, using the already-correct per-branch + # compute_results path (TwoSidesContainer_rxh_A::compute_results_tsc_rxha) + V = model.dc_pf(V0, 1, 1e-8) if is_dc else model.ac_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + line_ref = self._line(model) + trafo_ref = self._trafo(model) + + # same case through the batch path (TimeSeriesCPP), which is what + # TimeSerie / ContingencyAnalysis / SecurityAnalysis use under the hood + gens = model.get_generators() + loads = model.get_loads() + sgens = model.get_static_generators() + gen_p = np.array([[g.target_p_mw for g in gens]]) + sgen_p = np.array([[s.target_p_mw for s in sgens]]) if len(sgens) else np.zeros((1, 0)) + load_p = np.array([[ld.target_p_mw for ld in loads]]) + load_q = np.array([[ld.target_q_mvar for ld in loads]]) + + ts = TimeSeriesCPP(model) + if is_dc: + ts.change_algorithm(AlgorithmType.DC_SparseLU) + status = ts.compute_Vs(gen_p, sgen_p, load_p, load_q, V0, 10, 1e-8) + assert status == 1, "TimeSeriesCPP powerflow diverged" + P = ts.compute_power_flows() + A = ts.compute_flows() + + # no NaN / Inf anywhere: this is exactly what the bug produced + assert np.isfinite(P).all(), "non-finite active power flow in the batch result" + assert np.isfinite(A).all(), "non-finite amps flow in the batch result" + + n_line = len(model.get_lines()) + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + + # and the values match the reference (per-branch, already-correct) computation + assert np.isclose(P[0, li], line_ref.res_p1_mw, atol=1e-6), f"{P[0, li]} vs {line_ref.res_p1_mw}" + assert np.isclose(A[0, li], line_ref.res_a1_ka, atol=1e-6), f"{A[0, li]} vs {line_ref.res_a1_ka}" + assert np.isclose(P[0, n_line + ti], trafo_ref.res_p1_mw, atol=1e-6), f"{P[0, n_line + ti]} vs {trafo_ref.res_p1_mw}" + assert np.isclose(A[0, n_line + ti], trafo_ref.res_a1_ka, atol=1e-6), f"{A[0, n_line + ti]} vs {trafo_ref.res_a1_ka}" + + def test_batch_algo_half_open_ac(self): + """TimeSeriesCPP (AC) must not return NaN for a half-open line/trafo""" + self._check_batch_algo_matches_direct_pf(is_dc=False) + + def test_batch_algo_half_open_dc(self): + """TimeSeriesCPP (DC) must not return huge bogus flows for a half-open line/trafo""" + self._check_batch_algo_matches_direct_pf(is_dc=True) + # TODO trafo with alpha (phase shift) # TODO FDPF powerflow too diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index d53e6b2f..478bf23f 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -121,10 +121,12 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants void compute_amps_flows(const T & structure_data, real_type sn_mva, size_t lag_id, - bool is_trafo) + bool is_trafo) { const auto & bus_vn_kv = _grid_model.get_bus_vn_kv(); const auto & el_status = structure_data.get_status_global(); + const auto & status1 = structure_data.get_status_side_1(); + const auto & status2 = structure_data.get_status_side_2(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); bool is_ac = _algo.ac_solver_used(); @@ -138,21 +140,36 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants size_t nb_el = structure_data.nb(); real_type sqrt_3 = sqrt(3.); + const Eigen::Index nb_steps = _voltages.rows(); RealVect res; for(size_t el_id = 0; el_id < nb_el; ++el_id){ if(!el_status[el_id]) continue; - // retrieve which buses are used + const bool s1 = status1[el_id]; + const bool s2 = status2[el_id]; + + // retrieve which buses are used; a half-open branch (see + // keep_half_open_lines) has bus_id == _deactivated_bus_id on + // its open side and must not be used to index _voltages / + // bus_vn_kv -- substitute an open-end voltage of exactly 0 + // instead (both sides treated the same way). For AC, yac_eff_* + // is already Kron-reduced for whichever side is open, so this + // alone gives the correct "or"-side (side 1) flow either way; + // DC has no such reduction (handled explicitly below). GlobalBusId bus_from_me = bus_from(el_id); GlobalBusId bus_to_me = bus_to(el_id); - - // retrieve voltages - const auto Efrom = _voltages.col(bus_from_me.cast_int()); // vector (one voltages per step) - const auto Eto = _voltages.col(bus_to_me.cast_int()); - - const real_type bus_vn_kv_f = bus_vn_kv(bus_from_me.cast_int()); - const RealVect v_f_kv = Efrom.array().abs() * bus_vn_kv_f; + const CplxVect Efrom = s1 ? CplxVect(_voltages.col(bus_from_me.cast_int())) : CplxVect::Zero(nb_steps); + const CplxVect Eto = s2 ? CplxVect(_voltages.col(bus_to_me.cast_int())) : CplxVect::Zero(nb_steps); + + // vn_kv base for the amps conversion: use whichever side is + // actually energized. If side 1 (the one being measured) is + // open the numerator below is exactly 0 regardless (AC: Efrom + // == 0 forces S_ft == 0; DC: forced explicitly), so the choice + // of base here only has to avoid a spurious 0/0 division. + const real_type bus_vn_kv_f = s1 ? bus_vn_kv(bus_from_me.cast_int()) + : (s2 ? bus_vn_kv(bus_to_me.cast_int()) : real_type(1.)); + const RealVect v_f_kv = (s1 ? Efrom.array().abs() : Eto.array().abs()) * bus_vn_kv_f; if(is_ac){ // retrieve physical parameters (complex) @@ -166,6 +183,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // now compute the current flow res = S_ft.array().abs() * sn_mva; }else{ + // unlike yac_eff_*, ydc_11/ydc_12 are NOT Kron-reduced for a + // half-open branch: DC treats one side open as fully + // disconnected (see fillBdc: "disco on one side == disco on + // both sides"), so report 0 rather than mixing ydc_ff/ydc_ft + // with a meaningless open-end angle. + if(!(s1 && s2)){ + _amps_flows.col(el_id + lag_id).setZero(); + continue; + } // DC active flow from the bus angles (theta) directly, like the gridmodel // results: P = ydc_ff . theta_from + ydc_ft . theta_to (theta = bus voltage angle) const real_type y_ff = vect_ydc_ff(el_id); @@ -187,6 +213,8 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants bool is_trafo) { const auto & el_status = structure_data.get_status_global(); + const auto & status1 = structure_data.get_status_side_1(); + const auto & status2 = structure_data.get_status_side_2(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); const bool is_ac = _algo.ac_solver_used(); @@ -199,18 +227,28 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants Eigen::Ref dc_x_tau_shift = structure_data.dc_x_tau_shift(); // not used in AC nor if it's powerline anyway size_t nb_el = structure_data.nb(); + const Eigen::Index nb_steps = _voltages.rows(); RealVect res; for(size_t el_id = 0; el_id < nb_el; ++el_id){ if(!el_status[el_id]) continue; - // retrieve which buses are used + const bool s1 = status1[el_id]; + const bool s2 = status2[el_id]; + + // a half-open branch (see keep_half_open_lines) has bus_id == + // _deactivated_bus_id on its open side; substitute an open-end + // voltage of exactly 0 rather than indexing _voltages with it + // (both sides treated the same way). For AC, yac_eff_* is + // already Kron-reduced for whichever side is open, so this + // alone gives the correct "or"-side (side 1) flow either way + // (0 when side 1 itself is open, matching + // TwoSidesContainer_rxh_A::compute_results_tsc_rxha_no_amps); + // DC has no such reduction (handled explicitly below). GlobalBusId bus_from_me = bus_from(el_id); GlobalBusId bus_to_me = bus_to(el_id); - - // retrieve voltages - const auto & Efrom = _voltages.col(bus_from_me.cast_int()); // vector (one voltages per step) - const auto & Eto = _voltages.col(bus_to_me.cast_int()); + const CplxVect Efrom = s1 ? CplxVect(_voltages.col(bus_from_me.cast_int())) : CplxVect::Zero(nb_steps); + const CplxVect Eto = s2 ? CplxVect(_voltages.col(bus_to_me.cast_int())) : CplxVect::Zero(nb_steps); // trafo equations (to get the power at the "from" side) if(is_ac){ @@ -224,6 +262,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // now compute the active flow res = S_ft.array().real() * sn_mva; }else{ + // unlike yac_eff_*, ydc_11/ydc_12 are NOT Kron-reduced for a + // half-open branch: DC treats one side open as fully + // disconnected (see fillBdc: "disco on one side == disco on + // both sides"), so report 0 rather than mixing ydc_ff/ydc_ft + // with a meaningless open-end angle. + if(!(s1 && s2)){ + _active_power_flows.col(el_id + lag_id).setZero(); + continue; + } // DC active flow from the bus angles (theta) directly, like the gridmodel // results: P = ydc_ff . theta_from + ydc_ft . theta_to (theta = bus voltage angle) const real_type y_ff = vect_ydc_ff(el_id); From bce5890ae66db5be4ffedead2140b6b92d152978 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 8 Jul 2026 14:47:51 +0200 Subject: [PATCH 051/166] fix some other issues related to disconnected buses Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 45 ++++++++ .../tests/test_line_disco_one_side.py | 104 +++++++++++++++++- src/core/LSGrid.cpp | 37 ++++++- .../batch_algorithm/ContingencyAnalysis.cpp | 48 ++++++-- .../element_container/HvdcLineContainer.cpp | 35 +++++- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 9 +- 6 files changed, 262 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d2510ef4..31b760fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -431,6 +431,51 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `-1`, and force the DC flow to `0` whenever either side is open (matching `fillBdc`'s "disco on one side == disco on both sides" DC convention). Tested in `test_line_disco_one_side.py`. +- [FIXED] `LSGrid.get_lodf()` indexed `id_me_to_dc_solver_` directly with a branch's raw + (grid-numbering) bus id, with no status check of any kind (not even the branch's + *global* one). For a half-open branch (`keep_half_open_lines`) the open side's bus id + is the `_deactivated_bus_id` (`-1`) sentinel, and `id_me_to_dc_solver_` casts negative + indices to a huge unsigned offset before indexing its backing `std::vector`, so this + was a near-certain out-of-bounds crash, reachable from plain Python via + `init_from_pypowsybl(keep_half_open_lines=True)` -> `dc_pf(...)` -> `get_lodf()`. + `BaseDCAlgo::get_lodf()` had a second, independent bug in the same spot: its + `_deactivated_bus_id` guard set the LODF column to `NaN` but never `continue`d, so the + very next line silently overwrote that with `PTDF.col(-1)` -- again indexing with `-1`. + Both are fixed: `LSGrid.get_lodf()` now checks each branch's own per-side DC + connectivity (half-open or fully disconnected both count, matching `fillBdc`'s "disco + on one side == disco on both sides") before resolving a solver bus id, propagating the + `_deactivated_bus_id` sentinel instead of indexing with it; `BaseDCAlgo::get_lodf()` + now gives such a branch the identity treatment (row and column all `0` except a `1` on + the diagonal) and `continue`s, matching the physical fact that a branch already + carrying no DC flow has zero impact anywhere, whether it is itself "outaged" or some + other line is. Tested in `test_line_disco_one_side.py`. +- [FIXED] `ContingencyAnalysis.cpp`'s internal `check_current_violations` (feeds + `ContingencyAnalysisCPP.compute_limit_violations` / `get_violations` / `get_violations_n`, + used by the python `ContingencyAnalysis` / `SecurityAnalysis` wrappers) had the same + class of bug as the `BaseBatchSolverSynch` one above: it only checked a branch's + *global* status before resolving and indexing with each side's raw bus id (the + `_deactivated_bus_id` check ran only after that indexing had already happened). Now + each side's bus id is only resolved/indexed when that side's own status is connected + (mirroring the `BaseBatchSolverSynch` fix): an open side's voltage is substituted with + `0`, and for DC (no Kron-reduced coefficients to cancel a bogus open-end angle, unlike + AC) both sides report `0` current whenever either side is open, matching `fillBdc`'s + convention. Tested in `test_line_disco_one_side.py`. +- [HARDENING] `HvdcLineContainer::fillSbus` / `compute_results` and + `LSGrid::fill_hvdc_droop_solver_data` resolve an angle-droop ("AC emulation") HVDC + line's two converter buses using the *masked* per-side accessor (`-1` if that side is + individually disconnected) before indexing `id_grid_to_solver`/`id_me_to_solver`, with + no per-side status check of their own -- only relying on the invariant that + `LSGrid::deactivate_dcline_side1`/`_side2` and + `HvdcLineContainer::disconnect_if_not_in_main_component` always call `disable_droop` + whenever a converter is individually opened (droop across an open converter has no + physical meaning: the remote angle used by the linear P(theta) relationship is gone). + This currently always holds (no other code path can set `droop_enabled_` per-element), + so this was not a live bug, but it depended on every future half-open-creating code + path remembering to also call `disable_droop`, with no immediate feedback if one + didn't. All three functions now explicitly check both sides are connected before + resolving/indexing, and raise a `RuntimeError` instead of silently indexing with `-1` + if that invariant is ever violated. Existing HVDC test suite (`test_hvdc_*`, 35 tests) + unaffected. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/tests/test_line_disco_one_side.py b/lightsim2grid/tests/test_line_disco_one_side.py index 68452e6b..c7fa0d10 100644 --- a/lightsim2grid/tests/test_line_disco_one_side.py +++ b/lightsim2grid/tests/test_line_disco_one_side.py @@ -21,7 +21,8 @@ from lightsim2grid import LightSimBackend from lightsim2grid.network import init_from_pypowsybl -from lightsim2grid.lightsim2grid_cpp import TimeSeriesCPP, AlgorithmType +from lightsim2grid.lightsim2grid_cpp import (TimeSeriesCPP, AlgorithmType, + ContingencyAnalysisCPP, ViolationElementType) from test_match_with_pypowsybl.utils_for_slack import ( @@ -769,5 +770,106 @@ def test_batch_algo_half_open_dc(self): """TimeSeriesCPP (DC) must not return huge bogus flows for a half-open line/trafo""" self._check_batch_algo_matches_direct_pf(is_dc=True) + def test_lodf_half_open(self): + """Regression test for LSGrid::get_lodf()/BaseDCAlgo::get_lodf indexing a bus id + of -1 (the open side's `_deactivated_bus_id`) without any guard. A half-open + branch carries no DC flow at all (same convention as `fillBdc`), so "outaging" it + changes nothing anywhere in the grid: its row and column in the LODF matrix should + be the identity (0 everywhere except a 1 on the diagonal).""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + nb_bus = model.total_bus() + V0 = np.full(nb_bus, 1.0, dtype=complex) + V = model.dc_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + + LODF = model.get_lodf() + n_line = len(model.get_lines()) + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + + for idx in (li, n_line + ti): + col = LODF[:, idx] + assert col[idx] == 1., f"diagonal (col) at {idx} is {col[idx]}, expected 1." + off_diag_col = np.delete(col, idx) + assert np.all(off_diag_col == 0.), f"non-zero off-diagonal in column {idx}" + + row = LODF[idx, :] + assert row[idx] == 1., f"diagonal (row) at {idx} is {row[idx]}, expected 1." + off_diag_row = np.delete(row, idx) + # NaNs on the row are expected: they come from OTHER (unrelated) bridge/radial + # lines whose own LODF column is undefined, not from our half-open branch -- + # every non-NaN entry must still be exactly 0. + finite = off_diag_row[~np.isnan(off_diag_row)] + assert np.all(finite == 0.), f"non-zero finite off-diagonal in row {idx}" + + def _check_current_violations_half_open(self, is_dc): + """Regression test for ContingencyAnalysis.cpp::check_current_violations indexing + a bus id of -1 (the open side's `_deactivated_bus_id`) without any per-side guard + before the `_deactivated_bus_id` check (which ran too late, after the indexing + already happened). Sets an artificially tiny current limit on the half-open line + and trafo and checks the reported currents match the trusted per-branch reference + (`LSGrid.ac_pf`/`dc_pf` + `LineInfo`/`TrafoInfo`) exactly, instead of crashing or + reporting garbage.""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + n_line = len(model.get_lines()) + n_trafo = len(model.get_trafos()) + + # tiny (but not 0, to avoid the "no limit configured" early-return) limit on the + # half-open elements, huge limit (no violation) everywhere else + lim_line = np.full(n_line, 999.) + lim_line[li] = 1e-6 + lim_trafo = np.full(n_trafo, 999.) + lim_trafo[ti] = 1e-6 + model.set_line_current_limit_side1(lim_line) + model.set_line_current_limit_side2(lim_line) + model.set_trafo_current_limit_side1(lim_trafo) + model.set_trafo_current_limit_side2(lim_trafo) + + nb_bus = model.total_bus() + V0 = np.full(nb_bus, 1.0, dtype=complex) + + # reference: direct powerflow, using the already-correct per-branch compute_results + V = model.dc_pf(V0, 10, 1e-8) if is_dc else model.ac_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + line_ref = self._line(model) + trafo_ref = self._trafo(model) + + ca = ContingencyAnalysisCPP(model, True) # compute_limit_violations=True + if is_dc: + ca.change_algorithm(AlgorithmType.DC_SparseLU) + ca.add_all_n1() + ca.compute(V0, 10, 1e-8) + violations = ca.get_violations_n() + + by_side = {(v.element_id, v.side): v.value for v in violations + if v.element_type == ViolationElementType.LINE and v.element_id == li} + by_side.update({(v.element_id, v.side): v.value for v in violations + if v.element_type == ViolationElementType.TRAFO and v.element_id == ti}) + + # side 1 of the line is the open one: 0 current, never violates (not reported) + assert (li, 1) not in by_side or np.isclose(by_side[(li, 1)], line_ref.res_a1_ka, atol=1e-9) + # side 2 of the line is energized: matches the reference exactly (this is the one + # actually expected to violate the tiny limit, for AC -- line charging current) + if not np.isclose(line_ref.res_a2_ka, 0., atol=1e-9): + assert (li, 2) in by_side, "expected side 2 of the half-open line to violate" + assert np.isclose(by_side[(li, 2)], line_ref.res_a2_ka, atol=1e-9) + # the trafo (pure series, no charging) carries 0 A on both sides: no violation + assert (ti, 1) not in by_side or np.isclose(by_side[(ti, 1)], trafo_ref.res_a1_ka, atol=1e-9) + assert (ti, 2) not in by_side or np.isclose(by_side[(ti, 2)], trafo_ref.res_a2_ka, atol=1e-9) + + def test_current_violations_half_open_ac(self): + self._check_current_violations_half_open(is_dc=False) + + def test_current_violations_half_open_dc(self): + self._check_current_violations_half_open(is_dc=True) + # TODO trafo with alpha (phase shift) # TODO FDPF powerflow too diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index b90f07b1..7676a1a8 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -434,6 +434,21 @@ void LSGrid::fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) co data.connected2.assign(nb_droop, true); for(int pos = 0; pos < nb_droop; ++pos){ const int hvdc_id = indices[pos]; + // angle-droop ("AC emulation") needs both remote angles: this must never + // happen (both call sites that can half-open an hvdc line -- + // LSGrid::deactivate_dcline_side1/2 and + // HvdcLineContainer::disconnect_if_not_in_main_component -- call + // disable_droop at the same time), but enforce it explicitly rather than + // silently indexing id_me_to_solver with the open side's bus id (-1) below. + if(!hvdc_lines_.get_connected_side_1(hvdc_id) || !hvdc_lines_.get_connected_side_2(hvdc_id)){ + std::ostringstream exc_; + exc_ << "LSGrid::fill_hvdc_droop_solver_data: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " has angle-droop enabled while half-open (one side " + "disconnected) -- this should never happen, disable_droop " + "must be called whenever a side is opened."; + throw std::runtime_error(exc_.str()); + } const GlobalBusId bus_1 = hvdc_lines_.get_bus_side_1(hvdc_id); const GlobalBusId bus_2 = hvdc_lines_.get_bus_side_2(hvdc_id); const SolverBusId bus_1_solver = id_me_to_solver[bus_1.cast_int()]; @@ -1427,15 +1442,35 @@ RealMat LSGrid::get_lodf(){ if(Bbus_dc_.size() == 0){ throw std::runtime_error("LSGrid::get_lodf: Cannot get the ptdf without having first computed a DC powerflow."); } - const size_t nb_el = powerlines_.nb() + trafos_.nb(); + const size_t n_line = powerlines_.nb(); + const size_t nb_el = n_line + trafos_.nb(); // retrieve the from_bus / to_bus from the grid GlobalBusIdVect from_bus = GlobalBusIdVect::concat(powerlines_.get_bus_id_side_1(), trafos_.get_bus_id_side_1()); GlobalBusIdVect to_bus = GlobalBusIdVect::concat(powerlines_.get_bus_id_side_2(), trafos_.get_bus_id_side_2()); + const auto & status1_line = powerlines_.get_status_side_1(); + const auto & status2_line = powerlines_.get_status_side_2(); + const auto & status1_trafo = trafos_.get_status_side_1(); + const auto & status2_trafo = trafos_.get_status_side_2(); // convert it to solver bus id IntVect from_bus_solver(nb_el); // TODO : SolverBusIdVect here IntVect to_bus_solver(nb_el); for(size_t el_id = 0; el_id < nb_el; ++el_id){ + const bool is_dc_connected = el_id < n_line + ? (status1_line[el_id] && status2_line[el_id]) + : (status1_trafo[el_id - n_line] && status2_trafo[el_id - n_line]); + if(!is_dc_connected){ + // half-open (see keep_half_open_lines) or fully disconnected: this + // branch carries no DC flow at all (TwoSidesContainer_rxh_A::fillBdc + // drops it from Bbus entirely -- "disco on one side == disco on both + // sides"), and its open/stale bus id must not index + // id_me_to_dc_solver_ -- propagate the deactivated sentinel instead, + // so BaseDCAlgo::get_lodf gives it the identity treatment (its + // "outage" changes nothing, anywhere). + from_bus_solver[el_id] = BaseConstants::_deactivated_bus_id; + to_bus_solver[el_id] = BaseConstants::_deactivated_bus_id; + continue; + } // from side GlobalBusId f_grid_bus = from_bus[el_id]; SolverBusId f_solver_bus = id_me_to_dc_solver_[f_grid_bus.cast_int()]; diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 6b836823..c6472e79 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -90,6 +90,8 @@ void check_current_violations( if(limit1.size() == 0 && limit2.size() == 0) return; // thermal limits never configured const auto & el_status = structure_data.get_status_global(); + const auto & status1 = structure_data.get_status_side_1(); + const auto & status2 = structure_data.get_status_side_2(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); const std::vector & el_names = structure_data.get_names(); // empty if never set @@ -125,17 +127,36 @@ void check_current_violations( const bool has_lim2 = limit2.size() > 0 && !isnan(limit2(el_idx)); if(!has_lim1 && !has_lim2) continue; - const int bus_from_me = bus_from(static_cast(el_id)).cast_int(); - const int bus_to_me = bus_to(static_cast(el_id)).cast_int(); - const int solver_from = id_me_to_solver(bus_from_me).cast_int(); - const int solver_to = id_me_to_solver(bus_to_me).cast_int(); - if(solver_from == BaseConstants::_deactivated_bus_id || - solver_to == BaseConstants::_deactivated_bus_id) continue; + // a half-open branch (see keep_half_open_lines) has bus_id == + // _deactivated_bus_id on its open side; only resolve / index with a + // side's bus id when that side is actually connected (both sides + // treated the same way), mirroring + // BaseBatchSolverSynch::compute_amps_flows / compute_active_power_flows. + const bool s1 = status1[el_id]; + const bool s2 = status2[el_id]; + int bus_from_me = BaseConstants::_deactivated_bus_id; + int bus_to_me = BaseConstants::_deactivated_bus_id; + int solver_from = BaseConstants::_deactivated_bus_id; + int solver_to = BaseConstants::_deactivated_bus_id; + if(s1){ + bus_from_me = bus_from(static_cast(el_id)).cast_int(); + solver_from = id_me_to_solver(bus_from_me).cast_int(); + if(solver_from == BaseConstants::_deactivated_bus_id) continue; // bus not in the solver + } + if(s2){ + bus_to_me = bus_to(static_cast(el_id)).cast_int(); + solver_to = id_me_to_solver(bus_to_me).cast_int(); + if(solver_to == BaseConstants::_deactivated_bus_id) continue; + } - const cplx_type Efrom = V(solver_from); - const cplx_type Eto = V(solver_to); - const real_type v_from_kv = std::abs(Efrom) * bus_vn_kv(bus_from_me); - const real_type v_to_kv = std::abs(Eto) * bus_vn_kv(bus_to_me); + // an open side's voltage is exactly 0 (yac_eff_* is already Kron-reduced + // for whichever side is open, so this alone gives the correct AC result + // either way); the vn_kv base only has to avoid a spurious 0/0 division + // when that side isn't used (numerator forced to 0 too, see below). + const cplx_type Efrom = s1 ? V(solver_from) : cplx_type(0., 0.); + const cplx_type Eto = s2 ? V(solver_to) : cplx_type(0., 0.); + const real_type v_from_kv = s1 ? std::abs(Efrom) * bus_vn_kv(bus_from_me) : real_type(1.); + const real_type v_to_kv = s2 ? std::abs(Eto) * bus_vn_kv(bus_to_me) : real_type(1.); real_type amps1 = 0.; real_type amps2 = 0.; @@ -149,7 +170,12 @@ void check_current_violations( const cplx_type I_to = std::conj(yac_eff_22(el_idx) * Eto + yac_eff_21(el_idx) * Efrom); const cplx_type S_to = Eto * I_to; amps2 = std::abs(S_to) * sn_mva / (sqrt_3 * v_to_kv); - } else { + } else if(s1 && s2){ + // unlike yac_eff_*, ydc_11/ydc_12 are NOT Kron-reduced for a + // half-open branch: DC treats one side open as fully disconnected + // (see fillBdc: "disco on one side == disco on both sides"), so + // both sides report 0 (amps1/amps2 already initialized) rather + // than mixing ydc_ff/ydc_ft with a meaningless open-end angle. const real_type theta_from = std::arg(Efrom); const real_type theta_to = std::arg(Eto); // side 1: mirrors BaseBatchSolverSynch::compute_amps_flows exactly (incl. the diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index 9d652f20..2dfd94cb 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -379,6 +379,21 @@ void HvdcLineContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_gri for(int hvdc_id = 0; hvdc_id < nb_hvdc; ++hvdc_id){ if(!droop_enabled_[hvdc_id] || !status_global_[hvdc_id]) continue; if(status_droop_(hvdc_id) == 0) continue; // linear: theta-dependent, handled by the DC algorithm + // angle-droop ("AC emulation") needs both remote angles: this must + // never happen (both call sites that can half-open an hvdc line -- + // LSGrid::deactivate_dcline_side1/2 and + // disconnect_if_not_in_main_component -- call disable_droop at the + // same time), but enforce it explicitly rather than silently + // indexing id_grid_to_solver with the open side's bus id (-1) below. + if(!get_connected_side_1(hvdc_id) || !get_connected_side_2(hvdc_id)){ + std::ostringstream exc_; + exc_ << "HvdcLineContainer::fillSbus: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " has angle-droop enabled while half-open (one side " + "disconnected) -- this should never happen, disable_droop " + "must be called whenever a side is opened."; + throw std::runtime_error(exc_.str()); + } real_type p1_flow, p2_flow; droop_flows_mw(hvdc_id, 0., p1_flow, p2_flow); // raw unused when saturated const GlobalBusId bus_1 = get_bus_side_1(hvdc_id); @@ -419,12 +434,30 @@ void HvdcLineContainer::compute_results(const Eigen::Ref & Va, Eigen::Ref res_p_2 = get_res_p_side_2(); for(int hvdc_id = 0; hvdc_id < nb_hvdc; ++hvdc_id){ if(!droop_enabled_[hvdc_id] || !status_global_[hvdc_id]) continue; + // same invariant as HvdcLineContainer::fillSbus: a droop-enabled line + // must never be half-open (disable_droop is called wherever a side can + // be opened) -- enforce it rather than silently indexing with -1 below. + if(!get_connected_side_1(hvdc_id) || !get_connected_side_2(hvdc_id)){ + std::ostringstream exc_; + exc_ << "HvdcLineContainer::compute_results: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " has angle-droop enabled while half-open (one side " + "disconnected) -- this should never happen, disable_droop " + "must be called whenever a side is opened."; + throw std::runtime_error(exc_.str()); + } const GlobalBusId bus_1 = get_bus_side_1(hvdc_id); const GlobalBusId bus_2 = get_bus_side_2(hvdc_id); const SolverBusId bus_1_solver = id_grid_to_solver[bus_1.cast_int()]; const SolverBusId bus_2_solver = id_grid_to_solver[bus_2.cast_int()]; if((bus_1_solver.cast_int() == _deactivated_bus_id) || - (bus_2_solver.cast_int() == _deactivated_bus_id)) continue; + (bus_2_solver.cast_int() == _deactivated_bus_id)){ + std::ostringstream exc_; + exc_ << "HvdcLineContainer::compute_results: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " is connected to a disconnected bus while being connected to the grid."; + throw std::runtime_error(exc_.str()); + } const real_type theta_1 = Va(bus_1_solver.cast_int()); const real_type theta_2 = Va(bus_2_solver.cast_int()); const real_type raw = p0_mw_(hvdc_id) + k_mw_per_rad_(hvdc_id) * (theta_1 - theta_2); diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index c2ac7326..a49fcc9b 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -507,8 +507,13 @@ RealMat BaseDCAlgo::get_lodf(const IntVect & from_bus, auto f_bus = from_bus(line_id); auto t_bus = to_bus(line_id); if ((f_bus == BaseConstants::_deactivated_bus_id) || (t_bus == BaseConstants::_deactivated_bus_id)){ - // element is disconnected - LODF.col(line_id).array() = std::numeric_limits::quiet_NaN(); + // element carries no DC flow (disconnected, or half-open -- see + // TwoSidesContainer_rxh_A::fillBdc's "disco on one side == disco on + // both sides" convention): "outaging" it has no effect anywhere in + // the grid, i.e. an identity column. Its own row is already all-0 + // too, since its PTDF row is 0 (fillBf_for_PTDF excludes it from Bf). + LODF(line_id, line_id) = 1.; + continue; } LODF.col(line_id).array() = PTDF.col(f_bus).array() - PTDF.col(t_bus).array(); const real_type diag_coeff = LODF(line_id, line_id); From 716c59693b35f5e87c06ffc0f532fa619d0ef210 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 8 Jul 2026 14:47:51 +0200 Subject: [PATCH 052/166] fix some other issues related to disconnected buses Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 45 ++++++++ .../tests/test_line_disco_one_side.py | 104 +++++++++++++++++- src/core/LSGrid.cpp | 37 ++++++- .../batch_algorithm/ContingencyAnalysis.cpp | 48 ++++++-- .../element_container/HvdcLineContainer.cpp | 35 +++++- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 9 +- 6 files changed, 262 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d2510ef4..31b760fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -431,6 +431,51 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `-1`, and force the DC flow to `0` whenever either side is open (matching `fillBdc`'s "disco on one side == disco on both sides" DC convention). Tested in `test_line_disco_one_side.py`. +- [FIXED] `LSGrid.get_lodf()` indexed `id_me_to_dc_solver_` directly with a branch's raw + (grid-numbering) bus id, with no status check of any kind (not even the branch's + *global* one). For a half-open branch (`keep_half_open_lines`) the open side's bus id + is the `_deactivated_bus_id` (`-1`) sentinel, and `id_me_to_dc_solver_` casts negative + indices to a huge unsigned offset before indexing its backing `std::vector`, so this + was a near-certain out-of-bounds crash, reachable from plain Python via + `init_from_pypowsybl(keep_half_open_lines=True)` -> `dc_pf(...)` -> `get_lodf()`. + `BaseDCAlgo::get_lodf()` had a second, independent bug in the same spot: its + `_deactivated_bus_id` guard set the LODF column to `NaN` but never `continue`d, so the + very next line silently overwrote that with `PTDF.col(-1)` -- again indexing with `-1`. + Both are fixed: `LSGrid.get_lodf()` now checks each branch's own per-side DC + connectivity (half-open or fully disconnected both count, matching `fillBdc`'s "disco + on one side == disco on both sides") before resolving a solver bus id, propagating the + `_deactivated_bus_id` sentinel instead of indexing with it; `BaseDCAlgo::get_lodf()` + now gives such a branch the identity treatment (row and column all `0` except a `1` on + the diagonal) and `continue`s, matching the physical fact that a branch already + carrying no DC flow has zero impact anywhere, whether it is itself "outaged" or some + other line is. Tested in `test_line_disco_one_side.py`. +- [FIXED] `ContingencyAnalysis.cpp`'s internal `check_current_violations` (feeds + `ContingencyAnalysisCPP.compute_limit_violations` / `get_violations` / `get_violations_n`, + used by the python `ContingencyAnalysis` / `SecurityAnalysis` wrappers) had the same + class of bug as the `BaseBatchSolverSynch` one above: it only checked a branch's + *global* status before resolving and indexing with each side's raw bus id (the + `_deactivated_bus_id` check ran only after that indexing had already happened). Now + each side's bus id is only resolved/indexed when that side's own status is connected + (mirroring the `BaseBatchSolverSynch` fix): an open side's voltage is substituted with + `0`, and for DC (no Kron-reduced coefficients to cancel a bogus open-end angle, unlike + AC) both sides report `0` current whenever either side is open, matching `fillBdc`'s + convention. Tested in `test_line_disco_one_side.py`. +- [HARDENING] `HvdcLineContainer::fillSbus` / `compute_results` and + `LSGrid::fill_hvdc_droop_solver_data` resolve an angle-droop ("AC emulation") HVDC + line's two converter buses using the *masked* per-side accessor (`-1` if that side is + individually disconnected) before indexing `id_grid_to_solver`/`id_me_to_solver`, with + no per-side status check of their own -- only relying on the invariant that + `LSGrid::deactivate_dcline_side1`/`_side2` and + `HvdcLineContainer::disconnect_if_not_in_main_component` always call `disable_droop` + whenever a converter is individually opened (droop across an open converter has no + physical meaning: the remote angle used by the linear P(theta) relationship is gone). + This currently always holds (no other code path can set `droop_enabled_` per-element), + so this was not a live bug, but it depended on every future half-open-creating code + path remembering to also call `disable_droop`, with no immediate feedback if one + didn't. All three functions now explicitly check both sides are connected before + resolving/indexing, and raise a `RuntimeError` instead of silently indexing with `-1` + if that invariant is ever violated. Existing HVDC test suite (`test_hvdc_*`, 35 tests) + unaffected. [0.13.1] 2026-04-21 -------------------- diff --git a/lightsim2grid/tests/test_line_disco_one_side.py b/lightsim2grid/tests/test_line_disco_one_side.py index 68452e6b..c7fa0d10 100644 --- a/lightsim2grid/tests/test_line_disco_one_side.py +++ b/lightsim2grid/tests/test_line_disco_one_side.py @@ -21,7 +21,8 @@ from lightsim2grid import LightSimBackend from lightsim2grid.network import init_from_pypowsybl -from lightsim2grid.lightsim2grid_cpp import TimeSeriesCPP, AlgorithmType +from lightsim2grid.lightsim2grid_cpp import (TimeSeriesCPP, AlgorithmType, + ContingencyAnalysisCPP, ViolationElementType) from test_match_with_pypowsybl.utils_for_slack import ( @@ -769,5 +770,106 @@ def test_batch_algo_half_open_dc(self): """TimeSeriesCPP (DC) must not return huge bogus flows for a half-open line/trafo""" self._check_batch_algo_matches_direct_pf(is_dc=True) + def test_lodf_half_open(self): + """Regression test for LSGrid::get_lodf()/BaseDCAlgo::get_lodf indexing a bus id + of -1 (the open side's `_deactivated_bus_id`) without any guard. A half-open + branch carries no DC flow at all (same convention as `fillBdc`), so "outaging" it + changes nothing anywhere in the grid: its row and column in the LODF matrix should + be the identity (0 everywhere except a 1 on the diagonal).""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + nb_bus = model.total_bus() + V0 = np.full(nb_bus, 1.0, dtype=complex) + V = model.dc_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + + LODF = model.get_lodf() + n_line = len(model.get_lines()) + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + + for idx in (li, n_line + ti): + col = LODF[:, idx] + assert col[idx] == 1., f"diagonal (col) at {idx} is {col[idx]}, expected 1." + off_diag_col = np.delete(col, idx) + assert np.all(off_diag_col == 0.), f"non-zero off-diagonal in column {idx}" + + row = LODF[idx, :] + assert row[idx] == 1., f"diagonal (row) at {idx} is {row[idx]}, expected 1." + off_diag_row = np.delete(row, idx) + # NaNs on the row are expected: they come from OTHER (unrelated) bridge/radial + # lines whose own LODF column is undefined, not from our half-open branch -- + # every non-NaN entry must still be exactly 0. + finite = off_diag_row[~np.isnan(off_diag_row)] + assert np.all(finite == 0.), f"non-zero finite off-diagonal in row {idx}" + + def _check_current_violations_half_open(self, is_dc): + """Regression test for ContingencyAnalysis.cpp::check_current_violations indexing + a bus id of -1 (the open side's `_deactivated_bus_id`) without any per-side guard + before the `_deactivated_bus_id` check (which ran too late, after the indexing + already happened). Sets an artificially tiny current limit on the half-open line + and trafo and checks the reported currents match the trusted per-branch reference + (`LSGrid.ac_pf`/`dc_pf` + `LineInfo`/`TrafoInfo`) exactly, instead of crashing or + reporting garbage.""" + n = pp_network.create_ieee118() + self._open_sides(n) + model = init_from_pypowsybl(n, gen_slack_id=self.GEN_SLACK_ID, + sort_index=False, keep_half_open_lines=True) + li = list(pp_network.create_ieee118().get_lines().index).index(self.LID) + ti = list(pp_network.create_ieee118().get_2_windings_transformers().index).index(self.TID) + n_line = len(model.get_lines()) + n_trafo = len(model.get_trafos()) + + # tiny (but not 0, to avoid the "no limit configured" early-return) limit on the + # half-open elements, huge limit (no violation) everywhere else + lim_line = np.full(n_line, 999.) + lim_line[li] = 1e-6 + lim_trafo = np.full(n_trafo, 999.) + lim_trafo[ti] = 1e-6 + model.set_line_current_limit_side1(lim_line) + model.set_line_current_limit_side2(lim_line) + model.set_trafo_current_limit_side1(lim_trafo) + model.set_trafo_current_limit_side2(lim_trafo) + + nb_bus = model.total_bus() + V0 = np.full(nb_bus, 1.0, dtype=complex) + + # reference: direct powerflow, using the already-correct per-branch compute_results + V = model.dc_pf(V0, 10, 1e-8) if is_dc else model.ac_pf(V0, 10, 1e-8) + assert V.shape[0] > 0, "powerflow diverged" + line_ref = self._line(model) + trafo_ref = self._trafo(model) + + ca = ContingencyAnalysisCPP(model, True) # compute_limit_violations=True + if is_dc: + ca.change_algorithm(AlgorithmType.DC_SparseLU) + ca.add_all_n1() + ca.compute(V0, 10, 1e-8) + violations = ca.get_violations_n() + + by_side = {(v.element_id, v.side): v.value for v in violations + if v.element_type == ViolationElementType.LINE and v.element_id == li} + by_side.update({(v.element_id, v.side): v.value for v in violations + if v.element_type == ViolationElementType.TRAFO and v.element_id == ti}) + + # side 1 of the line is the open one: 0 current, never violates (not reported) + assert (li, 1) not in by_side or np.isclose(by_side[(li, 1)], line_ref.res_a1_ka, atol=1e-9) + # side 2 of the line is energized: matches the reference exactly (this is the one + # actually expected to violate the tiny limit, for AC -- line charging current) + if not np.isclose(line_ref.res_a2_ka, 0., atol=1e-9): + assert (li, 2) in by_side, "expected side 2 of the half-open line to violate" + assert np.isclose(by_side[(li, 2)], line_ref.res_a2_ka, atol=1e-9) + # the trafo (pure series, no charging) carries 0 A on both sides: no violation + assert (ti, 1) not in by_side or np.isclose(by_side[(ti, 1)], trafo_ref.res_a1_ka, atol=1e-9) + assert (ti, 2) not in by_side or np.isclose(by_side[(ti, 2)], trafo_ref.res_a2_ka, atol=1e-9) + + def test_current_violations_half_open_ac(self): + self._check_current_violations_half_open(is_dc=False) + + def test_current_violations_half_open_dc(self): + self._check_current_violations_half_open(is_dc=True) + # TODO trafo with alpha (phase shift) # TODO FDPF powerflow too diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index b90f07b1..7676a1a8 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -434,6 +434,21 @@ void LSGrid::fill_hvdc_droop_solver_data(HvdcDroopSolverData & data, bool ac) co data.connected2.assign(nb_droop, true); for(int pos = 0; pos < nb_droop; ++pos){ const int hvdc_id = indices[pos]; + // angle-droop ("AC emulation") needs both remote angles: this must never + // happen (both call sites that can half-open an hvdc line -- + // LSGrid::deactivate_dcline_side1/2 and + // HvdcLineContainer::disconnect_if_not_in_main_component -- call + // disable_droop at the same time), but enforce it explicitly rather than + // silently indexing id_me_to_solver with the open side's bus id (-1) below. + if(!hvdc_lines_.get_connected_side_1(hvdc_id) || !hvdc_lines_.get_connected_side_2(hvdc_id)){ + std::ostringstream exc_; + exc_ << "LSGrid::fill_hvdc_droop_solver_data: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " has angle-droop enabled while half-open (one side " + "disconnected) -- this should never happen, disable_droop " + "must be called whenever a side is opened."; + throw std::runtime_error(exc_.str()); + } const GlobalBusId bus_1 = hvdc_lines_.get_bus_side_1(hvdc_id); const GlobalBusId bus_2 = hvdc_lines_.get_bus_side_2(hvdc_id); const SolverBusId bus_1_solver = id_me_to_solver[bus_1.cast_int()]; @@ -1427,15 +1442,35 @@ RealMat LSGrid::get_lodf(){ if(Bbus_dc_.size() == 0){ throw std::runtime_error("LSGrid::get_lodf: Cannot get the ptdf without having first computed a DC powerflow."); } - const size_t nb_el = powerlines_.nb() + trafos_.nb(); + const size_t n_line = powerlines_.nb(); + const size_t nb_el = n_line + trafos_.nb(); // retrieve the from_bus / to_bus from the grid GlobalBusIdVect from_bus = GlobalBusIdVect::concat(powerlines_.get_bus_id_side_1(), trafos_.get_bus_id_side_1()); GlobalBusIdVect to_bus = GlobalBusIdVect::concat(powerlines_.get_bus_id_side_2(), trafos_.get_bus_id_side_2()); + const auto & status1_line = powerlines_.get_status_side_1(); + const auto & status2_line = powerlines_.get_status_side_2(); + const auto & status1_trafo = trafos_.get_status_side_1(); + const auto & status2_trafo = trafos_.get_status_side_2(); // convert it to solver bus id IntVect from_bus_solver(nb_el); // TODO : SolverBusIdVect here IntVect to_bus_solver(nb_el); for(size_t el_id = 0; el_id < nb_el; ++el_id){ + const bool is_dc_connected = el_id < n_line + ? (status1_line[el_id] && status2_line[el_id]) + : (status1_trafo[el_id - n_line] && status2_trafo[el_id - n_line]); + if(!is_dc_connected){ + // half-open (see keep_half_open_lines) or fully disconnected: this + // branch carries no DC flow at all (TwoSidesContainer_rxh_A::fillBdc + // drops it from Bbus entirely -- "disco on one side == disco on both + // sides"), and its open/stale bus id must not index + // id_me_to_dc_solver_ -- propagate the deactivated sentinel instead, + // so BaseDCAlgo::get_lodf gives it the identity treatment (its + // "outage" changes nothing, anywhere). + from_bus_solver[el_id] = BaseConstants::_deactivated_bus_id; + to_bus_solver[el_id] = BaseConstants::_deactivated_bus_id; + continue; + } // from side GlobalBusId f_grid_bus = from_bus[el_id]; SolverBusId f_solver_bus = id_me_to_dc_solver_[f_grid_bus.cast_int()]; diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 6b836823..c6472e79 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -90,6 +90,8 @@ void check_current_violations( if(limit1.size() == 0 && limit2.size() == 0) return; // thermal limits never configured const auto & el_status = structure_data.get_status_global(); + const auto & status1 = structure_data.get_status_side_1(); + const auto & status2 = structure_data.get_status_side_2(); const GlobalBusIdVect & bus_from = structure_data.get_bus_id_side_1(); const GlobalBusIdVect & bus_to = structure_data.get_bus_id_side_2(); const std::vector & el_names = structure_data.get_names(); // empty if never set @@ -125,17 +127,36 @@ void check_current_violations( const bool has_lim2 = limit2.size() > 0 && !isnan(limit2(el_idx)); if(!has_lim1 && !has_lim2) continue; - const int bus_from_me = bus_from(static_cast(el_id)).cast_int(); - const int bus_to_me = bus_to(static_cast(el_id)).cast_int(); - const int solver_from = id_me_to_solver(bus_from_me).cast_int(); - const int solver_to = id_me_to_solver(bus_to_me).cast_int(); - if(solver_from == BaseConstants::_deactivated_bus_id || - solver_to == BaseConstants::_deactivated_bus_id) continue; + // a half-open branch (see keep_half_open_lines) has bus_id == + // _deactivated_bus_id on its open side; only resolve / index with a + // side's bus id when that side is actually connected (both sides + // treated the same way), mirroring + // BaseBatchSolverSynch::compute_amps_flows / compute_active_power_flows. + const bool s1 = status1[el_id]; + const bool s2 = status2[el_id]; + int bus_from_me = BaseConstants::_deactivated_bus_id; + int bus_to_me = BaseConstants::_deactivated_bus_id; + int solver_from = BaseConstants::_deactivated_bus_id; + int solver_to = BaseConstants::_deactivated_bus_id; + if(s1){ + bus_from_me = bus_from(static_cast(el_id)).cast_int(); + solver_from = id_me_to_solver(bus_from_me).cast_int(); + if(solver_from == BaseConstants::_deactivated_bus_id) continue; // bus not in the solver + } + if(s2){ + bus_to_me = bus_to(static_cast(el_id)).cast_int(); + solver_to = id_me_to_solver(bus_to_me).cast_int(); + if(solver_to == BaseConstants::_deactivated_bus_id) continue; + } - const cplx_type Efrom = V(solver_from); - const cplx_type Eto = V(solver_to); - const real_type v_from_kv = std::abs(Efrom) * bus_vn_kv(bus_from_me); - const real_type v_to_kv = std::abs(Eto) * bus_vn_kv(bus_to_me); + // an open side's voltage is exactly 0 (yac_eff_* is already Kron-reduced + // for whichever side is open, so this alone gives the correct AC result + // either way); the vn_kv base only has to avoid a spurious 0/0 division + // when that side isn't used (numerator forced to 0 too, see below). + const cplx_type Efrom = s1 ? V(solver_from) : cplx_type(0., 0.); + const cplx_type Eto = s2 ? V(solver_to) : cplx_type(0., 0.); + const real_type v_from_kv = s1 ? std::abs(Efrom) * bus_vn_kv(bus_from_me) : real_type(1.); + const real_type v_to_kv = s2 ? std::abs(Eto) * bus_vn_kv(bus_to_me) : real_type(1.); real_type amps1 = 0.; real_type amps2 = 0.; @@ -149,7 +170,12 @@ void check_current_violations( const cplx_type I_to = std::conj(yac_eff_22(el_idx) * Eto + yac_eff_21(el_idx) * Efrom); const cplx_type S_to = Eto * I_to; amps2 = std::abs(S_to) * sn_mva / (sqrt_3 * v_to_kv); - } else { + } else if(s1 && s2){ + // unlike yac_eff_*, ydc_11/ydc_12 are NOT Kron-reduced for a + // half-open branch: DC treats one side open as fully disconnected + // (see fillBdc: "disco on one side == disco on both sides"), so + // both sides report 0 (amps1/amps2 already initialized) rather + // than mixing ydc_ff/ydc_ft with a meaningless open-end angle. const real_type theta_from = std::arg(Efrom); const real_type theta_to = std::arg(Eto); // side 1: mirrors BaseBatchSolverSynch::compute_amps_flows exactly (incl. the diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index 9d652f20..2dfd94cb 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -379,6 +379,21 @@ void HvdcLineContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_gri for(int hvdc_id = 0; hvdc_id < nb_hvdc; ++hvdc_id){ if(!droop_enabled_[hvdc_id] || !status_global_[hvdc_id]) continue; if(status_droop_(hvdc_id) == 0) continue; // linear: theta-dependent, handled by the DC algorithm + // angle-droop ("AC emulation") needs both remote angles: this must + // never happen (both call sites that can half-open an hvdc line -- + // LSGrid::deactivate_dcline_side1/2 and + // disconnect_if_not_in_main_component -- call disable_droop at the + // same time), but enforce it explicitly rather than silently + // indexing id_grid_to_solver with the open side's bus id (-1) below. + if(!get_connected_side_1(hvdc_id) || !get_connected_side_2(hvdc_id)){ + std::ostringstream exc_; + exc_ << "HvdcLineContainer::fillSbus: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " has angle-droop enabled while half-open (one side " + "disconnected) -- this should never happen, disable_droop " + "must be called whenever a side is opened."; + throw std::runtime_error(exc_.str()); + } real_type p1_flow, p2_flow; droop_flows_mw(hvdc_id, 0., p1_flow, p2_flow); // raw unused when saturated const GlobalBusId bus_1 = get_bus_side_1(hvdc_id); @@ -419,12 +434,30 @@ void HvdcLineContainer::compute_results(const Eigen::Ref & Va, Eigen::Ref res_p_2 = get_res_p_side_2(); for(int hvdc_id = 0; hvdc_id < nb_hvdc; ++hvdc_id){ if(!droop_enabled_[hvdc_id] || !status_global_[hvdc_id]) continue; + // same invariant as HvdcLineContainer::fillSbus: a droop-enabled line + // must never be half-open (disable_droop is called wherever a side can + // be opened) -- enforce it rather than silently indexing with -1 below. + if(!get_connected_side_1(hvdc_id) || !get_connected_side_2(hvdc_id)){ + std::ostringstream exc_; + exc_ << "HvdcLineContainer::compute_results: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " has angle-droop enabled while half-open (one side " + "disconnected) -- this should never happen, disable_droop " + "must be called whenever a side is opened."; + throw std::runtime_error(exc_.str()); + } const GlobalBusId bus_1 = get_bus_side_1(hvdc_id); const GlobalBusId bus_2 = get_bus_side_2(hvdc_id); const SolverBusId bus_1_solver = id_grid_to_solver[bus_1.cast_int()]; const SolverBusId bus_2_solver = id_grid_to_solver[bus_2.cast_int()]; if((bus_1_solver.cast_int() == _deactivated_bus_id) || - (bus_2_solver.cast_int() == _deactivated_bus_id)) continue; + (bus_2_solver.cast_int() == _deactivated_bus_id)){ + std::ostringstream exc_; + exc_ << "HvdcLineContainer::compute_results: hvdc line with id "; + exc_ << hvdc_id; + exc_ << " is connected to a disconnected bus while being connected to the grid."; + throw std::runtime_error(exc_.str()); + } const real_type theta_1 = Va(bus_1_solver.cast_int()); const real_type theta_2 = Va(bus_2_solver.cast_int()); const real_type raw = p0_mw_(hvdc_id) + k_mw_per_rad_(hvdc_id) * (theta_1 - theta_2); diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index c2ac7326..a49fcc9b 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -507,8 +507,13 @@ RealMat BaseDCAlgo::get_lodf(const IntVect & from_bus, auto f_bus = from_bus(line_id); auto t_bus = to_bus(line_id); if ((f_bus == BaseConstants::_deactivated_bus_id) || (t_bus == BaseConstants::_deactivated_bus_id)){ - // element is disconnected - LODF.col(line_id).array() = std::numeric_limits::quiet_NaN(); + // element carries no DC flow (disconnected, or half-open -- see + // TwoSidesContainer_rxh_A::fillBdc's "disco on one side == disco on + // both sides" convention): "outaging" it has no effect anywhere in + // the grid, i.e. an identity column. Its own row is already all-0 + // too, since its PTDF row is 0 (fillBf_for_PTDF excludes it from Bf). + LODF(line_id, line_id) = 1.; + continue; } LODF.col(line_id).array() = PTDF.col(f_bus).array() - PTDF.col(t_bus).array(); const real_type diag_coeff = LODF(line_id, line_id); From e8c9a613ddfe9ba9958caa10fd61923e7bad8e70 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 10 Jul 2026 10:58:18 +0200 Subject: [PATCH 053/166] improve the baking function to be closer to real grid Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_bake.py | 227 +++++++++++++++++- lightsim2grid/tests/test_olf_bake.py | 159 ++++++++++++ 2 files changed, 379 insertions(+), 7 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 41c21e74..1665fabb 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -54,9 +54,22 @@ inside their limits keep voltage control -- their equation type is unchanged. Units with NaN (unlimited) reactive limits are never frozen, since NaN comparisons are False. +* Generators OLF's own voltage-control consistency checks would discard for a + reason other than "not started": too small a reactive range (default + ``reactiveRangeCheckMode``, widest ``max_q - min_q`` below 1 MVar) or an + implausible ``target_v`` (outside 0.8-1.2 pu of nominal, on buses above 20 kV). + Frozen to fixed-Q the same way as the "not started" case above, since OLF + itself never actually voltage-controlled them either. * Active-power redistribution from distributed slack / area interchange: the realized P is written back into the target P of generators and batteries (and optionally loads, if the slack was distributed on load). +* Generators OLF's own ``checkActivePowerControl`` would exclude from + slack-distribution participation (dispatched at ~0 MW, an implausible + ``max_p``, ``target_p`` outside ``[min_p, max_p]``, or a degenerate P range): + their ``activePowerControl`` extension's ``participate`` flag is set to + ``False`` (created if absent), so neither a subsequent pypowsybl OLF re-solve + nor lightsim2grid's own distributed slack -- which reads that same flag -- + puts slack mismatch back onto them. * Static var compensators whose realized reactive output sits at (or beyond) their voltage-dependent susceptance envelope (``Q(V) = b * V^2``, ``b`` in ``b_min``..``b_max``, recomputed in MVAr at the SVC's own solved terminal @@ -157,8 +170,10 @@ def bake_outer_loops( network, bake_taps: bool = True, bake_reactive_limits: bool = True, + bake_generator_voltage_control_discards: bool = True, bake_active_power: bool = True, - bake_remote_voltage_control: bool = True, + bake_active_power_control_participation: bool = True, + bake_remote_voltage_control: bool = False, balance_on_loads: bool = False, load_power_factor_constant: bool = False, keep_only_main_comp: bool=True @@ -180,9 +195,21 @@ def bake_outer_loops( input positions and disable their regulation. bake_reactive_limits Freeze generators / VSC stations that hit a Q limit to fixed-Q (PQ). + bake_generator_voltage_control_discards + Freeze generators OLF's own voltage-control consistency checks would + discard for a reason other than "not started": too small a reactive + range, or an implausible ``target_v`` (see + :func:`_bake_generator_voltage_control_discards`). Also gated by + ``bake_reactive_limits`` -- has no effect if that is off. bake_active_power Write realized active power back into generator/battery target P (and load p0/q0 if ``balance_on_loads``). + bake_active_power_control_participation + Zero out (``activePowerControl`` extension ``participate=False``) + slack-distribution participation for generators OLF's own + ``checkActivePowerControl`` would exclude (see + :func:`_bake_active_power_control_participation`). Also gated by + ``bake_active_power`` -- has no effect if that is off. bake_remote_voltage_control Rewrite remote voltage control to local control at the solved terminal voltage (see :func:`_bake_remote_voltage_control`). Needed so that @@ -206,13 +233,16 @@ def bake_outer_loops( if bake_taps: _bake_taps_and_sections(network, keep_only_main_comp) if bake_reactive_limits: - _bake_reactive_limit_switches(network, keep_only_main_comp) + _bake_reactive_limit_switches( + network, keep_only_main_comp, bake_generator_voltage_control_discards + ) if bake_active_power: _bake_active_power( network, balance_on_loads=balance_on_loads, load_power_factor_constant=load_power_factor_constant, - keep_only_main_comp=keep_only_main_comp + keep_only_main_comp=keep_only_main_comp, + bake_active_power_control_participation=bake_active_power_control_participation ) if bake_remote_voltage_control: _bake_remote_voltage_control(network, keep_only_main_comp) @@ -273,6 +303,27 @@ def _bake_taps_and_sections(network, keep_only_main_comp=True): # POWER_EPSILON_SI = 1e-4 MW (AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl). _ZERO_P_TOL = 1e-4 +# OLF's own PlausibleValues.MIN_REACTIVE_RANGE (1 MVar): with the default +# reactiveRangeCheckMode ("MAX"), a generator whose widest reactive range across its +# whole active-power range (or its fixed min_q/max_q box, for a MIN_MAX-kind generator) +# falls below this is discarded from voltage control +# (AbstractLfGenerator.checkIfReactiveRangesAreLargeEnoughForVoltageControl). +_MIN_REACTIVE_RANGE_MVAR = 1.0 + +# OLF's own defaults (LfNetworkParameters): a generator's targetV, expressed in per +# unit of its (regulated bus) nominal voltage, is discarded from voltage control if +# outside this range -- but only on buses above the nominal-voltage floor below (this +# is minNominalVoltageTargetVoltageCheck, distinct from the *realistic-voltage-check* +# floor that PARAMS_STANDARD sets to 180 kV; this one is left at its own OLF default). +_MIN_PLAUSIBLE_TARGET_V_PU = 0.8 +_MAX_PLAUSIBLE_TARGET_V_PU = 1.2 +_MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV = 20.0 + +# OLF's own plausibleActivePowerLimit default (MW): a generator whose maxP exceeds +# this is discarded from active-power (slack) participation regardless of any other +# parameter (AbstractLfGenerator.checkActivePowerControl). +_MAX_PLAUSIBLE_ACTIVE_POWER_MW = 10000.0 + def _bake_generator_not_started(network, keep_only_main_comp=True): """Freeze a voltage-regulating generator dispatched at (approximately) 0 MW, @@ -319,10 +370,94 @@ def _bake_generator_not_started(network, keep_only_main_comp=True): network.update_generators(upd) +def _generator_max_reactive_range(network, gen_index): + """OLF's own default ``reactiveRangeCheckMode`` is ``MAX``: the widest + ``max_q - min_q`` across the whole active-power range of the reactive + capability curve for a CURVE-kind generator, or simply ``max_q - min_q`` + for a MIN_MAX (fixed box) generator. Returns a ``pandas.Series`` of ranges + (MVAr), indexed like ``gen_index``, ``NaN`` for any id not found. + """ + rng = pd.Series(np.nan, index=gen_index) + if not len(gen_index): + return rng + box = network.get_generators(attributes=["reactive_limits_kind", "min_q", "max_q"]) + box = box.loc[box.index.intersection(gen_index)] + is_box = box["reactive_limits_kind"] == "MIN_MAX" + rng.loc[box.index[is_box]] = (box["max_q"] - box["min_q"])[is_box] + curve_ids = box.index[~is_box] + if len(curve_ids): + pts = network.get_reactive_capability_curve_points() + # pts is indexed on a (id, num) MultiIndex -- intersecting it directly against + # a flat Index of generator ids matches nothing, since a tuple never equals a + # bare id; filter on the first level instead. + pts = pts.loc[pts.index.get_level_values(0).isin(curve_ids)] + if len(pts): + pt_range = pts["max_q"] - pts["min_q"] + rng.update(pt_range.groupby(level=0).max()) + return rng + + +def _bake_generator_voltage_control_discards(network, keep_only_main_comp=True): + """Freeze a voltage-regulating generator to fixed-Q when PowSyBl OLF's own + consistency checks (``AbstractLfGenerator.checkVoltageControlConsistency``) + would have discarded it from voltage control for a reason other than "not + started" (handled separately by :func:`_bake_generator_not_started`, which + must run before this so its frozen generators are excluded here): + + * too small a reactive range (``checkIfReactiveRangesAreLargeEnoughForVoltageControl``, + see :func:`_generator_max_reactive_range` and ``_MIN_REACTIVE_RANGE_MVAR``); + * an implausible target voltage (``checkTargetV``, see + ``_MIN_PLAUSIBLE_TARGET_V_PU`` / ``_MAX_PLAUSIBLE_TARGET_V_PU``). + + Like ``_bake_generator_not_started``, these generators never actually reached + voltage control in the reference (outer-loop) run: OLF falls back to a fixed-Q + injection at the raw IIDM ``target_q`` (``LfGeneratorImpl.getTargetQ()``), *not* + at any computed value -- so the realized ``q`` from the reference solve already + equals that fixed output, and freezing to it here is exact. Without this, + lightsim2grid -- which has no equivalent of these OLF-specific plausibility + screens -- would treat such a generator as a normal PV bus after baking. + """ + df_bus = network.get_buses(attributes=["synchronous_component"]) + gen = network.get_generators( + attributes=["voltage_regulator_on", "target_v", "q", "voltage_level_id", "connected", "bus_id"] + ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) + reg = gen[gen["voltage_regulator_on"]] + if not len(reg): + return + + nominal_v = network.get_voltage_levels(attributes=["nominal_v"])["nominal_v"] + # approximation: uses the generator's own terminal nominal voltage, not the + # (possibly remote) regulated bus -- same base-case approximation already + # documented for _bake_remote_voltage_control. + gen_nominal_v = nominal_v.reindex(reg["voltage_level_id"]).to_numpy() + + max_range = _generator_max_reactive_range(network, reg.index).to_numpy() + too_small_range = max_range < _MIN_REACTIVE_RANGE_MVAR + + target_v_pu = reg["target_v"].to_numpy() / gen_nominal_v + implausible_v = ( + (gen_nominal_v > _MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV) + & ((target_v_pu < _MIN_PLAUSIBLE_TARGET_V_PU) | (target_v_pu > _MAX_PLAUSIBLE_TARGET_V_PU)) + ) + + mask = too_small_range | implausible_v + if not mask.any(): + return + upd = pd.DataFrame(index=reg.index[mask]) + upd["target_q"] = -reg["q"].to_numpy()[mask] + upd["voltage_regulator_on"] = False + network.update_generators(upd) + + def _bake_reactive_limit_switches( network, - keep_only_main_comp=True): + keep_only_main_comp=True, + bake_generator_voltage_control_discards=True): _bake_generator_not_started(network, keep_only_main_comp) + if bake_generator_voltage_control_discards: + _bake_generator_voltage_control_discards(network, keep_only_main_comp) df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ @@ -507,13 +642,91 @@ def _bake_remote_voltage_control(network, keep_only_main_comp=True): network.update_generators(upd) -def _bake_active_power(network, balance_on_loads, load_power_factor_constant, keep_only_main_comp=True): +def _bake_active_power_control_participation(network, gen): + """Zero out active-power (slack-distribution) participation for generators that + PowSyBl OLF's own ``AbstractLfGenerator.checkActivePowerControl`` would exclude: + + * dispatched at (approximately) zero MW (``POWER_EPSILON_SI``, regardless of + ``min_p`` -- unlike :func:`_bake_generator_not_started`, this check has no + ``min_p > 0`` guard); + * an implausible ``max_p`` (``_MAX_PLAUSIBLE_ACTIVE_POWER_MW``, always checked); + * ``target_p`` outside ``[min_target_p, max_target_p]``, or a degenerate + (``max_target_p == min_target_p``) range -- OLF's own defaults have + ``useActiveLimits`` on, so these are always checked too. ``min_target_p`` / + ``max_target_p`` default to ``min_p`` / ``max_p`` unless the generator's own + ``activePowerControl`` extension overrides them, mirroring + ``ActivePowerControlHelper``. + + ``gen`` must be the pre-bake generator frame (``p``, ``target_p``, ``min_p``, + ``max_p``), read *before* :func:`_bake_active_power` overwrites ``target_p`` + with the realized dispatch -- the discard decision is about what OLF actually + used to build the reference solve, not the post-bake value. + + Writes ``participate=False`` into the network's own ``activePowerControl`` + extension (created where absent) rather than any lightsim2grid-side state: + lightsim2grid's own ``_default_distributed_slack`` (mirroring the same OLF + rule) already reads exactly this extension's ``participate`` flag when + computing slack weights, so this single write keeps both lightsim2grid and any + subsequent pypowsybl OLF re-solve from putting slack mismatch back onto a + generator OLF itself excluded from participating. + """ + apc = network.get_extensions("activePowerControl") + min_target_p = gen["min_p"].to_numpy(copy=True) + max_target_p = gen["max_p"].to_numpy(copy=True) + if len(apc): + common = gen.index.intersection(apc.index) + if len(common): + pos = gen.index.get_indexer(common) + if "min_target_p" in apc.columns: + v = apc.loc[common, "min_target_p"].to_numpy() + ok = ~np.isnan(v) + min_target_p[pos[ok]] = v[ok] + if "max_target_p" in apc.columns: + v = apc.loc[common, "max_target_p"].to_numpy() + ok = ~np.isnan(v) + max_target_p[pos[ok]] = v[ok] + + target_p = gen["target_p"].to_numpy() + max_p = gen["max_p"].to_numpy() + + zero_target = np.abs(target_p) < _ZERO_P_TOL + maxp_not_plausible = max_p > _MAX_PLAUSIBLE_ACTIVE_POWER_MW + outside_limits = (target_p > max_target_p) | (target_p < min_target_p) + degenerate_range = (max_target_p - min_target_p) < _ZERO_P_TOL + + excluded = zero_target | maxp_not_plausible | outside_limits | degenerate_range + if not excluded.any(): + return + + excluded_ids = gen.index[excluded] + already_apc = excluded_ids.intersection(apc.index) if len(apc) else excluded_ids[:0] + new_apc = excluded_ids.difference(already_apc) + + if len(already_apc): + network.update_extensions( + "activePowerControl", pd.DataFrame({"participate": False}, index=already_apc) + ) + if len(new_apc): + network.create_extensions( + "activePowerControl", pd.DataFrame({"participate": False}, index=new_apc) + ) + + +def _bake_active_power( + network, + balance_on_loads, + load_power_factor_constant, + keep_only_main_comp=True, + bake_active_power_control_participation=True +): df_bus = network.get_buses(attributes=["synchronous_component"]) - - gen = network.get_generators(attributes=["p", "connected", "bus_id"]) + + gen = network.get_generators(attributes=["p", "target_p", "min_p", "max_p", "connected", "bus_id"]) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) if len(gen): + if bake_active_power_control_participation: + _bake_active_power_control_participation(network, gen) # result p is load convention; target_p is generator convention network.update_generators( pd.DataFrame({"target_p": -gen["p"]}, index=gen.index) diff --git a/lightsim2grid/tests/test_olf_bake.py b/lightsim2grid/tests/test_olf_bake.py index c84c2bfc..06339b0f 100644 --- a/lightsim2grid/tests/test_olf_bake.py +++ b/lightsim2grid/tests/test_olf_bake.py @@ -155,6 +155,51 @@ def four_substations(): return pp.network.create_four_substations_node_breaker_network() +def ieee14_curve_reactive_range_too_small(): + """IEEE-14 with B2-G's reactive limits replaced by a degenerate CURVE (a + fixed +/-0.3 MVAr range, well under OLF's 1 MVar plausibility floor) + instead of the default MIN_MAX box. Mirrors real generators (small + run-of-river hydro units, etc.) that OLF silently treats as PQ regardless + of ``voltage_regulator_on``. + + The range is kept comfortably nonzero (0.6 MVAr) so the *ordinary* + Q-at-limit saturation freeze (whose tolerance blows up and would + coincidentally catch an exactly-zero range) does not also fire here -- + this exercises ``_bake_generator_voltage_control_discards`` alone. It is + also the only fixture in this file with CURVE (rather than MIN_MAX) + reactive limits, exercising ``_generator_max_reactive_range``'s + curve-points code path. + """ + n = pp.network.create_ieee14() + n.create_curve_reactive_limits( + id=["B2-G", "B2-G"], p=[0.0, 100.0], min_q=[-0.3, -0.3], max_q=[0.3, 0.3] + ) + return n + + +def ieee14_curve_reactive_range_too_small_zero_target_q(): + """Same too-small (+/-0.3 MVAr) CURVE range as + ``ieee14_curve_reactive_range_too_small``, but with B2-G's raw target_q + pinned to 0 -- i.e. *inside* the tiny box -- instead of IEEE-14's default + 42.4 MVAr. Used only to isolate ``_bake_generator_voltage_control_discards`` + from the ordinary Q-at-limit saturation freeze: with a target_q outside the + box (the other fixture), that pre-existing freeze also fires on its own + (realized Q lands far outside the box either way) and would mask the flag + having any effect; with target_q inside the box, only the new too-small- + range check discards the generator, so disabling it is observable.""" + n = ieee14_curve_reactive_range_too_small() + n.update_generators(id="B2-G", target_q=0.0) + return n + + +def ieee14_implausible_target_v(): + """IEEE-14 with B2-G's target_v set far outside OLF's plausible target- + voltage window (0.8-1.2 pu of nominal): 50 kV on a 135 kV bus is ~0.37 pu.""" + n = pp.network.create_ieee14() + n.update_generators(id="B2-G", target_v=50.0) + return n + + def _olf_roundtrip_max_dev(network_factory): """Return (max |dV| kV, max |dAngle| deg) between OLF-with-loops and the baked OLF-loop-free solve. Pure pypowsybl; no lightsim2grid. This is the @@ -373,6 +418,120 @@ def test_olf_baked_reporter_shows_no_action(self): "reporter shows an outer loop acted on the baked grid:\n" + str(rep), ) + # ----------------------------------------------------------------- + # Voltage-control discards beyond Q-limit saturation: too-small + # reactive range and implausible target_v + # (_bake_generator_voltage_control_discards). + # ----------------------------------------------------------------- + def test_olf_reactive_range_too_small_frozen(self): + """A CURVE-kind generator with a sub-1-MVar reactive range is not + actually voltage-controlled by OLF: its realized Q sits far outside + the tiny +/-0.3 MVAr box the curve declares, proving OLF fell back to + the generator's raw (unconfined) target_q rather than confining it + through voltage control. Bake must freeze it to fixed-Q at that + realized q, and the baked loop-free re-solve must reproduce it.""" + n_ref = ieee14_curve_reactive_range_too_small() + lf.run_ac(n_ref, _with_loops_params()) + # not actually voltage-controlled: q falls way outside the +/-0.3 box + self.assertGreater( + abs(n_ref.get_generators(attributes=["q"]).loc["B2-G", "q"]), 1.0 + ) + + n = ieee14_curve_reactive_range_too_small() + lf.run_ac(n, _with_loops_params()) + q_ref = n.get_generators(attributes=["q"]).loc["B2-G", "q"] + bake_outer_loops(n) + g = n.get_generators(attributes=["voltage_regulator_on", "target_q"]) + self.assertFalse(g.loc["B2-G", "voltage_regulator_on"]) + self.assertLess(abs(g.loc["B2-G", "target_q"] - (-q_ref)), 1e-2) + # other generators (plain MIN_MAX, ample range) stay untouched + self.assertTrue(g.loc[["B1-G", "B3-G", "B6-G", "B8-G"], "voltage_regulator_on"].all()) + + res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) + q_redo = n.get_generators(attributes=["q"]).loc["B2-G", "q"] + self.assertLess(abs(q_redo - q_ref), 1e-2) + + def test_olf_reactive_range_too_small_flag_off(self): + """``bake_generator_voltage_control_discards=False`` leaves the + too-small-range generator regulating voltage. Uses the target_q=0 + variant so the pre-existing Q-at-limit saturation freeze -- which + would otherwise also catch this generator on its own, masking the + flag -- does not fire (see the fixture's docstring).""" + n = ieee14_curve_reactive_range_too_small_zero_target_q() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n, bake_generator_voltage_control_discards=False) + self.assertTrue( + n.get_generators(attributes=["voltage_regulator_on"]).loc["B2-G", "voltage_regulator_on"] + ) + + def test_olf_reactive_range_too_small_zero_target_q_frozen(self): + """Same as ``test_olf_reactive_range_too_small_frozen`` but with the + flag on (default): confirms the new check alone -- independent of + the ordinary saturation freeze -- discards this generator too.""" + n = ieee14_curve_reactive_range_too_small_zero_target_q() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + self.assertFalse( + n.get_generators(attributes=["voltage_regulator_on"]).loc["B2-G", "voltage_regulator_on"] + ) + + def test_olf_implausible_target_v_frozen(self): + """A generator with target_v far outside OLF's plausible window is + frozen to fixed-Q, at the realized q, the same way.""" + n_ref = ieee14_implausible_target_v() + lf.run_ac(n_ref, _with_loops_params()) + q_ref = n_ref.get_generators(attributes=["q"]).loc["B2-G", "q"] + b_ref = n_ref.get_buses(attributes=["v_mag"]) + bus_id = n_ref.get_generators(attributes=["bus_id"]).loc["B2-G", "bus_id"] + self.assertGreater(abs(b_ref.loc[bus_id, "v_mag"] - 50.0), 1.0) + + n = ieee14_implausible_target_v() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + g = n.get_generators(attributes=["voltage_regulator_on", "target_q"]) + self.assertFalse(g.loc["B2-G", "voltage_regulator_on"]) + self.assertLess(abs(g.loc["B2-G", "target_q"] - (-q_ref)), 1e-2) + self.assertTrue(g.loc[["B1-G", "B3-G", "B6-G", "B8-G"], "voltage_regulator_on"].all()) + + def test_olf_implausible_target_v_flag_off(self): + n = ieee14_implausible_target_v() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n, bake_generator_voltage_control_discards=False) + self.assertTrue( + n.get_generators(attributes=["voltage_regulator_on"]).loc["B2-G", "voltage_regulator_on"] + ) + + # ----------------------------------------------------------------- + # Active-power (slack-distribution) participation zeroing + # (_bake_active_power_control_participation). + # ----------------------------------------------------------------- + def test_olf_active_power_control_participation_excluded(self): + """B3-G/B6-G/B8-G sit at target_p=0 MW by default on IEEE-14, with + min_p < 0 (so they are NOT also frozen by the "not started" voltage + rule -- this isolates the active-power-only exclusion); B1-G gets an + implausible max_p. All four get participate=False written into the + network's own activePowerControl extension; the untouched B2-G does + not get an extension entry at all.""" + n = pp.network.create_ieee14() + n.update_generators(id="B1-G", max_p=20000.0) + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + apc = n.get_extensions("activePowerControl") + for gid in ["B1-G", "B3-G", "B6-G", "B8-G"]: + self.assertIn(gid, apc.index, f"{gid} should have an activePowerControl entry") + self.assertFalse(bool(apc.loc[gid, "participate"]), f"{gid} should not participate") + self.assertNotIn("B2-G", apc.index) + + def test_olf_active_power_control_participation_flag_off(self): + """``bake_active_power_control_participation=False`` creates no + activePowerControl extension at all.""" + n = pp.network.create_ieee14() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n, bake_active_power_control_participation=False) + apc = n.get_extensions("activePowerControl") + self.assertEqual(len(apc), 0) + # ----------------------------------------------------------------- # lightsim2grid agreement tests # ----------------------------------------------------------------- From 948574780a3ceb9f04983fed18146c16f16bdc3d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 10 Jul 2026 10:58:18 +0200 Subject: [PATCH 054/166] improve the baking function to be closer to real grid Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_bake.py | 227 +++++++++++++++++- lightsim2grid/tests/test_olf_bake.py | 159 ++++++++++++ 2 files changed, 379 insertions(+), 7 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 41c21e74..1665fabb 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -54,9 +54,22 @@ inside their limits keep voltage control -- their equation type is unchanged. Units with NaN (unlimited) reactive limits are never frozen, since NaN comparisons are False. +* Generators OLF's own voltage-control consistency checks would discard for a + reason other than "not started": too small a reactive range (default + ``reactiveRangeCheckMode``, widest ``max_q - min_q`` below 1 MVar) or an + implausible ``target_v`` (outside 0.8-1.2 pu of nominal, on buses above 20 kV). + Frozen to fixed-Q the same way as the "not started" case above, since OLF + itself never actually voltage-controlled them either. * Active-power redistribution from distributed slack / area interchange: the realized P is written back into the target P of generators and batteries (and optionally loads, if the slack was distributed on load). +* Generators OLF's own ``checkActivePowerControl`` would exclude from + slack-distribution participation (dispatched at ~0 MW, an implausible + ``max_p``, ``target_p`` outside ``[min_p, max_p]``, or a degenerate P range): + their ``activePowerControl`` extension's ``participate`` flag is set to + ``False`` (created if absent), so neither a subsequent pypowsybl OLF re-solve + nor lightsim2grid's own distributed slack -- which reads that same flag -- + puts slack mismatch back onto them. * Static var compensators whose realized reactive output sits at (or beyond) their voltage-dependent susceptance envelope (``Q(V) = b * V^2``, ``b`` in ``b_min``..``b_max``, recomputed in MVAr at the SVC's own solved terminal @@ -157,8 +170,10 @@ def bake_outer_loops( network, bake_taps: bool = True, bake_reactive_limits: bool = True, + bake_generator_voltage_control_discards: bool = True, bake_active_power: bool = True, - bake_remote_voltage_control: bool = True, + bake_active_power_control_participation: bool = True, + bake_remote_voltage_control: bool = False, balance_on_loads: bool = False, load_power_factor_constant: bool = False, keep_only_main_comp: bool=True @@ -180,9 +195,21 @@ def bake_outer_loops( input positions and disable their regulation. bake_reactive_limits Freeze generators / VSC stations that hit a Q limit to fixed-Q (PQ). + bake_generator_voltage_control_discards + Freeze generators OLF's own voltage-control consistency checks would + discard for a reason other than "not started": too small a reactive + range, or an implausible ``target_v`` (see + :func:`_bake_generator_voltage_control_discards`). Also gated by + ``bake_reactive_limits`` -- has no effect if that is off. bake_active_power Write realized active power back into generator/battery target P (and load p0/q0 if ``balance_on_loads``). + bake_active_power_control_participation + Zero out (``activePowerControl`` extension ``participate=False``) + slack-distribution participation for generators OLF's own + ``checkActivePowerControl`` would exclude (see + :func:`_bake_active_power_control_participation`). Also gated by + ``bake_active_power`` -- has no effect if that is off. bake_remote_voltage_control Rewrite remote voltage control to local control at the solved terminal voltage (see :func:`_bake_remote_voltage_control`). Needed so that @@ -206,13 +233,16 @@ def bake_outer_loops( if bake_taps: _bake_taps_and_sections(network, keep_only_main_comp) if bake_reactive_limits: - _bake_reactive_limit_switches(network, keep_only_main_comp) + _bake_reactive_limit_switches( + network, keep_only_main_comp, bake_generator_voltage_control_discards + ) if bake_active_power: _bake_active_power( network, balance_on_loads=balance_on_loads, load_power_factor_constant=load_power_factor_constant, - keep_only_main_comp=keep_only_main_comp + keep_only_main_comp=keep_only_main_comp, + bake_active_power_control_participation=bake_active_power_control_participation ) if bake_remote_voltage_control: _bake_remote_voltage_control(network, keep_only_main_comp) @@ -273,6 +303,27 @@ def _bake_taps_and_sections(network, keep_only_main_comp=True): # POWER_EPSILON_SI = 1e-4 MW (AbstractLfGenerator.checkIfGeneratorStartedForVoltageControl). _ZERO_P_TOL = 1e-4 +# OLF's own PlausibleValues.MIN_REACTIVE_RANGE (1 MVar): with the default +# reactiveRangeCheckMode ("MAX"), a generator whose widest reactive range across its +# whole active-power range (or its fixed min_q/max_q box, for a MIN_MAX-kind generator) +# falls below this is discarded from voltage control +# (AbstractLfGenerator.checkIfReactiveRangesAreLargeEnoughForVoltageControl). +_MIN_REACTIVE_RANGE_MVAR = 1.0 + +# OLF's own defaults (LfNetworkParameters): a generator's targetV, expressed in per +# unit of its (regulated bus) nominal voltage, is discarded from voltage control if +# outside this range -- but only on buses above the nominal-voltage floor below (this +# is minNominalVoltageTargetVoltageCheck, distinct from the *realistic-voltage-check* +# floor that PARAMS_STANDARD sets to 180 kV; this one is left at its own OLF default). +_MIN_PLAUSIBLE_TARGET_V_PU = 0.8 +_MAX_PLAUSIBLE_TARGET_V_PU = 1.2 +_MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV = 20.0 + +# OLF's own plausibleActivePowerLimit default (MW): a generator whose maxP exceeds +# this is discarded from active-power (slack) participation regardless of any other +# parameter (AbstractLfGenerator.checkActivePowerControl). +_MAX_PLAUSIBLE_ACTIVE_POWER_MW = 10000.0 + def _bake_generator_not_started(network, keep_only_main_comp=True): """Freeze a voltage-regulating generator dispatched at (approximately) 0 MW, @@ -319,10 +370,94 @@ def _bake_generator_not_started(network, keep_only_main_comp=True): network.update_generators(upd) +def _generator_max_reactive_range(network, gen_index): + """OLF's own default ``reactiveRangeCheckMode`` is ``MAX``: the widest + ``max_q - min_q`` across the whole active-power range of the reactive + capability curve for a CURVE-kind generator, or simply ``max_q - min_q`` + for a MIN_MAX (fixed box) generator. Returns a ``pandas.Series`` of ranges + (MVAr), indexed like ``gen_index``, ``NaN`` for any id not found. + """ + rng = pd.Series(np.nan, index=gen_index) + if not len(gen_index): + return rng + box = network.get_generators(attributes=["reactive_limits_kind", "min_q", "max_q"]) + box = box.loc[box.index.intersection(gen_index)] + is_box = box["reactive_limits_kind"] == "MIN_MAX" + rng.loc[box.index[is_box]] = (box["max_q"] - box["min_q"])[is_box] + curve_ids = box.index[~is_box] + if len(curve_ids): + pts = network.get_reactive_capability_curve_points() + # pts is indexed on a (id, num) MultiIndex -- intersecting it directly against + # a flat Index of generator ids matches nothing, since a tuple never equals a + # bare id; filter on the first level instead. + pts = pts.loc[pts.index.get_level_values(0).isin(curve_ids)] + if len(pts): + pt_range = pts["max_q"] - pts["min_q"] + rng.update(pt_range.groupby(level=0).max()) + return rng + + +def _bake_generator_voltage_control_discards(network, keep_only_main_comp=True): + """Freeze a voltage-regulating generator to fixed-Q when PowSyBl OLF's own + consistency checks (``AbstractLfGenerator.checkVoltageControlConsistency``) + would have discarded it from voltage control for a reason other than "not + started" (handled separately by :func:`_bake_generator_not_started`, which + must run before this so its frozen generators are excluded here): + + * too small a reactive range (``checkIfReactiveRangesAreLargeEnoughForVoltageControl``, + see :func:`_generator_max_reactive_range` and ``_MIN_REACTIVE_RANGE_MVAR``); + * an implausible target voltage (``checkTargetV``, see + ``_MIN_PLAUSIBLE_TARGET_V_PU`` / ``_MAX_PLAUSIBLE_TARGET_V_PU``). + + Like ``_bake_generator_not_started``, these generators never actually reached + voltage control in the reference (outer-loop) run: OLF falls back to a fixed-Q + injection at the raw IIDM ``target_q`` (``LfGeneratorImpl.getTargetQ()``), *not* + at any computed value -- so the realized ``q`` from the reference solve already + equals that fixed output, and freezing to it here is exact. Without this, + lightsim2grid -- which has no equivalent of these OLF-specific plausibility + screens -- would treat such a generator as a normal PV bus after baking. + """ + df_bus = network.get_buses(attributes=["synchronous_component"]) + gen = network.get_generators( + attributes=["voltage_regulator_on", "target_v", "q", "voltage_level_id", "connected", "bus_id"] + ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) + reg = gen[gen["voltage_regulator_on"]] + if not len(reg): + return + + nominal_v = network.get_voltage_levels(attributes=["nominal_v"])["nominal_v"] + # approximation: uses the generator's own terminal nominal voltage, not the + # (possibly remote) regulated bus -- same base-case approximation already + # documented for _bake_remote_voltage_control. + gen_nominal_v = nominal_v.reindex(reg["voltage_level_id"]).to_numpy() + + max_range = _generator_max_reactive_range(network, reg.index).to_numpy() + too_small_range = max_range < _MIN_REACTIVE_RANGE_MVAR + + target_v_pu = reg["target_v"].to_numpy() / gen_nominal_v + implausible_v = ( + (gen_nominal_v > _MIN_NOMINAL_V_FOR_TARGET_V_CHECK_KV) + & ((target_v_pu < _MIN_PLAUSIBLE_TARGET_V_PU) | (target_v_pu > _MAX_PLAUSIBLE_TARGET_V_PU)) + ) + + mask = too_small_range | implausible_v + if not mask.any(): + return + upd = pd.DataFrame(index=reg.index[mask]) + upd["target_q"] = -reg["q"].to_numpy()[mask] + upd["voltage_regulator_on"] = False + network.update_generators(upd) + + def _bake_reactive_limit_switches( network, - keep_only_main_comp=True): + keep_only_main_comp=True, + bake_generator_voltage_control_discards=True): _bake_generator_not_started(network, keep_only_main_comp) + if bake_generator_voltage_control_discards: + _bake_generator_voltage_control_discards(network, keep_only_main_comp) df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ @@ -507,13 +642,91 @@ def _bake_remote_voltage_control(network, keep_only_main_comp=True): network.update_generators(upd) -def _bake_active_power(network, balance_on_loads, load_power_factor_constant, keep_only_main_comp=True): +def _bake_active_power_control_participation(network, gen): + """Zero out active-power (slack-distribution) participation for generators that + PowSyBl OLF's own ``AbstractLfGenerator.checkActivePowerControl`` would exclude: + + * dispatched at (approximately) zero MW (``POWER_EPSILON_SI``, regardless of + ``min_p`` -- unlike :func:`_bake_generator_not_started`, this check has no + ``min_p > 0`` guard); + * an implausible ``max_p`` (``_MAX_PLAUSIBLE_ACTIVE_POWER_MW``, always checked); + * ``target_p`` outside ``[min_target_p, max_target_p]``, or a degenerate + (``max_target_p == min_target_p``) range -- OLF's own defaults have + ``useActiveLimits`` on, so these are always checked too. ``min_target_p`` / + ``max_target_p`` default to ``min_p`` / ``max_p`` unless the generator's own + ``activePowerControl`` extension overrides them, mirroring + ``ActivePowerControlHelper``. + + ``gen`` must be the pre-bake generator frame (``p``, ``target_p``, ``min_p``, + ``max_p``), read *before* :func:`_bake_active_power` overwrites ``target_p`` + with the realized dispatch -- the discard decision is about what OLF actually + used to build the reference solve, not the post-bake value. + + Writes ``participate=False`` into the network's own ``activePowerControl`` + extension (created where absent) rather than any lightsim2grid-side state: + lightsim2grid's own ``_default_distributed_slack`` (mirroring the same OLF + rule) already reads exactly this extension's ``participate`` flag when + computing slack weights, so this single write keeps both lightsim2grid and any + subsequent pypowsybl OLF re-solve from putting slack mismatch back onto a + generator OLF itself excluded from participating. + """ + apc = network.get_extensions("activePowerControl") + min_target_p = gen["min_p"].to_numpy(copy=True) + max_target_p = gen["max_p"].to_numpy(copy=True) + if len(apc): + common = gen.index.intersection(apc.index) + if len(common): + pos = gen.index.get_indexer(common) + if "min_target_p" in apc.columns: + v = apc.loc[common, "min_target_p"].to_numpy() + ok = ~np.isnan(v) + min_target_p[pos[ok]] = v[ok] + if "max_target_p" in apc.columns: + v = apc.loc[common, "max_target_p"].to_numpy() + ok = ~np.isnan(v) + max_target_p[pos[ok]] = v[ok] + + target_p = gen["target_p"].to_numpy() + max_p = gen["max_p"].to_numpy() + + zero_target = np.abs(target_p) < _ZERO_P_TOL + maxp_not_plausible = max_p > _MAX_PLAUSIBLE_ACTIVE_POWER_MW + outside_limits = (target_p > max_target_p) | (target_p < min_target_p) + degenerate_range = (max_target_p - min_target_p) < _ZERO_P_TOL + + excluded = zero_target | maxp_not_plausible | outside_limits | degenerate_range + if not excluded.any(): + return + + excluded_ids = gen.index[excluded] + already_apc = excluded_ids.intersection(apc.index) if len(apc) else excluded_ids[:0] + new_apc = excluded_ids.difference(already_apc) + + if len(already_apc): + network.update_extensions( + "activePowerControl", pd.DataFrame({"participate": False}, index=already_apc) + ) + if len(new_apc): + network.create_extensions( + "activePowerControl", pd.DataFrame({"participate": False}, index=new_apc) + ) + + +def _bake_active_power( + network, + balance_on_loads, + load_power_factor_constant, + keep_only_main_comp=True, + bake_active_power_control_participation=True +): df_bus = network.get_buses(attributes=["synchronous_component"]) - - gen = network.get_generators(attributes=["p", "connected", "bus_id"]) + + gen = network.get_generators(attributes=["p", "target_p", "min_p", "max_p", "connected", "bus_id"]) if keep_only_main_comp: gen = _keep_only_main_comp(gen, df_bus) if len(gen): + if bake_active_power_control_participation: + _bake_active_power_control_participation(network, gen) # result p is load convention; target_p is generator convention network.update_generators( pd.DataFrame({"target_p": -gen["p"]}, index=gen.index) diff --git a/lightsim2grid/tests/test_olf_bake.py b/lightsim2grid/tests/test_olf_bake.py index c84c2bfc..06339b0f 100644 --- a/lightsim2grid/tests/test_olf_bake.py +++ b/lightsim2grid/tests/test_olf_bake.py @@ -155,6 +155,51 @@ def four_substations(): return pp.network.create_four_substations_node_breaker_network() +def ieee14_curve_reactive_range_too_small(): + """IEEE-14 with B2-G's reactive limits replaced by a degenerate CURVE (a + fixed +/-0.3 MVAr range, well under OLF's 1 MVar plausibility floor) + instead of the default MIN_MAX box. Mirrors real generators (small + run-of-river hydro units, etc.) that OLF silently treats as PQ regardless + of ``voltage_regulator_on``. + + The range is kept comfortably nonzero (0.6 MVAr) so the *ordinary* + Q-at-limit saturation freeze (whose tolerance blows up and would + coincidentally catch an exactly-zero range) does not also fire here -- + this exercises ``_bake_generator_voltage_control_discards`` alone. It is + also the only fixture in this file with CURVE (rather than MIN_MAX) + reactive limits, exercising ``_generator_max_reactive_range``'s + curve-points code path. + """ + n = pp.network.create_ieee14() + n.create_curve_reactive_limits( + id=["B2-G", "B2-G"], p=[0.0, 100.0], min_q=[-0.3, -0.3], max_q=[0.3, 0.3] + ) + return n + + +def ieee14_curve_reactive_range_too_small_zero_target_q(): + """Same too-small (+/-0.3 MVAr) CURVE range as + ``ieee14_curve_reactive_range_too_small``, but with B2-G's raw target_q + pinned to 0 -- i.e. *inside* the tiny box -- instead of IEEE-14's default + 42.4 MVAr. Used only to isolate ``_bake_generator_voltage_control_discards`` + from the ordinary Q-at-limit saturation freeze: with a target_q outside the + box (the other fixture), that pre-existing freeze also fires on its own + (realized Q lands far outside the box either way) and would mask the flag + having any effect; with target_q inside the box, only the new too-small- + range check discards the generator, so disabling it is observable.""" + n = ieee14_curve_reactive_range_too_small() + n.update_generators(id="B2-G", target_q=0.0) + return n + + +def ieee14_implausible_target_v(): + """IEEE-14 with B2-G's target_v set far outside OLF's plausible target- + voltage window (0.8-1.2 pu of nominal): 50 kV on a 135 kV bus is ~0.37 pu.""" + n = pp.network.create_ieee14() + n.update_generators(id="B2-G", target_v=50.0) + return n + + def _olf_roundtrip_max_dev(network_factory): """Return (max |dV| kV, max |dAngle| deg) between OLF-with-loops and the baked OLF-loop-free solve. Pure pypowsybl; no lightsim2grid. This is the @@ -373,6 +418,120 @@ def test_olf_baked_reporter_shows_no_action(self): "reporter shows an outer loop acted on the baked grid:\n" + str(rep), ) + # ----------------------------------------------------------------- + # Voltage-control discards beyond Q-limit saturation: too-small + # reactive range and implausible target_v + # (_bake_generator_voltage_control_discards). + # ----------------------------------------------------------------- + def test_olf_reactive_range_too_small_frozen(self): + """A CURVE-kind generator with a sub-1-MVar reactive range is not + actually voltage-controlled by OLF: its realized Q sits far outside + the tiny +/-0.3 MVAr box the curve declares, proving OLF fell back to + the generator's raw (unconfined) target_q rather than confining it + through voltage control. Bake must freeze it to fixed-Q at that + realized q, and the baked loop-free re-solve must reproduce it.""" + n_ref = ieee14_curve_reactive_range_too_small() + lf.run_ac(n_ref, _with_loops_params()) + # not actually voltage-controlled: q falls way outside the +/-0.3 box + self.assertGreater( + abs(n_ref.get_generators(attributes=["q"]).loc["B2-G", "q"]), 1.0 + ) + + n = ieee14_curve_reactive_range_too_small() + lf.run_ac(n, _with_loops_params()) + q_ref = n.get_generators(attributes=["q"]).loc["B2-G", "q"] + bake_outer_loops(n) + g = n.get_generators(attributes=["voltage_regulator_on", "target_q"]) + self.assertFalse(g.loc["B2-G", "voltage_regulator_on"]) + self.assertLess(abs(g.loc["B2-G", "target_q"] - (-q_ref)), 1e-2) + # other generators (plain MIN_MAX, ample range) stay untouched + self.assertTrue(g.loc[["B1-G", "B3-G", "B6-G", "B8-G"], "voltage_regulator_on"].all()) + + res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) + q_redo = n.get_generators(attributes=["q"]).loc["B2-G", "q"] + self.assertLess(abs(q_redo - q_ref), 1e-2) + + def test_olf_reactive_range_too_small_flag_off(self): + """``bake_generator_voltage_control_discards=False`` leaves the + too-small-range generator regulating voltage. Uses the target_q=0 + variant so the pre-existing Q-at-limit saturation freeze -- which + would otherwise also catch this generator on its own, masking the + flag -- does not fire (see the fixture's docstring).""" + n = ieee14_curve_reactive_range_too_small_zero_target_q() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n, bake_generator_voltage_control_discards=False) + self.assertTrue( + n.get_generators(attributes=["voltage_regulator_on"]).loc["B2-G", "voltage_regulator_on"] + ) + + def test_olf_reactive_range_too_small_zero_target_q_frozen(self): + """Same as ``test_olf_reactive_range_too_small_frozen`` but with the + flag on (default): confirms the new check alone -- independent of + the ordinary saturation freeze -- discards this generator too.""" + n = ieee14_curve_reactive_range_too_small_zero_target_q() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + self.assertFalse( + n.get_generators(attributes=["voltage_regulator_on"]).loc["B2-G", "voltage_regulator_on"] + ) + + def test_olf_implausible_target_v_frozen(self): + """A generator with target_v far outside OLF's plausible window is + frozen to fixed-Q, at the realized q, the same way.""" + n_ref = ieee14_implausible_target_v() + lf.run_ac(n_ref, _with_loops_params()) + q_ref = n_ref.get_generators(attributes=["q"]).loc["B2-G", "q"] + b_ref = n_ref.get_buses(attributes=["v_mag"]) + bus_id = n_ref.get_generators(attributes=["bus_id"]).loc["B2-G", "bus_id"] + self.assertGreater(abs(b_ref.loc[bus_id, "v_mag"] - 50.0), 1.0) + + n = ieee14_implausible_target_v() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + g = n.get_generators(attributes=["voltage_regulator_on", "target_q"]) + self.assertFalse(g.loc["B2-G", "voltage_regulator_on"]) + self.assertLess(abs(g.loc["B2-G", "target_q"] - (-q_ref)), 1e-2) + self.assertTrue(g.loc[["B1-G", "B3-G", "B6-G", "B8-G"], "voltage_regulator_on"].all()) + + def test_olf_implausible_target_v_flag_off(self): + n = ieee14_implausible_target_v() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n, bake_generator_voltage_control_discards=False) + self.assertTrue( + n.get_generators(attributes=["voltage_regulator_on"]).loc["B2-G", "voltage_regulator_on"] + ) + + # ----------------------------------------------------------------- + # Active-power (slack-distribution) participation zeroing + # (_bake_active_power_control_participation). + # ----------------------------------------------------------------- + def test_olf_active_power_control_participation_excluded(self): + """B3-G/B6-G/B8-G sit at target_p=0 MW by default on IEEE-14, with + min_p < 0 (so they are NOT also frozen by the "not started" voltage + rule -- this isolates the active-power-only exclusion); B1-G gets an + implausible max_p. All four get participate=False written into the + network's own activePowerControl extension; the untouched B2-G does + not get an extension entry at all.""" + n = pp.network.create_ieee14() + n.update_generators(id="B1-G", max_p=20000.0) + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n) + apc = n.get_extensions("activePowerControl") + for gid in ["B1-G", "B3-G", "B6-G", "B8-G"]: + self.assertIn(gid, apc.index, f"{gid} should have an activePowerControl entry") + self.assertFalse(bool(apc.loc[gid, "participate"]), f"{gid} should not participate") + self.assertNotIn("B2-G", apc.index) + + def test_olf_active_power_control_participation_flag_off(self): + """``bake_active_power_control_participation=False`` creates no + activePowerControl extension at all.""" + n = pp.network.create_ieee14() + lf.run_ac(n, _with_loops_params()) + bake_outer_loops(n, bake_active_power_control_participation=False) + apc = n.get_extensions("activePowerControl") + self.assertEqual(len(apc), 0) + # ----------------------------------------------------------------- # lightsim2grid agreement tests # ----------------------------------------------------------------- From 752ca0eeadc4712e8282858662786c8649a92eb0 Mon Sep 17 00:00:00 2001 From: Benjamin DONNOT Date: Fri, 10 Jul 2026 21:52:50 +0200 Subject: [PATCH 055/166] fix some broken tests Signed-off-by: Benjamin DONNOT --- .../network/from_pypowsybl/_olf_bake.py | 13 ++-- lightsim2grid/tests/test_DCSecurityAnlysis.py | 69 ++++++++++++++++++- .../tests/test_voltage_control_pypowsybl.py | 2 +- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 1665fabb..297e80d0 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -113,12 +113,15 @@ def _q_limit_tol(qmin, qmax): - rng = np.asarray(qmax) - np.asarray(qmin) # An unset reactive-limit box defaults to +/-Double.MAX in pypowsybl (an - # "unbounded" sentinel, not a real 3.6e308 MVAr range): a relative tolerance on - # that would swallow every generator. Treat absurdly large / non-finite ranges - # as "no limit" -- no relative bonus, just the absolute floor -- rather than - # let them dominate the max(). + # "unbounded" sentinel, not a real 3.6e308 MVAr range): subtracting the two + # overflows to +inf, which is exactly the "no limit" case filtered out below -- + # expected and already handled, so silence the resulting RuntimeWarning. + with np.errstate(over="ignore"): + rng = np.asarray(qmax) - np.asarray(qmin) + # A relative tolerance on that overflowed/absurd range would swallow every + # generator. Treat absurdly large / non-finite ranges as "no limit" -- no + # relative bonus, just the absolute floor -- rather than let them dominate max(). rng = np.where(np.isfinite(rng) & (rng < 1e6), rng, 0.0) return np.maximum(_Q_LIMIT_TOL_ABS, _Q_LIMIT_TOL_REL * rng) diff --git a/lightsim2grid/tests/test_DCSecurityAnlysis.py b/lightsim2grid/tests/test_DCSecurityAnlysis.py index eaaa3569..c8c07c5c 100644 --- a/lightsim2grid/tests/test_DCSecurityAnlysis.py +++ b/lightsim2grid/tests/test_DCSecurityAnlysis.py @@ -332,7 +332,19 @@ def test_compare_lodf(self, act=None): den = (np.ones((nbr, 1)) * h.T * -1 + 1.) with np.errstate(divide='ignore', invalid='ignore'): LODF_pypower = (H / den) - update_LODF_diag(LODF_pypower) + update_LODF_diag(LODF_pypower) + + # lines / trafos already out of service in `gridmodel` carry no flow, so "outaging" + # them changes nothing elsewhere: lightsim2grid represents this as an identity column + # (see BaseDCAlgo::get_lodf). The pypower-style formula above is unaware of this (it + # builds f_/t_ from the topological bus id regardless of connection status), so its + # column for such an element is generally non-zero garbage. Align it with lightsim2grid's + # convention before comparing. + already_disco = np.concatenate((~np.array(gridmodel.get_lines_status()), + ~np.array(gridmodel.get_trafo_status()))) + LODF_pypower[:, already_disco] = 0. + LODF_pypower[already_disco, already_disco] = 1. + # nan and inf etc are handled below isfinite_pypower = np.isfinite(LODF_pypower) isfinite_ls = np.isfinite(LODF_mat) @@ -369,6 +381,61 @@ def test_compare_lodf(self, act=None): assert np.abs(por_lodf[has_conv] - res_p1[has_conv]).max() <= 1e-6 + def test_lodf_formula_flow_reconstruction_deact_line(self): + """test that the LODF formula used to reconstruct post-contingency flows + (flow_after = flow_before + LODF[:, k] * flow_before[k]) is still correct + when a line was already disconnected *before* the LODF is computed: + - flows reconstructed for contingencies unrelated to the already-off line + must match a direct DC powerflow (in particular the already-off line + must stay at 0 flow, before and after the extra contingency) + - "outaging" the already-off line itself must be a no-op (identity) + """ + obs = self._aux_do_reset() + act = self.env.action_space({"set_line_status": [(1, -1)]}) + obs, reward, done, info = self.env.step(act) + assert not done, "Unable to do the action, powerflow diverges" + assert not info["is_ambiguous"] + assert not info["is_illegal"] + assert not info["is_illegal_reco"] + + gridmodel = self.env.backend._grid.copy() + gridmodel.change_algorithm(AlgorithmType.DC_SparseLU) + gridmodel.dc_pf(1. * self.env.backend._debug_Vdc, 10, 1e-7) + lor_p, *_ = gridmodel.get_line_res1() + tor_p, *_ = gridmodel.get_trafo_res1() + flow_before = np.concatenate((lor_p, tor_p)) + nb_real_line = len(lor_p) + LODF_mat = 1. * gridmodel.get_lodf() + + # sanity: the already-disconnected line carries no flow + assert abs(flow_before[1]) <= 1e-6 + + # contingencies unrelated to the already-disconnected line 1 (all converge, see setUp) + for l_id in [2, 4, 7, 10, 15, 19]: + flow_after_formula = flow_before + LODF_mat[:, l_id] * flow_before[l_id] + + gridmodel_tmp = gridmodel.copy() + if l_id < nb_real_line: + gridmodel_tmp.deactivate_powerline(l_id) + else: + gridmodel_tmp.deactivate_trafo(l_id - nb_real_line) + res_tmp = gridmodel_tmp.dc_pf(1. * self.env.backend._debug_Vdc, 10, 1e-7) + assert res_tmp.shape[0] != 0, f"powerflow should converge for contingency {l_id}" + lor_tmp, *_ = gridmodel_tmp.get_line_res1() + tor_tmp, *_ = gridmodel_tmp.get_trafo_res1() + flow_after_ref = np.concatenate((lor_tmp, tor_tmp)) + + assert np.abs(flow_after_formula - flow_after_ref).max() <= 1e-6, \ + f"LODF-reconstructed flows wrong for contingency {l_id}: {np.abs(flow_after_formula - flow_after_ref)}" + # the already-disconnected line must still carry 0 flow, both in the + # reconstructed and the reference flows + assert abs(flow_after_formula[1]) <= 1e-6 + assert abs(flow_after_ref[1]) <= 1e-6 + + # outaging the already-disconnected line itself is a no-op: it changes nothing + flow_after_formula = flow_before + LODF_mat[:, 1] * flow_before[1] + assert np.abs(flow_after_formula - flow_before).max() <= 1e-6 + def test_compare_lodf_topo(self): self.test_compare_lodf(act=self.env.action_space({"set_bus": {"substations_id": [(1, (1, 2, 1, 2, 1, 2))]}})) diff --git a/lightsim2grid/tests/test_voltage_control_pypowsybl.py b/lightsim2grid/tests/test_voltage_control_pypowsybl.py index adc2369c..057ab8ce 100644 --- a/lightsim2grid/tests/test_voltage_control_pypowsybl.py +++ b/lightsim2grid/tests/test_voltage_control_pypowsybl.py @@ -220,7 +220,7 @@ def test_remote_gen_on_slack_baked(self): n.per_unit = False ref = pp.loadflow.run_ac(n, parameters=self._params("VL1")) self.assertEqual(ref[0].status, pp.loadflow.ComponentStatus.CONVERGED) - bake_outer_loops(n) + bake_outer_loops(n, bake_remote_voltage_control=True) # G1 is now a LOCAL controller of its own terminal self.assertEqual(n.get_generators().loc["G1", "regulated_element_id"], "G1") _, V = self._run_ls(n, "G1") # used to raise; now supported via baking From 131aca7876c66065e77ed95dd6e03a3db3905ab3 Mon Sep 17 00:00:00 2001 From: Benjamin DONNOT Date: Fri, 10 Jul 2026 21:52:50 +0200 Subject: [PATCH 056/166] fix some broken tests Signed-off-by: Benjamin DONNOT --- .../network/from_pypowsybl/_olf_bake.py | 13 ++-- lightsim2grid/tests/test_DCSecurityAnlysis.py | 69 ++++++++++++++++++- .../tests/test_voltage_control_pypowsybl.py | 2 +- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 1665fabb..297e80d0 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -113,12 +113,15 @@ def _q_limit_tol(qmin, qmax): - rng = np.asarray(qmax) - np.asarray(qmin) # An unset reactive-limit box defaults to +/-Double.MAX in pypowsybl (an - # "unbounded" sentinel, not a real 3.6e308 MVAr range): a relative tolerance on - # that would swallow every generator. Treat absurdly large / non-finite ranges - # as "no limit" -- no relative bonus, just the absolute floor -- rather than - # let them dominate the max(). + # "unbounded" sentinel, not a real 3.6e308 MVAr range): subtracting the two + # overflows to +inf, which is exactly the "no limit" case filtered out below -- + # expected and already handled, so silence the resulting RuntimeWarning. + with np.errstate(over="ignore"): + rng = np.asarray(qmax) - np.asarray(qmin) + # A relative tolerance on that overflowed/absurd range would swallow every + # generator. Treat absurdly large / non-finite ranges as "no limit" -- no + # relative bonus, just the absolute floor -- rather than let them dominate max(). rng = np.where(np.isfinite(rng) & (rng < 1e6), rng, 0.0) return np.maximum(_Q_LIMIT_TOL_ABS, _Q_LIMIT_TOL_REL * rng) diff --git a/lightsim2grid/tests/test_DCSecurityAnlysis.py b/lightsim2grid/tests/test_DCSecurityAnlysis.py index eaaa3569..c8c07c5c 100644 --- a/lightsim2grid/tests/test_DCSecurityAnlysis.py +++ b/lightsim2grid/tests/test_DCSecurityAnlysis.py @@ -332,7 +332,19 @@ def test_compare_lodf(self, act=None): den = (np.ones((nbr, 1)) * h.T * -1 + 1.) with np.errstate(divide='ignore', invalid='ignore'): LODF_pypower = (H / den) - update_LODF_diag(LODF_pypower) + update_LODF_diag(LODF_pypower) + + # lines / trafos already out of service in `gridmodel` carry no flow, so "outaging" + # them changes nothing elsewhere: lightsim2grid represents this as an identity column + # (see BaseDCAlgo::get_lodf). The pypower-style formula above is unaware of this (it + # builds f_/t_ from the topological bus id regardless of connection status), so its + # column for such an element is generally non-zero garbage. Align it with lightsim2grid's + # convention before comparing. + already_disco = np.concatenate((~np.array(gridmodel.get_lines_status()), + ~np.array(gridmodel.get_trafo_status()))) + LODF_pypower[:, already_disco] = 0. + LODF_pypower[already_disco, already_disco] = 1. + # nan and inf etc are handled below isfinite_pypower = np.isfinite(LODF_pypower) isfinite_ls = np.isfinite(LODF_mat) @@ -369,6 +381,61 @@ def test_compare_lodf(self, act=None): assert np.abs(por_lodf[has_conv] - res_p1[has_conv]).max() <= 1e-6 + def test_lodf_formula_flow_reconstruction_deact_line(self): + """test that the LODF formula used to reconstruct post-contingency flows + (flow_after = flow_before + LODF[:, k] * flow_before[k]) is still correct + when a line was already disconnected *before* the LODF is computed: + - flows reconstructed for contingencies unrelated to the already-off line + must match a direct DC powerflow (in particular the already-off line + must stay at 0 flow, before and after the extra contingency) + - "outaging" the already-off line itself must be a no-op (identity) + """ + obs = self._aux_do_reset() + act = self.env.action_space({"set_line_status": [(1, -1)]}) + obs, reward, done, info = self.env.step(act) + assert not done, "Unable to do the action, powerflow diverges" + assert not info["is_ambiguous"] + assert not info["is_illegal"] + assert not info["is_illegal_reco"] + + gridmodel = self.env.backend._grid.copy() + gridmodel.change_algorithm(AlgorithmType.DC_SparseLU) + gridmodel.dc_pf(1. * self.env.backend._debug_Vdc, 10, 1e-7) + lor_p, *_ = gridmodel.get_line_res1() + tor_p, *_ = gridmodel.get_trafo_res1() + flow_before = np.concatenate((lor_p, tor_p)) + nb_real_line = len(lor_p) + LODF_mat = 1. * gridmodel.get_lodf() + + # sanity: the already-disconnected line carries no flow + assert abs(flow_before[1]) <= 1e-6 + + # contingencies unrelated to the already-disconnected line 1 (all converge, see setUp) + for l_id in [2, 4, 7, 10, 15, 19]: + flow_after_formula = flow_before + LODF_mat[:, l_id] * flow_before[l_id] + + gridmodel_tmp = gridmodel.copy() + if l_id < nb_real_line: + gridmodel_tmp.deactivate_powerline(l_id) + else: + gridmodel_tmp.deactivate_trafo(l_id - nb_real_line) + res_tmp = gridmodel_tmp.dc_pf(1. * self.env.backend._debug_Vdc, 10, 1e-7) + assert res_tmp.shape[0] != 0, f"powerflow should converge for contingency {l_id}" + lor_tmp, *_ = gridmodel_tmp.get_line_res1() + tor_tmp, *_ = gridmodel_tmp.get_trafo_res1() + flow_after_ref = np.concatenate((lor_tmp, tor_tmp)) + + assert np.abs(flow_after_formula - flow_after_ref).max() <= 1e-6, \ + f"LODF-reconstructed flows wrong for contingency {l_id}: {np.abs(flow_after_formula - flow_after_ref)}" + # the already-disconnected line must still carry 0 flow, both in the + # reconstructed and the reference flows + assert abs(flow_after_formula[1]) <= 1e-6 + assert abs(flow_after_ref[1]) <= 1e-6 + + # outaging the already-disconnected line itself is a no-op: it changes nothing + flow_after_formula = flow_before + LODF_mat[:, 1] * flow_before[1] + assert np.abs(flow_after_formula - flow_before).max() <= 1e-6 + def test_compare_lodf_topo(self): self.test_compare_lodf(act=self.env.action_space({"set_bus": {"substations_id": [(1, (1, 2, 1, 2, 1, 2))]}})) diff --git a/lightsim2grid/tests/test_voltage_control_pypowsybl.py b/lightsim2grid/tests/test_voltage_control_pypowsybl.py index adc2369c..057ab8ce 100644 --- a/lightsim2grid/tests/test_voltage_control_pypowsybl.py +++ b/lightsim2grid/tests/test_voltage_control_pypowsybl.py @@ -220,7 +220,7 @@ def test_remote_gen_on_slack_baked(self): n.per_unit = False ref = pp.loadflow.run_ac(n, parameters=self._params("VL1")) self.assertEqual(ref[0].status, pp.loadflow.ComponentStatus.CONVERGED) - bake_outer_loops(n) + bake_outer_loops(n, bake_remote_voltage_control=True) # G1 is now a LOCAL controller of its own terminal self.assertEqual(n.get_generators().loc["G1", "regulated_element_id"], "G1") _, V = self._run_ls(n, "G1") # used to raise; now supported via baking From ca847c8a10cd3f8a46861e2dbfeb4225b680f673 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:50:25 +0000 Subject: [PATCH 057/166] Fix out-of-bounds memory access from Python-exposed bindings Several Python-exposed functions passed an element id or array straight to unchecked C++ indexing (std::vector::operator[] / Eigen operator()/.col(), whose bounds asserts are compiled out under NDEBUG). A negative id wraps to a huge size_t, and an out-of-range id reads or writes past the container, so a plain Python call such as grid.deactivate_load(-1) or grid.change_ratio_trafo(9999, 1.0) could corrupt or leak process memory. Add O(1) validation on the entry path, reusing the existing GenericContainer::_check_in_range / check_size helpers (the same idiom already used by the safe change_p/change_q/change_v and change_bus paths): - OneSideContainer::deactivate/reactivate/change_bus: validate el_id before dispatching to the _method hook (which indexed status_/bus_id_ before the _generic_* validator ran). This also covers the two-sided line/trafo/dcline containers, which delegate to these final methods first. - GeneratorContainer::update_slack_weights_by_id: validate every caller id before it indexes status_/maybe_slack_bus/target_p_mw_ (was an OOB write). - GeneratorContainer::set_regulated_bus / LSGrid::set_gen_regulated_bus: validate gen_id and the stored bus_id (was an OOB write + unvalidated bus). - TrafoContainer::change_ratio/change_shift: validate el_id (was an OOB write). - HvdcLineContainer::get_status_droop: validate hvdc_id (its setter already did). - LSGrid::update_continuous_values: require new_values and has_changed to have the same length before indexing new_values by has_changed's length. - OneSideContainer::set_pos_topo_vect/set_subid: check_size before assignment. - TimeSeries::compute_Vs: validate the gen_p/sgen_p/load_p/load_q matrix column counts against the grid element counts and that all share the same row count, before fill_SBus_* indexes user columns by the grid element ids. Out-of-range ids now raise IndexError/RuntimeError instead of segfaulting; valid calls and the powerflow are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- src/core/LSGrid.hpp | 20 +++++++++++++- src/core/batch_algorithm/TimeSeries.cpp | 27 +++++++++++++++++++ .../element_container/GeneratorContainer.cpp | 7 ++++- .../element_container/GeneratorContainer.hpp | 4 +++ .../element_container/HvdcLineContainer.hpp | 7 ++++- .../element_container/OneSideContainer.hpp | 10 +++++++ src/core/element_container/TrafoContainer.hpp | 4 +++ 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 663b7d95..c7185aac 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1020,7 +1020,17 @@ class LS2G_API LSGrid final * voltage control). `bus_id` == the generator's own bus restores ordinary * local PV control. */ - void set_gen_regulated_bus(int gen_id, int bus_id) {generators_.set_regulated_bus(gen_id, bus_id, algo_controler_); } + void set_gen_regulated_bus(int gen_id, int bus_id) { + // bus_id is stored verbatim and later dereferenced during solve, so reject + // an out-of-range bus here (gen_id is validated inside set_regulated_bus). + if(bus_id < 0 || bus_id >= static_cast(total_bus())){ + std::ostringstream exc_; + exc_ << "LSGrid::set_gen_regulated_bus: regulated bus id should be >= 0 and < "; + exc_ << total_bus() << " (number of buses on the grid), you provided " << bus_id << "."; + throw std::out_of_range(exc_.str()); + } + generators_.set_regulated_bus(gen_id, bus_id, algo_controler_); + } /** * Change the bus on the dc line "side 1" dcline_id. * @@ -1904,6 +1914,14 @@ class LS2G_API LSGrid final Eigen::Ref > & new_values, T fun) { + // new_values is indexed by has_changed's length below; a shorter new_values + // would over-read the buffer (Eigen operator[] is unchecked in release). + if(new_values.rows() != has_changed.rows()){ + std::ostringstream exc_; + exc_ << "LSGrid::update_continuous_values: 'has_changed' (size " << has_changed.rows(); + exc_ << ") and 'new_values' (size " << new_values.rows() << ") must have the same size."; + throw std::runtime_error(exc_.str()); + } for(int el_id = 0; el_id < has_changed.rows(); ++el_id) { if(has_changed(el_id)) diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index 2156bc43..f26481c2 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -43,6 +43,33 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, const auto & s_generators = _grid_model.get_static_generators_as_data(); const auto & loads = _grid_model.get_loads_as_data(); + // Validate the shapes of the caller-supplied matrices before fill_SBus_* uses them. + // fill_SBus_* iterates el_id up to the grid's element count and reads + // temporal_data.col(el_id) (unchecked Eigen .col()), so a matrix with too few + // columns over-reads; and _Sbuses is sized with gen_p.rows(), so mismatched row + // counts turn the `Sbuses.col(...) += tmp` into an out-of-bounds read/write. + const auto check_mat = [nb_steps](Eigen::Ref mat, Eigen::Index nb_expected_cols, + const std::string & name){ + if(static_cast(mat.rows()) != nb_steps){ + std::ostringstream exc_; + exc_ << "TimeSeries::compute_Vs: '" << name << "' has " << mat.rows() + << " rows (time steps) while 'gen_p' has " << nb_steps + << ". All injection matrices must share the same number of rows."; + throw std::runtime_error(exc_.str()); + } + if(mat.cols() != nb_expected_cols){ + std::ostringstream exc_; + exc_ << "TimeSeries::compute_Vs: '" << name << "' has " << mat.cols() + << " columns while the grid counts " << nb_expected_cols + << " such elements. The number of columns must match the number of elements."; + throw std::runtime_error(exc_.str()); + } + }; + check_mat(gen_p, generators.nb(), "gen_p"); + check_mat(sgen_p, s_generators.nb(), "sgen_p"); + check_mat(load_p, loads.nb(), "load_p"); + check_mat(load_q, loads.nb(), "load_q"); + // now build the Sbus _Sbuses = CplxMat::Zero(nb_steps, nb_buses_solver_); diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 77fb779f..33522c39 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -564,11 +564,16 @@ void GeneratorContainer::update_slack_weights_by_id( int nb_gen = nb(); std::vector maybe_slack_bus(nb_gen, false); + // validate every caller-supplied id before it is used to index status_ / + // maybe_slack_bus / target_p_mw_ below (raw operator[] / Eigen operator() are + // unchecked, and a negative id would wrap to a huge size_t -> OOB write). + for(int gen_id : gen_slack_id) _check_in_range(gen_id, status_, "update_slack_weights_by_id"); + // find which generators can be slack real_type total_target_p = 0.; for(int gen_id : gen_slack_id) { - if(status_[gen_id]) + if(status_[gen_id]) { maybe_slack_bus[gen_id] = true; total_target_p += abs(target_p_mw_(gen_id)); diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 623b3c7c..d4ebc908 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -213,6 +213,10 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter return regulated_bus_id_(gen_id) != bus_id_(gen_id).cast_int(); } void set_regulated_bus(int gen_id, int bus_id, DualAlgoControl & solver_control){ + // gen_id indexes regulated_bus_id_ with an unchecked Eigen operator() below + // (OOB write for an out-of-range / negative id). bus_id itself is validated + // by the caller (LSGrid::set_gen_regulated_bus) against the grid bus count. + _check_in_range(gen_id, regulated_bus_id_, "set_regulated_bus"); if(regulated_bus_id_(gen_id) != bus_id){ regulated_bus_id_(gen_id) = bus_id; solver_control.ac_algo_controler().tell_pv_changed(); // groups are rebuilt on topology init diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index d5f397c2..64dd476d 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -296,7 +296,12 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer get_status_droop_vect() const { return std::vector(status_droop_.begin(), status_droop_.end()); } diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index afb6994b..f6037726 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -207,11 +207,16 @@ class OneSideContainer : public GenericContainer } virtual bool deactivate(int el_id, DualAlgoControl & solver_control) final { + // validate el_id *before* dispatching: `_deactivate` indexes status_[el_id] + // with an unchecked operator[] (a negative id would wrap to a huge size_t), + // and `_generic_deactivate` only checks afterwards. + _check_in_range(el_id, status_, "deactivate"); bool res = this->_deactivate(el_id, solver_control); _generic_deactivate(el_id, status_); return res; } virtual bool reactivate(int el_id, DualAlgoControl & solver_control) final { + _check_in_range(el_id, status_, "reactivate"); bool res = this->_reactivate(el_id, solver_control); _generic_reactivate(el_id, status_); return res; @@ -228,6 +233,9 @@ class OneSideContainer : public GenericContainer GridModelBusId new_gridmodel_bus_id, DualAlgoControl & solver_control, const SubstationContainer & substation) final { + // validate load_id *before* dispatching: `_change_bus` reads bus_id_(load_id) + // with an unchecked Eigen operator(); `_generic_change_bus` only checks afterwards. + _check_in_range(load_id, bus_id_, "change_bus"); bool res = this->_change_bus(load_id, new_gridmodel_bus_id, solver_control, substation.nb_bus()); _generic_change_bus(load_id, new_gridmodel_bus_id, bus_id_, solver_control, substation.nb_bus()); return res; @@ -253,11 +261,13 @@ class OneSideContainer : public GenericContainer void set_pos_topo_vect(Eigen::Ref pos_topo_vect) { + check_size(pos_topo_vect, nb(), "pos_topo_vect"); pos_topo_vect_.array() = pos_topo_vect; } void set_subid(Eigen::Ref subid) { + check_size(subid, nb(), "subid"); subid_.array() = subid; } diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 5b99a8a7..862295d3 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -177,6 +177,8 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A_tol_equal_float){ ratio_(el_id) = new_ratio; // TODO speed: only some part needs to be recomputed @@ -196,6 +198,8 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A_tol_equal_float){ shift_(el_id) = new_shift_rad; // TODO speed: only some part needs to be recomputed From 7be352c15396c766dc98174d229a83da66ea6f89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:50:25 +0000 Subject: [PATCH 058/166] Fix out-of-bounds memory access from Python-exposed bindings Several Python-exposed functions passed an element id or array straight to unchecked C++ indexing (std::vector::operator[] / Eigen operator()/.col(), whose bounds asserts are compiled out under NDEBUG). A negative id wraps to a huge size_t, and an out-of-range id reads or writes past the container, so a plain Python call such as grid.deactivate_load(-1) or grid.change_ratio_trafo(9999, 1.0) could corrupt or leak process memory. Add O(1) validation on the entry path, reusing the existing GenericContainer::_check_in_range / check_size helpers (the same idiom already used by the safe change_p/change_q/change_v and change_bus paths): - OneSideContainer::deactivate/reactivate/change_bus: validate el_id before dispatching to the _method hook (which indexed status_/bus_id_ before the _generic_* validator ran). This also covers the two-sided line/trafo/dcline containers, which delegate to these final methods first. - GeneratorContainer::update_slack_weights_by_id: validate every caller id before it indexes status_/maybe_slack_bus/target_p_mw_ (was an OOB write). - GeneratorContainer::set_regulated_bus / LSGrid::set_gen_regulated_bus: validate gen_id and the stored bus_id (was an OOB write + unvalidated bus). - TrafoContainer::change_ratio/change_shift: validate el_id (was an OOB write). - HvdcLineContainer::get_status_droop: validate hvdc_id (its setter already did). - LSGrid::update_continuous_values: require new_values and has_changed to have the same length before indexing new_values by has_changed's length. - OneSideContainer::set_pos_topo_vect/set_subid: check_size before assignment. - TimeSeries::compute_Vs: validate the gen_p/sgen_p/load_p/load_q matrix column counts against the grid element counts and that all share the same row count, before fill_SBus_* indexes user columns by the grid element ids. Out-of-range ids now raise IndexError/RuntimeError instead of segfaulting; valid calls and the powerflow are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- src/core/LSGrid.hpp | 20 +++++++++++++- src/core/batch_algorithm/TimeSeries.cpp | 27 +++++++++++++++++++ .../element_container/GeneratorContainer.cpp | 7 ++++- .../element_container/GeneratorContainer.hpp | 4 +++ .../element_container/HvdcLineContainer.hpp | 7 ++++- .../element_container/OneSideContainer.hpp | 10 +++++++ src/core/element_container/TrafoContainer.hpp | 4 +++ 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 663b7d95..c7185aac 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1020,7 +1020,17 @@ class LS2G_API LSGrid final * voltage control). `bus_id` == the generator's own bus restores ordinary * local PV control. */ - void set_gen_regulated_bus(int gen_id, int bus_id) {generators_.set_regulated_bus(gen_id, bus_id, algo_controler_); } + void set_gen_regulated_bus(int gen_id, int bus_id) { + // bus_id is stored verbatim and later dereferenced during solve, so reject + // an out-of-range bus here (gen_id is validated inside set_regulated_bus). + if(bus_id < 0 || bus_id >= static_cast(total_bus())){ + std::ostringstream exc_; + exc_ << "LSGrid::set_gen_regulated_bus: regulated bus id should be >= 0 and < "; + exc_ << total_bus() << " (number of buses on the grid), you provided " << bus_id << "."; + throw std::out_of_range(exc_.str()); + } + generators_.set_regulated_bus(gen_id, bus_id, algo_controler_); + } /** * Change the bus on the dc line "side 1" dcline_id. * @@ -1904,6 +1914,14 @@ class LS2G_API LSGrid final Eigen::Ref > & new_values, T fun) { + // new_values is indexed by has_changed's length below; a shorter new_values + // would over-read the buffer (Eigen operator[] is unchecked in release). + if(new_values.rows() != has_changed.rows()){ + std::ostringstream exc_; + exc_ << "LSGrid::update_continuous_values: 'has_changed' (size " << has_changed.rows(); + exc_ << ") and 'new_values' (size " << new_values.rows() << ") must have the same size."; + throw std::runtime_error(exc_.str()); + } for(int el_id = 0; el_id < has_changed.rows(); ++el_id) { if(has_changed(el_id)) diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index 2156bc43..f26481c2 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -43,6 +43,33 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, const auto & s_generators = _grid_model.get_static_generators_as_data(); const auto & loads = _grid_model.get_loads_as_data(); + // Validate the shapes of the caller-supplied matrices before fill_SBus_* uses them. + // fill_SBus_* iterates el_id up to the grid's element count and reads + // temporal_data.col(el_id) (unchecked Eigen .col()), so a matrix with too few + // columns over-reads; and _Sbuses is sized with gen_p.rows(), so mismatched row + // counts turn the `Sbuses.col(...) += tmp` into an out-of-bounds read/write. + const auto check_mat = [nb_steps](Eigen::Ref mat, Eigen::Index nb_expected_cols, + const std::string & name){ + if(static_cast(mat.rows()) != nb_steps){ + std::ostringstream exc_; + exc_ << "TimeSeries::compute_Vs: '" << name << "' has " << mat.rows() + << " rows (time steps) while 'gen_p' has " << nb_steps + << ". All injection matrices must share the same number of rows."; + throw std::runtime_error(exc_.str()); + } + if(mat.cols() != nb_expected_cols){ + std::ostringstream exc_; + exc_ << "TimeSeries::compute_Vs: '" << name << "' has " << mat.cols() + << " columns while the grid counts " << nb_expected_cols + << " such elements. The number of columns must match the number of elements."; + throw std::runtime_error(exc_.str()); + } + }; + check_mat(gen_p, generators.nb(), "gen_p"); + check_mat(sgen_p, s_generators.nb(), "sgen_p"); + check_mat(load_p, loads.nb(), "load_p"); + check_mat(load_q, loads.nb(), "load_q"); + // now build the Sbus _Sbuses = CplxMat::Zero(nb_steps, nb_buses_solver_); diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 77fb779f..33522c39 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -564,11 +564,16 @@ void GeneratorContainer::update_slack_weights_by_id( int nb_gen = nb(); std::vector maybe_slack_bus(nb_gen, false); + // validate every caller-supplied id before it is used to index status_ / + // maybe_slack_bus / target_p_mw_ below (raw operator[] / Eigen operator() are + // unchecked, and a negative id would wrap to a huge size_t -> OOB write). + for(int gen_id : gen_slack_id) _check_in_range(gen_id, status_, "update_slack_weights_by_id"); + // find which generators can be slack real_type total_target_p = 0.; for(int gen_id : gen_slack_id) { - if(status_[gen_id]) + if(status_[gen_id]) { maybe_slack_bus[gen_id] = true; total_target_p += abs(target_p_mw_(gen_id)); diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 623b3c7c..d4ebc908 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -213,6 +213,10 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter return regulated_bus_id_(gen_id) != bus_id_(gen_id).cast_int(); } void set_regulated_bus(int gen_id, int bus_id, DualAlgoControl & solver_control){ + // gen_id indexes regulated_bus_id_ with an unchecked Eigen operator() below + // (OOB write for an out-of-range / negative id). bus_id itself is validated + // by the caller (LSGrid::set_gen_regulated_bus) against the grid bus count. + _check_in_range(gen_id, regulated_bus_id_, "set_regulated_bus"); if(regulated_bus_id_(gen_id) != bus_id){ regulated_bus_id_(gen_id) = bus_id; solver_control.ac_algo_controler().tell_pv_changed(); // groups are rebuilt on topology init diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index d5f397c2..64dd476d 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -296,7 +296,12 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer get_status_droop_vect() const { return std::vector(status_droop_.begin(), status_droop_.end()); } diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index afb6994b..f6037726 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -207,11 +207,16 @@ class OneSideContainer : public GenericContainer } virtual bool deactivate(int el_id, DualAlgoControl & solver_control) final { + // validate el_id *before* dispatching: `_deactivate` indexes status_[el_id] + // with an unchecked operator[] (a negative id would wrap to a huge size_t), + // and `_generic_deactivate` only checks afterwards. + _check_in_range(el_id, status_, "deactivate"); bool res = this->_deactivate(el_id, solver_control); _generic_deactivate(el_id, status_); return res; } virtual bool reactivate(int el_id, DualAlgoControl & solver_control) final { + _check_in_range(el_id, status_, "reactivate"); bool res = this->_reactivate(el_id, solver_control); _generic_reactivate(el_id, status_); return res; @@ -228,6 +233,9 @@ class OneSideContainer : public GenericContainer GridModelBusId new_gridmodel_bus_id, DualAlgoControl & solver_control, const SubstationContainer & substation) final { + // validate load_id *before* dispatching: `_change_bus` reads bus_id_(load_id) + // with an unchecked Eigen operator(); `_generic_change_bus` only checks afterwards. + _check_in_range(load_id, bus_id_, "change_bus"); bool res = this->_change_bus(load_id, new_gridmodel_bus_id, solver_control, substation.nb_bus()); _generic_change_bus(load_id, new_gridmodel_bus_id, bus_id_, solver_control, substation.nb_bus()); return res; @@ -253,11 +261,13 @@ class OneSideContainer : public GenericContainer void set_pos_topo_vect(Eigen::Ref pos_topo_vect) { + check_size(pos_topo_vect, nb(), "pos_topo_vect"); pos_topo_vect_.array() = pos_topo_vect; } void set_subid(Eigen::Ref subid) { + check_size(subid, nb(), "subid"); subid_.array() = subid; } diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 5b99a8a7..862295d3 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -177,6 +177,8 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A_tol_equal_float){ ratio_(el_id) = new_ratio; // TODO speed: only some part needs to be recomputed @@ -196,6 +198,8 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A_tol_equal_float){ shift_(el_id) = new_shift_rad; // TODO speed: only some part needs to be recomputed From 18a60ea706b12cc5267709ac8618e9e9c5f17c7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:59:06 +0000 Subject: [PATCH 059/166] Use make_unique for CKTSO/NICSLU index buffers; add CHANGELOG Replace the raw new[]/delete[] management of the CSC index buffers (ai_/ap_) in CKTSOLinearSolver and NICSLULinearSolver with std::unique_ptr created via std::make_unique. The buffer lifetime is unchanged (still released on reset() and destruction), but ownership is now RAII-managed and the manual delete[] in the destructor / reset() is gone. This also removes a latent leak: calling analyze() twice without an intervening reset() previously overwrote the raw pointers without freeing the old arrays; make_unique frees them on reassignment. Also document the out-of-bounds binding fixes and this refactor in CHANGELOG.rst. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 22 ++++++++++++++++++++++ src/core/linear_solvers/CKTSOSolver.cpp | 14 ++++++-------- src/core/linear_solvers/CKTSOSolver.hpp | 19 +++++++++++-------- src/core/linear_solvers/NICSLUSolver.cpp | 14 ++++++-------- src/core/linear_solvers/NICSLUSolver.hpp | 17 +++++++---------- 5 files changed, 52 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 31b760fd..47f12a90 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -115,6 +115,22 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. built its working matrix from the grid model's own (never populated, hence empty) Ybus, causing out-of-bounds writes. It now uses the correctly indexed internal `Ybus_` and builds the required inputs on demand, so it works both before and after `compute()`. +- [FIXED] several Python-exposed methods could read or write out of bounds (potential + segfault / memory corruption) when given an out-of-range or negative element id or a + mis-shaped array, because the id / array reached unchecked ``operator[]`` / + Eigen ``operator()`` / ``.col()`` (whose bounds asserts are compiled out in release + builds) with no prior validation. Out-of-range ids now raise ``IndexError`` and + mis-shaped arrays raise ``RuntimeError``, instead of corrupting memory. Affected: + ``deactivate_* / reactivate_* / change_bus_*`` (loads, gens, sgens, storages, shunts, + powerlines, trafos, dclines — the bounds check ran *after* the access), + ``update_slack_weights_by_id`` and ``set_gen_regulated_bus`` (out-of-bounds writes, the + latter also stored an unvalidated bus id), ``change_ratio_trafo`` / + ``change_shift_trafo`` / ``change_shift_trafo_deg`` (out-of-bounds writes), + ``get_status_droop_hvdc``, the grid2op fast-update path + (``update_gens_p`` / ``update_loads_p`` / ... when ``new_values`` is shorter than + ``has_changed``), ``set_*_pos_topo_vect`` / ``set_*_to_subid``, and + ``TimeSeriesCPP.compute_Vs`` (injection-matrix column / row counts are now validated + against the grid element counts). - [FIXED] pickling (or `save_binary`/`load_binary`) an `LSGrid` whose `_ls_to_orig` bus-mapping was set (which `init_from_pypowsybl` and `init_from_pandapower` both do) always raised ``"Impossible to set the converter ls_to_orig: the provided vector has @@ -195,6 +211,12 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / default-virtual-method signatures (kept as ``/*name*/`` comments for documentation, matching the convention already used elsewhere in the codebase). +- [IMPROVED] (cpp) the ``CKTSOLinearSolver`` and ``NICSLULinearSolver`` linear solvers now + hold their CSC index buffers (``ai_`` / ``ap_``) in ``std::unique_ptr`` allocated with + ``std::make_unique`` instead of raw ``new[]`` / ``delete[]``. The lifetime is unchanged + (the buffers are still released on ``reset()`` and destruction), but ownership is now + RAII-managed, which also removes a latent leak if ``analyze()`` were called twice without + an intervening ``reset()``. - [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so diff --git a/src/core/linear_solvers/CKTSOSolver.cpp b/src/core/linear_solvers/CKTSOSolver.cpp index 5b981956..e78aa758 100644 --- a/src/core/linear_solvers/CKTSOSolver.cpp +++ b/src/core/linear_solvers/CKTSOSolver.cpp @@ -20,15 +20,13 @@ const bool CKTSOLinearSolver::CAN_SOLVE_MAT = false; ErrorType CKTSOLinearSolver::reset(){ // free everything if(solver_ != nullptr) solver_->DestroySolver(); - if(ai_ != nullptr) delete [] ai_; - if(ap_ != nullptr) delete [] ap_; + ai_.reset(); + ap_.reset(); // should not be deleted, see https://github.com/Grid2Op/lightsim2grid/issues/52#issuecomment-1333565959 // if(iparm_!= nullptr) delete iparm_; // if(oparm_!= nullptr) delete oparm_; - ai_ = nullptr; - ap_ = nullptr; iparm_ = nullptr; oparm_ = nullptr; @@ -53,21 +51,21 @@ ErrorType CKTSOLinearSolver::analyze(const Eigen::SparseMatrix & J){ const auto n = J.cols(); const unsigned int nnz = J.nonZeros(); - ai_ = new int [nnz]; + ai_ = std::make_unique(nnz); const int * ref_ai = J.innerIndexPtr(); for(unsigned int i = 0; i < nnz; ++i){ ai_[i] = static_cast(ref_ai[i]); } - ap_ = new int [n+1]; + ap_ = std::make_unique(n+1); const int * ref_ap = J.outerIndexPtr(); for(int i = 0; i < n+1; ++i){ ap_[i] = static_cast(ref_ap[i]); } int ret = solver_->Analyze(false, // complex or real n, - ap_, - ai_, + ap_.get(), + ai_.get(), J.valuePtr(), nb_thread_); if (ret < 0){ diff --git a/src/core/linear_solvers/CKTSOSolver.hpp b/src/core/linear_solvers/CKTSOSolver.hpp index 071d4977..da90d9ba 100644 --- a/src/core/linear_solvers/CKTSOSolver.hpp +++ b/src/core/linear_solvers/CKTSOSolver.hpp @@ -10,6 +10,8 @@ #ifndef CKTSOSOLVER_H #define CKTSOSOLVER_H +#include + // eigen is necessary to easily pass data from numpy to c++ without any copy. // and to optimize the matrix operations #include "Utils.hpp" @@ -52,13 +54,12 @@ class LS2G_API CKTSOLinearSolver final ~CKTSOLinearSolver() noexcept { if(solver_!= nullptr) solver_->DestroySolver(); - if(ai_!= nullptr) delete [] ai_; - if(ap_!= nullptr) delete [] ap_; - + // ai_ / ap_ are std::unique_ptr and free themselves. + // should not be deleted, see https://github.com/Grid2Op/lightsim2grid/issues/52#issuecomment-1333565959 // if(iparm_!= nullptr) delete iparm_; // if(oparm_!= nullptr) delete oparm_; - + } // CKTSOLinearSolver(CKTSOLinearSolver && other) noexcept: nb_thread_(ohter.nb_thread_){ // TODO ! @@ -100,10 +101,12 @@ class LS2G_API CKTSOLinearSolver final // solver initialization ICktSo solver_; const unsigned int nb_thread_; - int * ai_; - int * ap_; - int * iparm_; - long long * oparm_; + // ai_ / ap_ own the CSC index buffers passed to CKTSO's Analyze; they must + // stay alive until reset() / destruction, which std::unique_ptr handles. + std::unique_ptr ai_; + std::unique_ptr ap_; + int * iparm_; // owned by CKTSO, not freed here + long long * oparm_; // owned by CKTSO, not freed here }; diff --git a/src/core/linear_solvers/NICSLUSolver.cpp b/src/core/linear_solvers/NICSLUSolver.cpp index 8dbc7465..0a108d82 100644 --- a/src/core/linear_solvers/NICSLUSolver.cpp +++ b/src/core/linear_solvers/NICSLUSolver.cpp @@ -17,10 +17,8 @@ const bool NICSLULinearSolver::CAN_SOLVE_MAT = false; ErrorType NICSLULinearSolver::reset(){ // free everything solver_.Free(); - if(ai_ != nullptr) delete [] ai_; // created in NICSLUSolver::initialize - if(ap_ != nullptr) delete [] ap_; // created in NICSLUSolver::initialize - ai_ = nullptr; - ap_ = nullptr; + ai_.reset(); // created in NICSLUSolver::analyze + ap_.reset(); // created in NICSLUSolver::analyze solver_ = CNicsLU(); int ret = solver_.Initialize(); @@ -43,21 +41,21 @@ ErrorType NICSLULinearSolver::analyze(const Eigen::SparseMatrix & J){ const auto n = J.cols(); const unsigned int nnz = J.nonZeros(); - ai_ = new unsigned int [nnz]; // deleted in destructor and NICSLUSolver::reset + ai_ = std::make_unique(nnz); // freed in destructor and NICSLUSolver::reset const int * ref_ai = J.innerIndexPtr(); for(unsigned int i = 0; i < nnz; ++i){ ai_[i] = static_cast(ref_ai[i]); } - ap_ = new unsigned int [n+1]; // deleted in destructor and NICSLUSolver::reset + ap_ = std::make_unique(n+1); // freed in destructor and NICSLUSolver::reset const int * ref_ap = J.outerIndexPtr(); for(int i = 0; i < n+1; ++i){ ap_[i] = static_cast(ref_ap[i]); } int ret = solver_.Analyze(n, J.valuePtr(), - ai_, - ap_, + ai_.get(), + ap_.get(), MATRIX_COLUMN_REAL); // MATRIX_COLUMN_REAL, MATRIX_ROW_REAL if (ret < 0){ // https://github.com/chenxm1986/nicslu/blob/master/nicslu202103/include/nicslu.h for error info diff --git a/src/core/linear_solvers/NICSLUSolver.hpp b/src/core/linear_solvers/NICSLUSolver.hpp index d3e12c94..b38e6569 100644 --- a/src/core/linear_solvers/NICSLUSolver.hpp +++ b/src/core/linear_solvers/NICSLUSolver.hpp @@ -10,6 +10,8 @@ #ifndef NICSLUSOLVER_H #define NICSLUSOLVER_H +#include + // eigen is necessary to easily pass data from numpy to c++ without any copy. // and to optimize the matrix operations #include "Utils.hpp" @@ -50,14 +52,7 @@ class LS2G_API NICSLULinearSolver final ~NICSLULinearSolver() noexcept { solver_.Free(); - if(ai_!= nullptr){ - delete [] ai_; - ai_= nullptr; - } - if(ap_!= nullptr){ - delete [] ap_; - ap_ = nullptr; - } + // ai_ / ap_ are std::unique_ptr and free themselves. } // NICSLULinearSolver(NICSLULinearSolver && other) noexcept: nb_thread_(other.nb_thread_){ @@ -93,8 +88,10 @@ class LS2G_API NICSLULinearSolver final // solver initialization CNicsLU solver_; const unsigned int nb_thread_; - unsigned int * ai_; - unsigned int * ap_; + // ai_ / ap_ own the CSC index buffers passed to NICSLU's Analyze; they must + // stay alive until reset() / destruction, which std::unique_ptr handles. + std::unique_ptr ai_; + std::unique_ptr ap_; }; From 4066f33df33adb2c3c9090150feb0463a08bdb5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:59:06 +0000 Subject: [PATCH 060/166] Use make_unique for CKTSO/NICSLU index buffers; add CHANGELOG Replace the raw new[]/delete[] management of the CSC index buffers (ai_/ap_) in CKTSOLinearSolver and NICSLULinearSolver with std::unique_ptr created via std::make_unique. The buffer lifetime is unchanged (still released on reset() and destruction), but ownership is now RAII-managed and the manual delete[] in the destructor / reset() is gone. This also removes a latent leak: calling analyze() twice without an intervening reset() previously overwrote the raw pointers without freeing the old arrays; make_unique frees them on reassignment. Also document the out-of-bounds binding fixes and this refactor in CHANGELOG.rst. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 22 ++++++++++++++++++++++ src/core/linear_solvers/CKTSOSolver.cpp | 14 ++++++-------- src/core/linear_solvers/CKTSOSolver.hpp | 19 +++++++++++-------- src/core/linear_solvers/NICSLUSolver.cpp | 14 ++++++-------- src/core/linear_solvers/NICSLUSolver.hpp | 17 +++++++---------- 5 files changed, 52 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 31b760fd..47f12a90 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -115,6 +115,22 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. built its working matrix from the grid model's own (never populated, hence empty) Ybus, causing out-of-bounds writes. It now uses the correctly indexed internal `Ybus_` and builds the required inputs on demand, so it works both before and after `compute()`. +- [FIXED] several Python-exposed methods could read or write out of bounds (potential + segfault / memory corruption) when given an out-of-range or negative element id or a + mis-shaped array, because the id / array reached unchecked ``operator[]`` / + Eigen ``operator()`` / ``.col()`` (whose bounds asserts are compiled out in release + builds) with no prior validation. Out-of-range ids now raise ``IndexError`` and + mis-shaped arrays raise ``RuntimeError``, instead of corrupting memory. Affected: + ``deactivate_* / reactivate_* / change_bus_*`` (loads, gens, sgens, storages, shunts, + powerlines, trafos, dclines — the bounds check ran *after* the access), + ``update_slack_weights_by_id`` and ``set_gen_regulated_bus`` (out-of-bounds writes, the + latter also stored an unvalidated bus id), ``change_ratio_trafo`` / + ``change_shift_trafo`` / ``change_shift_trafo_deg`` (out-of-bounds writes), + ``get_status_droop_hvdc``, the grid2op fast-update path + (``update_gens_p`` / ``update_loads_p`` / ... when ``new_values`` is shorter than + ``has_changed``), ``set_*_pos_topo_vect`` / ``set_*_to_subid``, and + ``TimeSeriesCPP.compute_Vs`` (injection-matrix column / row counts are now validated + against the grid element counts). - [FIXED] pickling (or `save_binary`/`load_binary`) an `LSGrid` whose `_ls_to_orig` bus-mapping was set (which `init_from_pypowsybl` and `init_from_pandapower` both do) always raised ``"Impossible to set the converter ls_to_orig: the provided vector has @@ -195,6 +211,12 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / default-virtual-method signatures (kept as ``/*name*/`` comments for documentation, matching the convention already used elsewhere in the codebase). +- [IMPROVED] (cpp) the ``CKTSOLinearSolver`` and ``NICSLULinearSolver`` linear solvers now + hold their CSC index buffers (``ai_`` / ``ap_``) in ``std::unique_ptr`` allocated with + ``std::make_unique`` instead of raw ``new[]`` / ``delete[]``. The lifetime is unchanged + (the buffers are still released on ``reset()`` and destruction), but ownership is now + RAII-managed, which also removes a latent leak if ``analyze()`` were called twice without + an intervening ``reset()``. - [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so diff --git a/src/core/linear_solvers/CKTSOSolver.cpp b/src/core/linear_solvers/CKTSOSolver.cpp index 5b981956..e78aa758 100644 --- a/src/core/linear_solvers/CKTSOSolver.cpp +++ b/src/core/linear_solvers/CKTSOSolver.cpp @@ -20,15 +20,13 @@ const bool CKTSOLinearSolver::CAN_SOLVE_MAT = false; ErrorType CKTSOLinearSolver::reset(){ // free everything if(solver_ != nullptr) solver_->DestroySolver(); - if(ai_ != nullptr) delete [] ai_; - if(ap_ != nullptr) delete [] ap_; + ai_.reset(); + ap_.reset(); // should not be deleted, see https://github.com/Grid2Op/lightsim2grid/issues/52#issuecomment-1333565959 // if(iparm_!= nullptr) delete iparm_; // if(oparm_!= nullptr) delete oparm_; - ai_ = nullptr; - ap_ = nullptr; iparm_ = nullptr; oparm_ = nullptr; @@ -53,21 +51,21 @@ ErrorType CKTSOLinearSolver::analyze(const Eigen::SparseMatrix & J){ const auto n = J.cols(); const unsigned int nnz = J.nonZeros(); - ai_ = new int [nnz]; + ai_ = std::make_unique(nnz); const int * ref_ai = J.innerIndexPtr(); for(unsigned int i = 0; i < nnz; ++i){ ai_[i] = static_cast(ref_ai[i]); } - ap_ = new int [n+1]; + ap_ = std::make_unique(n+1); const int * ref_ap = J.outerIndexPtr(); for(int i = 0; i < n+1; ++i){ ap_[i] = static_cast(ref_ap[i]); } int ret = solver_->Analyze(false, // complex or real n, - ap_, - ai_, + ap_.get(), + ai_.get(), J.valuePtr(), nb_thread_); if (ret < 0){ diff --git a/src/core/linear_solvers/CKTSOSolver.hpp b/src/core/linear_solvers/CKTSOSolver.hpp index 071d4977..da90d9ba 100644 --- a/src/core/linear_solvers/CKTSOSolver.hpp +++ b/src/core/linear_solvers/CKTSOSolver.hpp @@ -10,6 +10,8 @@ #ifndef CKTSOSOLVER_H #define CKTSOSOLVER_H +#include + // eigen is necessary to easily pass data from numpy to c++ without any copy. // and to optimize the matrix operations #include "Utils.hpp" @@ -52,13 +54,12 @@ class LS2G_API CKTSOLinearSolver final ~CKTSOLinearSolver() noexcept { if(solver_!= nullptr) solver_->DestroySolver(); - if(ai_!= nullptr) delete [] ai_; - if(ap_!= nullptr) delete [] ap_; - + // ai_ / ap_ are std::unique_ptr and free themselves. + // should not be deleted, see https://github.com/Grid2Op/lightsim2grid/issues/52#issuecomment-1333565959 // if(iparm_!= nullptr) delete iparm_; // if(oparm_!= nullptr) delete oparm_; - + } // CKTSOLinearSolver(CKTSOLinearSolver && other) noexcept: nb_thread_(ohter.nb_thread_){ // TODO ! @@ -100,10 +101,12 @@ class LS2G_API CKTSOLinearSolver final // solver initialization ICktSo solver_; const unsigned int nb_thread_; - int * ai_; - int * ap_; - int * iparm_; - long long * oparm_; + // ai_ / ap_ own the CSC index buffers passed to CKTSO's Analyze; they must + // stay alive until reset() / destruction, which std::unique_ptr handles. + std::unique_ptr ai_; + std::unique_ptr ap_; + int * iparm_; // owned by CKTSO, not freed here + long long * oparm_; // owned by CKTSO, not freed here }; diff --git a/src/core/linear_solvers/NICSLUSolver.cpp b/src/core/linear_solvers/NICSLUSolver.cpp index 8dbc7465..0a108d82 100644 --- a/src/core/linear_solvers/NICSLUSolver.cpp +++ b/src/core/linear_solvers/NICSLUSolver.cpp @@ -17,10 +17,8 @@ const bool NICSLULinearSolver::CAN_SOLVE_MAT = false; ErrorType NICSLULinearSolver::reset(){ // free everything solver_.Free(); - if(ai_ != nullptr) delete [] ai_; // created in NICSLUSolver::initialize - if(ap_ != nullptr) delete [] ap_; // created in NICSLUSolver::initialize - ai_ = nullptr; - ap_ = nullptr; + ai_.reset(); // created in NICSLUSolver::analyze + ap_.reset(); // created in NICSLUSolver::analyze solver_ = CNicsLU(); int ret = solver_.Initialize(); @@ -43,21 +41,21 @@ ErrorType NICSLULinearSolver::analyze(const Eigen::SparseMatrix & J){ const auto n = J.cols(); const unsigned int nnz = J.nonZeros(); - ai_ = new unsigned int [nnz]; // deleted in destructor and NICSLUSolver::reset + ai_ = std::make_unique(nnz); // freed in destructor and NICSLUSolver::reset const int * ref_ai = J.innerIndexPtr(); for(unsigned int i = 0; i < nnz; ++i){ ai_[i] = static_cast(ref_ai[i]); } - ap_ = new unsigned int [n+1]; // deleted in destructor and NICSLUSolver::reset + ap_ = std::make_unique(n+1); // freed in destructor and NICSLUSolver::reset const int * ref_ap = J.outerIndexPtr(); for(int i = 0; i < n+1; ++i){ ap_[i] = static_cast(ref_ap[i]); } int ret = solver_.Analyze(n, J.valuePtr(), - ai_, - ap_, + ai_.get(), + ap_.get(), MATRIX_COLUMN_REAL); // MATRIX_COLUMN_REAL, MATRIX_ROW_REAL if (ret < 0){ // https://github.com/chenxm1986/nicslu/blob/master/nicslu202103/include/nicslu.h for error info diff --git a/src/core/linear_solvers/NICSLUSolver.hpp b/src/core/linear_solvers/NICSLUSolver.hpp index d3e12c94..b38e6569 100644 --- a/src/core/linear_solvers/NICSLUSolver.hpp +++ b/src/core/linear_solvers/NICSLUSolver.hpp @@ -10,6 +10,8 @@ #ifndef NICSLUSOLVER_H #define NICSLUSOLVER_H +#include + // eigen is necessary to easily pass data from numpy to c++ without any copy. // and to optimize the matrix operations #include "Utils.hpp" @@ -50,14 +52,7 @@ class LS2G_API NICSLULinearSolver final ~NICSLULinearSolver() noexcept { solver_.Free(); - if(ai_!= nullptr){ - delete [] ai_; - ai_= nullptr; - } - if(ap_!= nullptr){ - delete [] ap_; - ap_ = nullptr; - } + // ai_ / ap_ are std::unique_ptr and free themselves. } // NICSLULinearSolver(NICSLULinearSolver && other) noexcept: nb_thread_(other.nb_thread_){ @@ -93,8 +88,10 @@ class LS2G_API NICSLULinearSolver final // solver initialization CNicsLU solver_; const unsigned int nb_thread_; - unsigned int * ai_; - unsigned int * ap_; + // ai_ / ap_ own the CSC index buffers passed to NICSLU's Analyze; they must + // stay alive until reset() / destruction, which std::unique_ptr handles. + std::unique_ptr ai_; + std::unique_ptr ap_; }; From 94b4ff9c4637090cde56318ad25067810d09768f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 10:08:16 +0000 Subject: [PATCH 061/166] Use unique_ptr with custom deleters for KLU solver handles Manage KLULinearSolver's klu_symbolic / klu_numeric handles with std::unique_ptr and stateful custom deleters that call klu_free_symbolic / klu_free_numeric, instead of freeing them by hand in the destructor and reset(). The deleters hold a pointer to the klu_common control struct (which the KLU free functions require), so common_ is now declared before the two handles to guarantee it outlives them during destruction (members are destroyed in reverse declaration order). Lifetime is preserved (handles are still released on reset() and destruction). As with the CKTSO/NICSLU change, this also removes a latent leak: re-running analyze()/factorize() without an intervening reset() previously overwrote the raw handle without freeing the old one; reset(...) now frees it first. Verified with the KLU solver on case118: converges and matches the SparseLU reference to ~2e-15 after 50 topology-change cycles (repeated analyze/factorize/ reset). Updated the CHANGELOG entry accordingly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 15 ++++--- src/core/linear_solvers/KLUSolver.cpp | 32 +++++++------ src/core/linear_solvers/KLUSolver.hpp | 65 ++++++++++++++++----------- 3 files changed, 65 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 47f12a90..076a1f1f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -211,12 +211,15 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / default-virtual-method signatures (kept as ``/*name*/`` comments for documentation, matching the convention already used elsewhere in the codebase). -- [IMPROVED] (cpp) the ``CKTSOLinearSolver`` and ``NICSLULinearSolver`` linear solvers now - hold their CSC index buffers (``ai_`` / ``ap_``) in ``std::unique_ptr`` allocated with - ``std::make_unique`` instead of raw ``new[]`` / ``delete[]``. The lifetime is unchanged - (the buffers are still released on ``reset()`` and destruction), but ownership is now - RAII-managed, which also removes a latent leak if ``analyze()`` were called twice without - an intervening ``reset()``. +- [IMPROVED] (cpp) the built-in linear solvers now manage their solver memory with RAII + smart pointers instead of manual allocation. ``CKTSOLinearSolver`` and + ``NICSLULinearSolver`` hold their CSC index buffers (``ai_`` / ``ap_``) in + ``std::unique_ptr`` allocated with ``std::make_unique`` (was raw ``new[]`` / + ``delete[]``); ``KLULinearSolver`` holds its ``klu_symbolic`` / ``klu_numeric`` handles in + ``std::unique_ptr`` with custom deleters that call ``klu_free_symbolic`` / + ``klu_free_numeric`` (was manual frees in the destructor and ``reset()``). Lifetimes are + unchanged, but ownership is now RAII-managed, which also removes a latent leak if + ``analyze()`` / ``factorize()`` were called twice without an intervening ``reset()``. - [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so diff --git a/src/core/linear_solvers/KLUSolver.cpp b/src/core/linear_solvers/KLUSolver.cpp index 99a842ea..7ef7364b 100644 --- a/src/core/linear_solvers/KLUSolver.cpp +++ b/src/core/linear_solvers/KLUSolver.cpp @@ -15,32 +15,36 @@ namespace ls2g { const bool KLULinearSolver::CAN_SOLVE_MAT = false; ErrorType KLULinearSolver::reset(){ - if(symbolic_ != nullptr) klu_free_symbolic(&symbolic_, &common_); - if(numeric_ != nullptr) klu_free_numeric(&numeric_, &common_); + // release both handles (their deleters use common_) before resetting common_ + numeric_.reset(); + symbolic_.reset(); common_ = klu_common(); - symbolic_ = nullptr; - numeric_ = nullptr; return ErrorType::NoError; } ErrorType KLULinearSolver::analyze(const Eigen::SparseMatrix& J){ // J is const here, but `klu_analyze` signature expects non-const arrays, so I const_cast const auto n = J.cols(); + // free any previous factorization (using the current common_) before resetting it, + // so re-analyzing without a prior reset() no longer leaks the old handles + numeric_.reset(); + symbolic_.reset(); common_ = klu_common(); - symbolic_ = klu_analyze(n, - const_cast::StorageIndex *>(J.outerIndexPtr()), - const_cast::StorageIndex *>(J.innerIndexPtr()), - &common_); + symbolic_.reset(klu_analyze(n, + const_cast::StorageIndex *>(J.outerIndexPtr()), + const_cast::StorageIndex *>(J.innerIndexPtr()), + &common_)); if(common_.status != KLU_OK) return ErrorType::SolverAnalyze; return ErrorType::NoError; } ErrorType KLULinearSolver::factorize(const Eigen::SparseMatrix& J){ // J is const here, but `klu_factor` signature expects non-const arrays, so I const_cast - numeric_ = klu_factor(const_cast::StorageIndex *>(J.outerIndexPtr()), - const_cast::StorageIndex *>(J.innerIndexPtr()), - const_cast(J.valuePtr()), - symbolic_, &common_); + // reset() frees any previous numeric factorization before storing the new one + numeric_.reset(klu_factor(const_cast::StorageIndex *>(J.outerIndexPtr()), + const_cast::StorageIndex *>(J.innerIndexPtr()), + const_cast(J.valuePtr()), + symbolic_.get(), &common_)); if(common_.status != KLU_OK) return ErrorType::SolverFactor; return ErrorType::NoError; } @@ -51,7 +55,7 @@ ErrorType KLULinearSolver::refactorize(const Eigen::SparseMatrix& J){ int ok = klu_refactor(const_cast::StorageIndex *>(J.outerIndexPtr()), const_cast::StorageIndex *>(J.innerIndexPtr()), const_cast(J.valuePtr()), - symbolic_, numeric_, &common_); + symbolic_.get(), numeric_.get(), &common_); if(ok != 1){ // std::cout << "\t KLU: refactor error" << std::endl; return ErrorType::SolverReFactor; @@ -61,7 +65,7 @@ ErrorType KLULinearSolver::refactorize(const Eigen::SparseMatrix& J){ ErrorType KLULinearSolver::solve(RealVect & b){ const int n = static_cast(b.size()); - int ok = klu_solve(symbolic_, numeric_, n, 1, &b(0), &common_); + int ok = klu_solve(symbolic_.get(), numeric_.get(), n, 1, &b(0), &common_); if(ok != 1){ // std::cout << "\t KLU: klu_solve error" << std::endl; return ErrorType::SolverSolve; diff --git a/src/core/linear_solvers/KLUSolver.hpp b/src/core/linear_solvers/KLUSolver.hpp index bf549264..78550d95 100644 --- a/src/core/linear_solvers/KLUSolver.hpp +++ b/src/core/linear_solvers/KLUSolver.hpp @@ -10,6 +10,7 @@ #ifndef KLSOLVER_H #define KLSOLVER_H #include +#include // eigen is necessary to easily pass data from numpy to c++ without any copy. // and to optimize the matrix operations @@ -39,29 +40,14 @@ Otherwise, unexpected behaviour might follow, including "segfault". class LS2G_API KLULinearSolver final { public: - KLULinearSolver() noexcept :symbolic_(nullptr),numeric_(nullptr),common_(){} - - ~KLULinearSolver() noexcept - { - // std::cout << "KLULinearSolver destructor 1" << std::endl; - if(symbolic_ != nullptr) klu_free_symbolic(&symbolic_, &common_); - // std::cout << "KLULinearSolver destructor 2" << std::endl; - if(numeric_ != nullptr) klu_free_numeric(&numeric_, &common_); - // std::cout << "KLULinearSolver destructor 3" << std::endl; - } - - // KLULinearSolver(KLULinearSolver && other) noexcept { - // TODO - // if(symbolic_ != nullptr) klu_free_symbolic(&symbolic_, &common_); - // symbolic_ = other.symbolic_; - // other.symbolic_ = nullptr; - - // if(numeric_ != nullptr) klu_free_numeric(&numeric_, &common_); - // numeric_ = other.numeric_; - // other.numeric_ = nullptr; - - // std::swap(common_, other.common_); - // } + KLULinearSolver() noexcept : + common_(), + symbolic_(nullptr, SymbolicDeleter{&common_}), + numeric_(nullptr, NumericDeleter{&common_}) + {} + + // symbolic_ / numeric_ are unique_ptr with custom deleters and free themselves. + ~KLULinearSolver() noexcept = default; // public api ErrorType reset(); @@ -72,12 +58,37 @@ class LS2G_API KLULinearSolver final // can this linear solver solve problem where RHS is a matrix static const bool CAN_SOLVE_MAT; - + private: - // solver initialization - klu_symbolic* symbolic_; - klu_numeric* numeric_; + // KLU frees its symbolic / numeric handles through a pair of functions that + // also need the `klu_common` control struct, so the deleters are stateful and + // hold a pointer to `common_`. `common_` is therefore declared *before* the two + // handles: members are destroyed in reverse declaration order, so the handles + // (and their deleters, which dereference `common_`) are released while `common_` + // is still alive. + struct SymbolicDeleter { + klu_common* common_ = nullptr; + void operator()(klu_symbolic* ptr) const noexcept { + if(ptr != nullptr){ + klu_symbolic* tmp = ptr; // klu_free_symbolic takes a klu_symbolic** + klu_free_symbolic(&tmp, common_); + } + } + }; + struct NumericDeleter { + klu_common* common_ = nullptr; + void operator()(klu_numeric* ptr) const noexcept { + if(ptr != nullptr){ + klu_numeric* tmp = ptr; // klu_free_numeric takes a klu_numeric** + klu_free_numeric(&tmp, common_); + } + } + }; + + // solver initialization (declaration order matters, see note above) klu_common common_; + std::unique_ptr symbolic_; + std::unique_ptr numeric_; // no copy allowed KLULinearSolver(const KLULinearSolver&) = delete; From 3b2eaeeefd703ae0b83069c90f7c91c566a79e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 10:08:16 +0000 Subject: [PATCH 062/166] Use unique_ptr with custom deleters for KLU solver handles Manage KLULinearSolver's klu_symbolic / klu_numeric handles with std::unique_ptr and stateful custom deleters that call klu_free_symbolic / klu_free_numeric, instead of freeing them by hand in the destructor and reset(). The deleters hold a pointer to the klu_common control struct (which the KLU free functions require), so common_ is now declared before the two handles to guarantee it outlives them during destruction (members are destroyed in reverse declaration order). Lifetime is preserved (handles are still released on reset() and destruction). As with the CKTSO/NICSLU change, this also removes a latent leak: re-running analyze()/factorize() without an intervening reset() previously overwrote the raw handle without freeing the old one; reset(...) now frees it first. Verified with the KLU solver on case118: converges and matches the SparseLU reference to ~2e-15 after 50 topology-change cycles (repeated analyze/factorize/ reset). Updated the CHANGELOG entry accordingly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 15 ++++--- src/core/linear_solvers/KLUSolver.cpp | 32 +++++++------ src/core/linear_solvers/KLUSolver.hpp | 65 ++++++++++++++++----------- 3 files changed, 65 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 47f12a90..076a1f1f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -211,12 +211,15 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. vs ``size_t``) and silenced ~188 intentionally-unused parameters on interface / default-virtual-method signatures (kept as ``/*name*/`` comments for documentation, matching the convention already used elsewhere in the codebase). -- [IMPROVED] (cpp) the ``CKTSOLinearSolver`` and ``NICSLULinearSolver`` linear solvers now - hold their CSC index buffers (``ai_`` / ``ap_``) in ``std::unique_ptr`` allocated with - ``std::make_unique`` instead of raw ``new[]`` / ``delete[]``. The lifetime is unchanged - (the buffers are still released on ``reset()`` and destruction), but ownership is now - RAII-managed, which also removes a latent leak if ``analyze()`` were called twice without - an intervening ``reset()``. +- [IMPROVED] (cpp) the built-in linear solvers now manage their solver memory with RAII + smart pointers instead of manual allocation. ``CKTSOLinearSolver`` and + ``NICSLULinearSolver`` hold their CSC index buffers (``ai_`` / ``ap_``) in + ``std::unique_ptr`` allocated with ``std::make_unique`` (was raw ``new[]`` / + ``delete[]``); ``KLULinearSolver`` holds its ``klu_symbolic`` / ``klu_numeric`` handles in + ``std::unique_ptr`` with custom deleters that call ``klu_free_symbolic`` / + ``klu_free_numeric`` (was manual frees in the destructor and ``reset()``). Lifetimes are + unchanged, but ownership is now RAII-managed, which also removes a latent leak if + ``analyze()`` / ``factorize()`` were called twice without an intervening ``reset()``. - [ADDED] `lightsim2grid.network.bake_outer_loops`: rewrites a pypowsybl network's input setpoints to the converged PowSyBl OpenLoadFlow (OLF) outer-loop state (tap / shunt positions, reactive-limit PV->PQ switches, distributed-slack active power) so diff --git a/src/core/linear_solvers/KLUSolver.cpp b/src/core/linear_solvers/KLUSolver.cpp index 99a842ea..7ef7364b 100644 --- a/src/core/linear_solvers/KLUSolver.cpp +++ b/src/core/linear_solvers/KLUSolver.cpp @@ -15,32 +15,36 @@ namespace ls2g { const bool KLULinearSolver::CAN_SOLVE_MAT = false; ErrorType KLULinearSolver::reset(){ - if(symbolic_ != nullptr) klu_free_symbolic(&symbolic_, &common_); - if(numeric_ != nullptr) klu_free_numeric(&numeric_, &common_); + // release both handles (their deleters use common_) before resetting common_ + numeric_.reset(); + symbolic_.reset(); common_ = klu_common(); - symbolic_ = nullptr; - numeric_ = nullptr; return ErrorType::NoError; } ErrorType KLULinearSolver::analyze(const Eigen::SparseMatrix& J){ // J is const here, but `klu_analyze` signature expects non-const arrays, so I const_cast const auto n = J.cols(); + // free any previous factorization (using the current common_) before resetting it, + // so re-analyzing without a prior reset() no longer leaks the old handles + numeric_.reset(); + symbolic_.reset(); common_ = klu_common(); - symbolic_ = klu_analyze(n, - const_cast::StorageIndex *>(J.outerIndexPtr()), - const_cast::StorageIndex *>(J.innerIndexPtr()), - &common_); + symbolic_.reset(klu_analyze(n, + const_cast::StorageIndex *>(J.outerIndexPtr()), + const_cast::StorageIndex *>(J.innerIndexPtr()), + &common_)); if(common_.status != KLU_OK) return ErrorType::SolverAnalyze; return ErrorType::NoError; } ErrorType KLULinearSolver::factorize(const Eigen::SparseMatrix& J){ // J is const here, but `klu_factor` signature expects non-const arrays, so I const_cast - numeric_ = klu_factor(const_cast::StorageIndex *>(J.outerIndexPtr()), - const_cast::StorageIndex *>(J.innerIndexPtr()), - const_cast(J.valuePtr()), - symbolic_, &common_); + // reset() frees any previous numeric factorization before storing the new one + numeric_.reset(klu_factor(const_cast::StorageIndex *>(J.outerIndexPtr()), + const_cast::StorageIndex *>(J.innerIndexPtr()), + const_cast(J.valuePtr()), + symbolic_.get(), &common_)); if(common_.status != KLU_OK) return ErrorType::SolverFactor; return ErrorType::NoError; } @@ -51,7 +55,7 @@ ErrorType KLULinearSolver::refactorize(const Eigen::SparseMatrix& J){ int ok = klu_refactor(const_cast::StorageIndex *>(J.outerIndexPtr()), const_cast::StorageIndex *>(J.innerIndexPtr()), const_cast(J.valuePtr()), - symbolic_, numeric_, &common_); + symbolic_.get(), numeric_.get(), &common_); if(ok != 1){ // std::cout << "\t KLU: refactor error" << std::endl; return ErrorType::SolverReFactor; @@ -61,7 +65,7 @@ ErrorType KLULinearSolver::refactorize(const Eigen::SparseMatrix& J){ ErrorType KLULinearSolver::solve(RealVect & b){ const int n = static_cast(b.size()); - int ok = klu_solve(symbolic_, numeric_, n, 1, &b(0), &common_); + int ok = klu_solve(symbolic_.get(), numeric_.get(), n, 1, &b(0), &common_); if(ok != 1){ // std::cout << "\t KLU: klu_solve error" << std::endl; return ErrorType::SolverSolve; diff --git a/src/core/linear_solvers/KLUSolver.hpp b/src/core/linear_solvers/KLUSolver.hpp index bf549264..78550d95 100644 --- a/src/core/linear_solvers/KLUSolver.hpp +++ b/src/core/linear_solvers/KLUSolver.hpp @@ -10,6 +10,7 @@ #ifndef KLSOLVER_H #define KLSOLVER_H #include +#include // eigen is necessary to easily pass data from numpy to c++ without any copy. // and to optimize the matrix operations @@ -39,29 +40,14 @@ Otherwise, unexpected behaviour might follow, including "segfault". class LS2G_API KLULinearSolver final { public: - KLULinearSolver() noexcept :symbolic_(nullptr),numeric_(nullptr),common_(){} - - ~KLULinearSolver() noexcept - { - // std::cout << "KLULinearSolver destructor 1" << std::endl; - if(symbolic_ != nullptr) klu_free_symbolic(&symbolic_, &common_); - // std::cout << "KLULinearSolver destructor 2" << std::endl; - if(numeric_ != nullptr) klu_free_numeric(&numeric_, &common_); - // std::cout << "KLULinearSolver destructor 3" << std::endl; - } - - // KLULinearSolver(KLULinearSolver && other) noexcept { - // TODO - // if(symbolic_ != nullptr) klu_free_symbolic(&symbolic_, &common_); - // symbolic_ = other.symbolic_; - // other.symbolic_ = nullptr; - - // if(numeric_ != nullptr) klu_free_numeric(&numeric_, &common_); - // numeric_ = other.numeric_; - // other.numeric_ = nullptr; - - // std::swap(common_, other.common_); - // } + KLULinearSolver() noexcept : + common_(), + symbolic_(nullptr, SymbolicDeleter{&common_}), + numeric_(nullptr, NumericDeleter{&common_}) + {} + + // symbolic_ / numeric_ are unique_ptr with custom deleters and free themselves. + ~KLULinearSolver() noexcept = default; // public api ErrorType reset(); @@ -72,12 +58,37 @@ class LS2G_API KLULinearSolver final // can this linear solver solve problem where RHS is a matrix static const bool CAN_SOLVE_MAT; - + private: - // solver initialization - klu_symbolic* symbolic_; - klu_numeric* numeric_; + // KLU frees its symbolic / numeric handles through a pair of functions that + // also need the `klu_common` control struct, so the deleters are stateful and + // hold a pointer to `common_`. `common_` is therefore declared *before* the two + // handles: members are destroyed in reverse declaration order, so the handles + // (and their deleters, which dereference `common_`) are released while `common_` + // is still alive. + struct SymbolicDeleter { + klu_common* common_ = nullptr; + void operator()(klu_symbolic* ptr) const noexcept { + if(ptr != nullptr){ + klu_symbolic* tmp = ptr; // klu_free_symbolic takes a klu_symbolic** + klu_free_symbolic(&tmp, common_); + } + } + }; + struct NumericDeleter { + klu_common* common_ = nullptr; + void operator()(klu_numeric* ptr) const noexcept { + if(ptr != nullptr){ + klu_numeric* tmp = ptr; // klu_free_numeric takes a klu_numeric** + klu_free_numeric(&tmp, common_); + } + } + }; + + // solver initialization (declaration order matters, see note above) klu_common common_; + std::unique_ptr symbolic_; + std::unique_ptr numeric_; // no copy allowed KLULinearSolver(const KLULinearSolver&) = delete; From 1beb45c54467b209e539b96324476381ef098b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 10:51:45 +0000 Subject: [PATCH 063/166] Add tests for out-of-bounds handling in Python-exposed methods New test file lightsim2grid/tests/test_LSGrid_out_of_bounds.py asserts that the memory-safety-fixed LSGrid / TimeSeriesCPP methods now raise a Python exception on bad input instead of reading/writing out of bounds: - IndexError for out-of-range / negative element ids: deactivate_*/reactivate_*/ change_bus_* (loads, gens, sgens, storages, shunts, svc, powerlines, trafos), update_slack_weights_by_id, set_gen_regulated_bus (id and bus), change_ratio_trafo, change_shift_trafo(_deg), get_status_droop_hvdc; - RuntimeError for array shape / length mismatches: update_gens_p/loads_p/... (new_values vs has_changed), set_*_pos_topo_vect / set_*_to_subid (check_size), and TimeSeriesCPP.compute_Vs (injection-matrix column / row counts). A regression test checks the same methods still work for valid inputs. Built on pandapower case14 (no grid2op dependency). All 10 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- .../tests/test_LSGrid_out_of_bounds.py | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 lightsim2grid/tests/test_LSGrid_out_of_bounds.py diff --git a/lightsim2grid/tests/test_LSGrid_out_of_bounds.py b/lightsim2grid/tests/test_LSGrid_out_of_bounds.py new file mode 100644 index 00000000..d7c8fc44 --- /dev/null +++ b/lightsim2grid/tests/test_LSGrid_out_of_bounds.py @@ -0,0 +1,248 @@ +# Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Regression tests for the memory-safety fixes on the Python-exposed `LSGrid` / +`TimeSeriesCPP` methods. + +Before the fix, passing an out-of-range or negative element id (or a mis-shaped +array) to these methods reached unchecked C++ indexing (``operator[]`` / Eigen +``operator()`` / ``.col()``, whose bounds asserts are compiled out in release +builds) and could read or write out of bounds. They must now raise a clean Python +exception instead: + + * ``IndexError`` for out-of-range / negative element ids (``_check_in_range`` + and the ``LSGrid``-level bus check throw ``std::out_of_range``); + * ``RuntimeError`` for array shape / length mismatches (``check_size``, + ``update_continuous_values`` and ``TimeSeries::compute_Vs`` throw + ``std::runtime_error``). +""" + +import unittest +import warnings +import numpy as np + +import pandapower.networks as pn +from lightsim2grid.network import init_from_pandapower + +try: + from lightsim2grid.lightsim2grid_cpp import TimeSeriesCPP + TimeSeriesCPP_AVAILABLE = True +except ImportError: + TimeSeriesCPP_AVAILABLE = False + + +# an id far above any element count, but still inside the C `int` range +BIG = 10 ** 6 + + +class TestOutOfBoundsBindings(unittest.TestCase): + def setUp(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + net = pn.case14() + self.grid = init_from_pandapower(net) + + # element counts as seen by the *gridmodel* (e.g. the pandapower ext_grid + # is modelled as an extra generator, so these differ from len(net.gen)). + self.n_gen = len(self.grid.get_generators()) + self.n_sgen = len(self.grid.get_static_generators()) + self.n_load = len(self.grid.get_loads()) + self.n_shunt = len(self.grid.get_shunts()) + self.n_storage = len(self.grid.get_storages()) + self.n_line = len(self.grid.get_lines()) + self.n_trafo = len(self.grid.get_trafos()) + self.n_bus = self.grid.total_bus() + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + def _assert_raises_each(self, exc, calls): + """`calls` is an iterable of (description, callable); each must raise `exc`.""" + for descr, fun in calls: + with self.subTest(descr): + with self.assertRaises(exc): + fun() + + # ------------------------------------------------------------------ + # out-of-bounds READS: deactivate / reactivate / change_bus family + # ------------------------------------------------------------------ + def test_deactivate_reactivate_out_of_bounds(self): + # one-side elements (loads, gens, sgens, storages, shunts, svc) and + # two-side branches (powerlines, trafos). Empty containers (e.g. sgen / + # storage / svc in case14) still raise, so no special-casing is needed. + families = ["load", "gen", "sgen", "storage", "shunt", "svc", + "powerline", "trafo"] + calls = [] + for fam in families: + for bad in (-1, BIG): + calls.append((f"deactivate_{fam}({bad})", + lambda fam=fam, bad=bad: getattr(self.grid, f"deactivate_{fam}")(bad))) + calls.append((f"reactivate_{fam}({bad})", + lambda fam=fam, bad=bad: getattr(self.grid, f"reactivate_{fam}")(bad))) + self._assert_raises_each(IndexError, calls) + + def test_change_bus_out_of_bounds(self): + valid_bus = 1 # any in-range gridmodel bus, to isolate the element-id check + calls = [] + # one-side "change_bus_(el_id, new_bus)" + for fam in ["load", "gen", "sgen", "storage", "shunt", "svc"]: + for bad in (-1, BIG): + calls.append((f"change_bus_{fam}({bad})", + lambda fam=fam, bad=bad: getattr(self.grid, f"change_bus_{fam}")(bad, valid_bus))) + # two-side "change_bus{1,2}_(el_id, new_bus)" + for fam in ["powerline", "trafo"]: + for side in (1, 2): + for bad in (-1, BIG): + calls.append((f"change_bus{side}_{fam}({bad})", + lambda fam=fam, side=side, bad=bad: + getattr(self.grid, f"change_bus{side}_{fam}")(bad, valid_bus))) + self._assert_raises_each(IndexError, calls) + + # ------------------------------------------------------------------ + # out-of-bounds WRITES + # ------------------------------------------------------------------ + def test_update_slack_weights_by_id_out_of_bounds(self): + calls = [ + ("update_slack_weights_by_id([BIG])", + lambda: self.grid.update_slack_weights_by_id(np.array([BIG], dtype=np.int32))), + ("update_slack_weights_by_id([-1])", + lambda: self.grid.update_slack_weights_by_id(np.array([-1], dtype=np.int32))), + ] + self._assert_raises_each(IndexError, calls) + + def test_set_gen_regulated_bus_out_of_bounds(self): + calls = [ + # bad gen_id (valid bus) + ("set_gen_regulated_bus(-1, 0)", lambda: self.grid.set_gen_regulated_bus(-1, 0)), + ("set_gen_regulated_bus(BIG, 0)", lambda: self.grid.set_gen_regulated_bus(BIG, 0)), + # valid gen_id, bad bus_id + ("set_gen_regulated_bus(0, -1)", lambda: self.grid.set_gen_regulated_bus(0, -1)), + ("set_gen_regulated_bus(0, BIG)", lambda: self.grid.set_gen_regulated_bus(0, BIG)), + ] + self._assert_raises_each(IndexError, calls) + + def test_change_ratio_shift_trafo_out_of_bounds(self): + calls = [] + for name, arg in [("change_ratio_trafo", 1.0), + ("change_shift_trafo", 0.1), + ("change_shift_trafo_deg", 1.0)]: + for bad in (-1, BIG): + calls.append((f"{name}({bad})", + lambda name=name, arg=arg, bad=bad: getattr(self.grid, name)(bad, arg))) + self._assert_raises_each(IndexError, calls) + + # ------------------------------------------------------------------ + # out-of-bounds READ (getter) + # ------------------------------------------------------------------ + def test_get_status_droop_hvdc_out_of_bounds(self): + # case14 has no hvdc line; the empty-container bounds check still fires. + calls = [ + ("get_status_droop_hvdc(-1)", lambda: self.grid.get_status_droop_hvdc(-1)), + ("get_status_droop_hvdc(BIG)", lambda: self.grid.get_status_droop_hvdc(BIG)), + ] + self._assert_raises_each(IndexError, calls) + + # ------------------------------------------------------------------ + # array length / shape mismatches + # ------------------------------------------------------------------ + def test_update_continuous_values_length_mismatch(self): + # for each grid2op fast-update method, `new_values` must have the same + # length as `has_changed`; a shorter `new_values` used to over-read. + specs = [ + ("update_gens_p", self.n_gen), + ("update_gens_v", self.n_gen), + ("update_sgens_p", self.n_sgen), + ("update_loads_p", self.n_load), + ("update_loads_q", self.n_load), + ("update_storages_p", self.n_storage), + ] + calls = [] + for name, n in specs: + # has_changed longer than new_values by one -> mismatch (works for n == 0 too) + has_changed = np.zeros(n + 1, dtype=bool) + has_changed[-1] = True + new_values = np.zeros(n, dtype=np.float32) + calls.append((f"{name}(len mismatch)", + lambda name=name, hc=has_changed, nv=new_values: + getattr(self.grid, name)(hc, nv))) + self._assert_raises_each(RuntimeError, calls) + + def test_set_pos_topo_vect_and_subid_size_mismatch(self): + specs = [ + ("set_gen_pos_topo_vect", self.n_gen), + ("set_load_pos_topo_vect", self.n_load), + ("set_storage_pos_topo_vect", self.n_storage), + ("set_gen_to_subid", self.n_gen), + ("set_load_to_subid", self.n_load), + ("set_storage_to_subid", self.n_storage), + ("set_shunt_to_subid", self.n_shunt), + ] + calls = [] + for name, n in specs: + wrong = np.zeros(n + 1, dtype=np.int32) # one element too many + calls.append((f"{name}(size mismatch)", + lambda name=name, arr=wrong: getattr(self.grid, name)(arr))) + self._assert_raises_each(RuntimeError, calls) + + @unittest.skipUnless(TimeSeriesCPP_AVAILABLE, "TimeSeriesCPP not available") + def test_compute_Vs_shape_mismatch(self): + ts = TimeSeriesCPP(self.grid) + nb_steps = 3 + Vinit = np.ones(self.n_bus, dtype=complex) + + def mat(rows, cols): + return np.zeros((rows, cols), dtype=np.float64) + + # correctly-shaped matrices (baseline used to build the mis-shaped variants) + gen_p = mat(nb_steps, self.n_gen) + sgen_p = mat(nb_steps, self.n_sgen) + load_p = mat(nb_steps, self.n_load) + load_q = mat(nb_steps, self.n_load) + + calls = [ + # wrong number of columns (fewer gens than the grid has) + ("gen_p wrong #cols", + lambda: ts.compute_Vs(mat(nb_steps, self.n_gen - 1), sgen_p, load_p, load_q, Vinit, 10, 1e-8)), + ("load_p wrong #cols", + lambda: ts.compute_Vs(gen_p, sgen_p, mat(nb_steps, self.n_load - 1), load_q, Vinit, 10, 1e-8)), + # mismatched number of rows (time steps) between matrices + ("load_q wrong #rows", + lambda: ts.compute_Vs(gen_p, sgen_p, load_p, mat(nb_steps - 1, self.n_load), Vinit, 10, 1e-8)), + ] + self._assert_raises_each(RuntimeError, calls) + + # ------------------------------------------------------------------ + # regression: the same methods still work for valid inputs + # ------------------------------------------------------------------ + def test_valid_calls_still_work(self): + # these must NOT raise + self.grid.deactivate_load(0) + self.grid.reactivate_load(0) + self.grid.change_ratio_trafo(0, 1.0) + self.grid.change_shift_trafo(0, 0.0) + self.grid.set_gen_regulated_bus(0, 0) + + has_changed = np.zeros(self.n_gen, dtype=bool) + new_values = np.zeros(self.n_gen, dtype=np.float32) + self.grid.update_gens_p(has_changed, new_values) + + if TimeSeriesCPP_AVAILABLE: + ts = TimeSeriesCPP(self.grid) + nb_steps = 3 + Vinit = np.ones(self.n_bus, dtype=complex) + res = ts.compute_Vs(np.zeros((nb_steps, self.n_gen), dtype=np.float64), + np.zeros((nb_steps, self.n_sgen), dtype=np.float64), + np.zeros((nb_steps, self.n_load), dtype=np.float64), + np.zeros((nb_steps, self.n_load), dtype=np.float64), + Vinit, 10, 1e-8) + self.assertIsNotNone(res) + + +if __name__ == "__main__": + unittest.main() From d3a2e155497db9d5421b974a9b20443ccd8f27c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 10:51:45 +0000 Subject: [PATCH 064/166] Add tests for out-of-bounds handling in Python-exposed methods New test file lightsim2grid/tests/test_LSGrid_out_of_bounds.py asserts that the memory-safety-fixed LSGrid / TimeSeriesCPP methods now raise a Python exception on bad input instead of reading/writing out of bounds: - IndexError for out-of-range / negative element ids: deactivate_*/reactivate_*/ change_bus_* (loads, gens, sgens, storages, shunts, svc, powerlines, trafos), update_slack_weights_by_id, set_gen_regulated_bus (id and bus), change_ratio_trafo, change_shift_trafo(_deg), get_status_droop_hvdc; - RuntimeError for array shape / length mismatches: update_gens_p/loads_p/... (new_values vs has_changed), set_*_pos_topo_vect / set_*_to_subid (check_size), and TimeSeriesCPP.compute_Vs (injection-matrix column / row counts). A regression test checks the same methods still work for valid inputs. Built on pandapower case14 (no grid2op dependency). All 10 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017ptbLEZy5KUt4u8qkiRjmR Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- .../tests/test_LSGrid_out_of_bounds.py | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 lightsim2grid/tests/test_LSGrid_out_of_bounds.py diff --git a/lightsim2grid/tests/test_LSGrid_out_of_bounds.py b/lightsim2grid/tests/test_LSGrid_out_of_bounds.py new file mode 100644 index 00000000..d7c8fc44 --- /dev/null +++ b/lightsim2grid/tests/test_LSGrid_out_of_bounds.py @@ -0,0 +1,248 @@ +# Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Regression tests for the memory-safety fixes on the Python-exposed `LSGrid` / +`TimeSeriesCPP` methods. + +Before the fix, passing an out-of-range or negative element id (or a mis-shaped +array) to these methods reached unchecked C++ indexing (``operator[]`` / Eigen +``operator()`` / ``.col()``, whose bounds asserts are compiled out in release +builds) and could read or write out of bounds. They must now raise a clean Python +exception instead: + + * ``IndexError`` for out-of-range / negative element ids (``_check_in_range`` + and the ``LSGrid``-level bus check throw ``std::out_of_range``); + * ``RuntimeError`` for array shape / length mismatches (``check_size``, + ``update_continuous_values`` and ``TimeSeries::compute_Vs`` throw + ``std::runtime_error``). +""" + +import unittest +import warnings +import numpy as np + +import pandapower.networks as pn +from lightsim2grid.network import init_from_pandapower + +try: + from lightsim2grid.lightsim2grid_cpp import TimeSeriesCPP + TimeSeriesCPP_AVAILABLE = True +except ImportError: + TimeSeriesCPP_AVAILABLE = False + + +# an id far above any element count, but still inside the C `int` range +BIG = 10 ** 6 + + +class TestOutOfBoundsBindings(unittest.TestCase): + def setUp(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + net = pn.case14() + self.grid = init_from_pandapower(net) + + # element counts as seen by the *gridmodel* (e.g. the pandapower ext_grid + # is modelled as an extra generator, so these differ from len(net.gen)). + self.n_gen = len(self.grid.get_generators()) + self.n_sgen = len(self.grid.get_static_generators()) + self.n_load = len(self.grid.get_loads()) + self.n_shunt = len(self.grid.get_shunts()) + self.n_storage = len(self.grid.get_storages()) + self.n_line = len(self.grid.get_lines()) + self.n_trafo = len(self.grid.get_trafos()) + self.n_bus = self.grid.total_bus() + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + def _assert_raises_each(self, exc, calls): + """`calls` is an iterable of (description, callable); each must raise `exc`.""" + for descr, fun in calls: + with self.subTest(descr): + with self.assertRaises(exc): + fun() + + # ------------------------------------------------------------------ + # out-of-bounds READS: deactivate / reactivate / change_bus family + # ------------------------------------------------------------------ + def test_deactivate_reactivate_out_of_bounds(self): + # one-side elements (loads, gens, sgens, storages, shunts, svc) and + # two-side branches (powerlines, trafos). Empty containers (e.g. sgen / + # storage / svc in case14) still raise, so no special-casing is needed. + families = ["load", "gen", "sgen", "storage", "shunt", "svc", + "powerline", "trafo"] + calls = [] + for fam in families: + for bad in (-1, BIG): + calls.append((f"deactivate_{fam}({bad})", + lambda fam=fam, bad=bad: getattr(self.grid, f"deactivate_{fam}")(bad))) + calls.append((f"reactivate_{fam}({bad})", + lambda fam=fam, bad=bad: getattr(self.grid, f"reactivate_{fam}")(bad))) + self._assert_raises_each(IndexError, calls) + + def test_change_bus_out_of_bounds(self): + valid_bus = 1 # any in-range gridmodel bus, to isolate the element-id check + calls = [] + # one-side "change_bus_(el_id, new_bus)" + for fam in ["load", "gen", "sgen", "storage", "shunt", "svc"]: + for bad in (-1, BIG): + calls.append((f"change_bus_{fam}({bad})", + lambda fam=fam, bad=bad: getattr(self.grid, f"change_bus_{fam}")(bad, valid_bus))) + # two-side "change_bus{1,2}_(el_id, new_bus)" + for fam in ["powerline", "trafo"]: + for side in (1, 2): + for bad in (-1, BIG): + calls.append((f"change_bus{side}_{fam}({bad})", + lambda fam=fam, side=side, bad=bad: + getattr(self.grid, f"change_bus{side}_{fam}")(bad, valid_bus))) + self._assert_raises_each(IndexError, calls) + + # ------------------------------------------------------------------ + # out-of-bounds WRITES + # ------------------------------------------------------------------ + def test_update_slack_weights_by_id_out_of_bounds(self): + calls = [ + ("update_slack_weights_by_id([BIG])", + lambda: self.grid.update_slack_weights_by_id(np.array([BIG], dtype=np.int32))), + ("update_slack_weights_by_id([-1])", + lambda: self.grid.update_slack_weights_by_id(np.array([-1], dtype=np.int32))), + ] + self._assert_raises_each(IndexError, calls) + + def test_set_gen_regulated_bus_out_of_bounds(self): + calls = [ + # bad gen_id (valid bus) + ("set_gen_regulated_bus(-1, 0)", lambda: self.grid.set_gen_regulated_bus(-1, 0)), + ("set_gen_regulated_bus(BIG, 0)", lambda: self.grid.set_gen_regulated_bus(BIG, 0)), + # valid gen_id, bad bus_id + ("set_gen_regulated_bus(0, -1)", lambda: self.grid.set_gen_regulated_bus(0, -1)), + ("set_gen_regulated_bus(0, BIG)", lambda: self.grid.set_gen_regulated_bus(0, BIG)), + ] + self._assert_raises_each(IndexError, calls) + + def test_change_ratio_shift_trafo_out_of_bounds(self): + calls = [] + for name, arg in [("change_ratio_trafo", 1.0), + ("change_shift_trafo", 0.1), + ("change_shift_trafo_deg", 1.0)]: + for bad in (-1, BIG): + calls.append((f"{name}({bad})", + lambda name=name, arg=arg, bad=bad: getattr(self.grid, name)(bad, arg))) + self._assert_raises_each(IndexError, calls) + + # ------------------------------------------------------------------ + # out-of-bounds READ (getter) + # ------------------------------------------------------------------ + def test_get_status_droop_hvdc_out_of_bounds(self): + # case14 has no hvdc line; the empty-container bounds check still fires. + calls = [ + ("get_status_droop_hvdc(-1)", lambda: self.grid.get_status_droop_hvdc(-1)), + ("get_status_droop_hvdc(BIG)", lambda: self.grid.get_status_droop_hvdc(BIG)), + ] + self._assert_raises_each(IndexError, calls) + + # ------------------------------------------------------------------ + # array length / shape mismatches + # ------------------------------------------------------------------ + def test_update_continuous_values_length_mismatch(self): + # for each grid2op fast-update method, `new_values` must have the same + # length as `has_changed`; a shorter `new_values` used to over-read. + specs = [ + ("update_gens_p", self.n_gen), + ("update_gens_v", self.n_gen), + ("update_sgens_p", self.n_sgen), + ("update_loads_p", self.n_load), + ("update_loads_q", self.n_load), + ("update_storages_p", self.n_storage), + ] + calls = [] + for name, n in specs: + # has_changed longer than new_values by one -> mismatch (works for n == 0 too) + has_changed = np.zeros(n + 1, dtype=bool) + has_changed[-1] = True + new_values = np.zeros(n, dtype=np.float32) + calls.append((f"{name}(len mismatch)", + lambda name=name, hc=has_changed, nv=new_values: + getattr(self.grid, name)(hc, nv))) + self._assert_raises_each(RuntimeError, calls) + + def test_set_pos_topo_vect_and_subid_size_mismatch(self): + specs = [ + ("set_gen_pos_topo_vect", self.n_gen), + ("set_load_pos_topo_vect", self.n_load), + ("set_storage_pos_topo_vect", self.n_storage), + ("set_gen_to_subid", self.n_gen), + ("set_load_to_subid", self.n_load), + ("set_storage_to_subid", self.n_storage), + ("set_shunt_to_subid", self.n_shunt), + ] + calls = [] + for name, n in specs: + wrong = np.zeros(n + 1, dtype=np.int32) # one element too many + calls.append((f"{name}(size mismatch)", + lambda name=name, arr=wrong: getattr(self.grid, name)(arr))) + self._assert_raises_each(RuntimeError, calls) + + @unittest.skipUnless(TimeSeriesCPP_AVAILABLE, "TimeSeriesCPP not available") + def test_compute_Vs_shape_mismatch(self): + ts = TimeSeriesCPP(self.grid) + nb_steps = 3 + Vinit = np.ones(self.n_bus, dtype=complex) + + def mat(rows, cols): + return np.zeros((rows, cols), dtype=np.float64) + + # correctly-shaped matrices (baseline used to build the mis-shaped variants) + gen_p = mat(nb_steps, self.n_gen) + sgen_p = mat(nb_steps, self.n_sgen) + load_p = mat(nb_steps, self.n_load) + load_q = mat(nb_steps, self.n_load) + + calls = [ + # wrong number of columns (fewer gens than the grid has) + ("gen_p wrong #cols", + lambda: ts.compute_Vs(mat(nb_steps, self.n_gen - 1), sgen_p, load_p, load_q, Vinit, 10, 1e-8)), + ("load_p wrong #cols", + lambda: ts.compute_Vs(gen_p, sgen_p, mat(nb_steps, self.n_load - 1), load_q, Vinit, 10, 1e-8)), + # mismatched number of rows (time steps) between matrices + ("load_q wrong #rows", + lambda: ts.compute_Vs(gen_p, sgen_p, load_p, mat(nb_steps - 1, self.n_load), Vinit, 10, 1e-8)), + ] + self._assert_raises_each(RuntimeError, calls) + + # ------------------------------------------------------------------ + # regression: the same methods still work for valid inputs + # ------------------------------------------------------------------ + def test_valid_calls_still_work(self): + # these must NOT raise + self.grid.deactivate_load(0) + self.grid.reactivate_load(0) + self.grid.change_ratio_trafo(0, 1.0) + self.grid.change_shift_trafo(0, 0.0) + self.grid.set_gen_regulated_bus(0, 0) + + has_changed = np.zeros(self.n_gen, dtype=bool) + new_values = np.zeros(self.n_gen, dtype=np.float32) + self.grid.update_gens_p(has_changed, new_values) + + if TimeSeriesCPP_AVAILABLE: + ts = TimeSeriesCPP(self.grid) + nb_steps = 3 + Vinit = np.ones(self.n_bus, dtype=complex) + res = ts.compute_Vs(np.zeros((nb_steps, self.n_gen), dtype=np.float64), + np.zeros((nb_steps, self.n_sgen), dtype=np.float64), + np.zeros((nb_steps, self.n_load), dtype=np.float64), + np.zeros((nb_steps, self.n_load), dtype=np.float64), + Vinit, 10, 1e-8) + self.assertIsNotNone(res) + + +if __name__ == "__main__": + unittest.main() From 6d83a38f450422244427076dc2e08ad9ad9f6d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 16:58:54 +0000 Subject: [PATCH 065/166] Harden binary serialization against ill-formed input Robustness improvements to save_binary/load_binary (BinaryArchive): - version compatibility is now decided by a dedicated binary format version (BINARY_FORMAT_VERSION, stored in the file header) instead of requiring the exact same lightsim2grid version: releases sharing the same format number read each other's files, and an unsupported format raises a RuntimeError naming both versions and format numbers - every count/length field read from a file is validated against the real file size before any allocation, so a corrupted count raises a clean RuntimeError instead of a MemoryError or a huge allocation - the header now stores the object type tag: loading a file into the wrong class (eg a LoadContainer file via StorageContainer.load_binary, whose StateRes layouts are identical) is rejected instead of silently succeeding - trailing bytes after the end of the data are rejected as corruption - save_binary is now atomic: it writes to a temporary file renamed over the destination only on success, so an interrupted save never destroys a previously saved file A committed reference file (lightsim2grid/tests/binary_format_fixture/) guards against StateRes layout changes made without bumping BINARY_FORMAT_VERSION, and each StateRes definition carries a reminder comment. Pickle behaviour is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 33 ++- docs/binary_serialization.rst | 27 +- .../case14_sandbox_format1.lsb | Bin 0 -> 4731 bytes .../tests/test_binary_serialization.py | 241 ++++++++++++++++-- src/bindings/python/binary_helpers.hpp | 19 +- src/core/BinaryArchive.cpp | 157 +++++++++++- src/core/BinaryArchive.hpp | 145 +++++++++-- src/core/LSGrid.cpp | 9 + src/core/LSGrid.hpp | 13 +- src/core/SubstationContainer.hpp | 3 + .../ConverterStationContainer.hpp | 2 + .../element_container/GeneratorContainer.hpp | 2 + .../element_container/HvdcLineContainer.hpp | 2 + src/core/element_container/LineContainer.hpp | 2 + src/core/element_container/LoadContainer.hpp | 2 + .../element_container/OneSideContainer.hpp | 2 + .../element_container/OneSideContainer_PQ.hpp | 2 + .../OneSideContainer_forBranch.hpp | 2 + src/core/element_container/SGenContainer.hpp | 2 + src/core/element_container/ShuntContainer.hpp | 2 + .../element_container/StorageContainer.hpp | 2 + src/core/element_container/SvcContainer.hpp | 3 + src/core/element_container/TrafoContainer.hpp | 2 + .../element_container/TwoSidesContainer.hpp | 2 + .../TwoSidesContainer_rxh_A.hpp | 1 + 25 files changed, 606 insertions(+), 71 deletions(-) create mode 100644 lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 31b760fd..0fdf2963 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -335,13 +335,32 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. container that has its own state (*eg* `gridmodel.get_loads().save_binary(...)`), mirroring what is already picklable. This is **not** a replacement for pickle: it trades portability for speed, meant for repeatedly re-loading the *same* grid on the - *same* machine / lightsim2grid build. **NB** as with pickle, a file can only be - reloaded with the exact same lightsim2grid version it was saved with; a version - mismatch or a corrupted / truncated file raises a `RuntimeError` (no cross-version - migration is attempted). See the new "Fast binary serialization" documentation page - and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against - pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster - to read than pickle on grids with ~9000 buses). + *same* machine / lightsim2grid build. See the new "Fast binary serialization" + documentation page and `benchmarks/benchmark_binary_serialization.py` for speed + comparisons against pickle (the speed up grows with grid size: up to ~17x faster to + write and ~8x faster to read than pickle on grids with ~9000 buses). +- [IMPROVED] robustness of `save_binary` / `load_binary` against ill-formed input: + + - version compatibility is now decided by a dedicated **binary format version** + (`BINARY_FORMAT_VERSION` in `src/core/BinaryArchive.hpp`, stored in the file + header) instead of requiring the exact same lightsim2grid version: the format + number is only bumped when the serialized layout changes, so all lightsim2grid + versions sharing the same format number read each other's files. An unsupported + format raises a `RuntimeError` naming both versions and format numbers. A + committed reference file (`lightsim2grid/tests/binary_format_fixture/`) guards + against layout changes made without bumping the format number. + - every count / length field read from a file is now validated against the real + file size *before* any allocation: a corrupted count raises a clean + `RuntimeError` instead of a `MemoryError` (or worse, an actual multi-gigabyte + allocation). + - the file header now stores the **object type** (*eg* `"LoadContainer"`): + loading a file into the wrong class raises a `RuntimeError` instead of silently + succeeding when the layouts happen to match (*eg* `LoadContainer` vs + `StorageContainer`). + - trailing bytes after the end of the data are now rejected as corruption. + - `save_binary` is now **atomic**: it writes to a temporary file that only + replaces the destination once fully written, so an interrupted save (crash, + full disk, ...) never destroys a previously saved file. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 0b91d6a2..10a91c20 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -46,18 +46,33 @@ Individual element containers work the same way: from lightsim2grid.elements import LoadContainer loads_reloaded = LoadContainer.load_binary("my_loads.lsb") +.. note:: + Version compatibility is decided by a **binary format version** (an integer stored in the file + header, see ``BINARY_FORMAT_VERSION`` in ``src/core/BinaryArchive.hpp``), not by the lightsim2grid + version: the format number is only bumped when the serialized layout actually changes, so all + lightsim2grid versions sharing the same format number can read each other's files. Loading a file + with an unsupported format number raises a ``RuntimeError`` naming both versions and both format + numbers. + .. warning:: - ``load_binary`` performs a **hard version check**: the file can only be reloaded with the exact same - lightsim2grid version (major.medium.minor) that was used to write it. As opposed to pickle, there is - no attempt at cross-version migration: a mismatch always raises a ``RuntimeError``. The same is true - of a corrupted or truncated file: ``load_binary`` raises a ``RuntimeError`` rather than crashing or - silently returning garbage. + ``load_binary`` validates its input and always raises a ``RuntimeError`` (rather than crashing, + exhausting memory, or silently returning garbage) on ill-formed files: garbage or truncated files, + corrupted internal sizes (every count in the file is checked against the real file size *before* + anything is allocated), files that contain an object of a different type (*eg* loading a + ``LoadContainer`` file with ``StorageContainer.load_binary``), and files with unexpected trailing + bytes are all rejected. ``save_binary`` on the other hand is **atomic**: it writes to a temporary + file that only replaces the destination once fully written, so an interrupted save never destroys + a previously saved file. Note that the format stores raw native data: files are meant to be written + and read by builds sharing the same data layout (same endianness, ``real_type``, ...) -- there is + no checksum nor cross-platform migration, pickle remains the portable format. .. note:: The binary format walks the exact same internal state (``get_state`` / ``set_state``) that pickle uses, so the two stay structurally in sync: anything that can be pickled can be saved with ``save_binary``. See ``lightsim2grid/tests/test_binary_serialization.py`` for the full test suite - (round trip, AC/DC powerflow round trip, version mismatch, corrupted file). + (round trip, AC/DC powerflow round trip, format mismatch, cross-version load, corrupted / truncated / + wrong-type files, atomicity, and a committed reference file guarding the layout against accidental + changes). .. _binary_serialization_benchmarks: diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb new file mode 100644 index 0000000000000000000000000000000000000000..eac26c82f11f96e4a0903bba601a91ed6976843f GIT binary patch literal 4731 zcmeHKZBUd|6yAkIQUt><6iP@*G|_$c-CY(d@1`QdfE6;0IyGG=BAc-Y9Y&=uo1C$k zD2EhBG#eZ%Ga@y!BvW=$8Bu1!nj!s%OvOxPU>`J1KkPjBea~fg2Wlt#_>pJk+4G!p z?>*<-ckg@lZc)hsx6|Qp#1V>0@>f4v4$HuYOMS!;%Qiwk$)V)+C+-;a#h!j)St|l* zw%-{hw$oOLq!EiHnl(H}tqGI`qS6kJHP12I)>*b@f0X)P%!g+K89`W6{h)L)F_^fS zm`prOyi9yda+vs;xkw z8d9~n$)jpB$>T!`FQu0`HZ!E(W^Sp<^UGWsA4&84(lDiAfWjpWcRW$UAVMRGgGj@} z#f6B?sbg5#ZP;G-C3`u*osD4;uy7F(P)W{WRyV#@3rf zG2;kazo?h=9IJd7!ta+&BljNkg zL{<9q(c6Q~;>iBk-rB5Yk(Bq`{JI$j#n>8O)BI&Giqu(czwW%}h&cMgiq}T2Z53aY z?e6mQ9T0t~^2v&VMsa+}vjfxef}&)^o>%5M4~wzWHs+`1cZi(sA5X+zJS8}PeC(&k zHWj`r?%R4HeM@bJC|G&6W&E;sQF*lU{=Ic=qVoI)S%>ay6Riu28}I(|lvq>Plu@<$ z4RLJgwv3mn+r_-)S9)EtQ*3p8KCu6tpr}lKK6qyLVR3e&{HAxwIdNfaUVm%-X)*Bq z=3m~fZx@YkZfpKIuS@5rN1nqb~s+jx-{mvt3liAvNNXsv}&8SUp<+A zVD-v6ZI@5(Sw88*L3_@^v~PQSHF})${MVYFJJ<1l**EVuQlnWw(5;x$JF9$_*QSHR z?BGe3Z3Z1qbQ}n}jwyZ8@LG)euegFe`a_naFK z0-{zPv-%5LL(SAxjiqHb5GE4R2{Q;R93V72el}q9Sa_j_woI|44>Hxi~1uz&*)4R#bQ4JiFxF?7H*7eGf!V?#@EAY@(S z_#fK3AZr65VM-6KSLca!V?)DboU=%+#zWT8ltsbtoGG>)t~YEt5e#2f5U|!QvbBFm zP3yw9tM_27j`p)(aQ5N8Tzo+g^wd?AW#u?Wn$tnj+?49_tmb&6x;!iHl^#9EFTI-Q zN+0fJR!q9B4;pk~A2jIBj=Q8ajc(#DE{F@}#*Y(;;8+@Z`^0Tiud$CPl=jg$PF*x{ z^dcm}RW_M0iEtAki*PGp4#6OJ34Q`6B1+;5C>6XHy0qf!#g^?zvvn3U9rN3vODife zph*$}N;xJ%lYb^Oox|)vNPP0og+E{(x(k}(3(GdgK?gfJ(rs}Y_rh-~$*VT%1*-yLD|Z~tJ#-vNTC Bxv2mE literal 0 HcmV?d00001 diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 7442c66d..44fdc2ff 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -218,11 +218,61 @@ def test_binary_algo_config(self): assert list(dc_cfg_1.int_params) == list(dc_cfg.int_params) assert np.allclose(dc_cfg_1.real_params, dc_cfg.real_params) - def test_cannot_load_wrong_version(self): + @staticmethod + def _header_end(data): + """Return the offset of the first state byte. + + File header layout (stable across binary format versions): 4-byte + magic "LSB2", uint32 little-endian binary format version, then 4 + length-prefixed strings (uint32 little-endian length + raw bytes): + type tag, major / medium / minor writer version. + """ + off = 4 + 4 # magic + format version + for _ in range(4): # type tag + 3 version strings + (str_len,) = struct.unpack_from(" 0, "ac powerflow diverged on the reference grid" + + def test_roundtrip_matches_reference_env(self): + """The fixture must stay equivalent to a freshly-built grid of the + same environment (guards against regenerating it with wrong data).""" + from lightsim2grid.network import LSGrid + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make(FIXTURE_ENV_NAME, test=True, backend=LightSimBackend()) + grid_ref = env.backend._grid + grid_fixture = LSGrid.load_binary(FIXTURE_PATH) + assert len(compare_network_input(grid_ref, grid_fixture)) == 0 + + +def _regenerate_fixture(): + """Manual helper: regenerate the committed reference file after a + deliberate BINARY_FORMAT_VERSION bump (never for any other reason).""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make(FIXTURE_ENV_NAME, test=True, backend=LightSimBackend()) + os.makedirs(os.path.dirname(FIXTURE_PATH), exist_ok=True) + env.backend._grid.save_binary(FIXTURE_PATH) + print(f"fixture regenerated: {FIXTURE_PATH}") + print("update the FIXTURE_* expected values and the file name if the format number changed") + + if __name__ == "__main__": - unittest.main() + import sys + if len(sys.argv) > 1 and sys.argv[1] == "regen": + _regenerate_fixture() + else: + unittest.main() diff --git a/src/bindings/python/binary_helpers.hpp b/src/bindings/python/binary_helpers.hpp index 063d3a29..985761c1 100644 --- a/src/bindings/python/binary_helpers.hpp +++ b/src/bindings/python/binary_helpers.hpp @@ -15,10 +15,13 @@ namespace py = pybind11; // Helper: attach save_binary()/load_binary() python methods to any container -// that exposes get_state()/set_state() and a nested StateRes type (the same -// contract used by add_pickle in pickle_helpers.hpp). This is an additive, -// faster alternative to pickle -- NOT a replacement, and NOT cross-version -// compatible (a version mismatch is a hard failure, see BinaryArchive.hpp). +// that exposes get_state()/set_state(), a nested StateRes type (the same +// contract used by add_pickle in pickle_helpers.hpp) and binary_type_tag(). +// This is an additive, faster alternative to pickle -- NOT a replacement. +// Files are readable by any lightsim2grid version sharing the same +// BINARY_FORMAT_VERSION (see BinaryArchive.hpp), and ill-formed input +// (garbage, truncation, corrupted sizes, wrong object type, trailing bytes) +// raises RuntimeError instead of crashing or over-allocating. // // These lambdas call ls2g::save_binary_generic/load_binary_generic directly // (rather than T::save_binary/T::load_binary): VERSION_MAJOR/MEDIUM/MINOR are @@ -31,12 +34,16 @@ void add_binary_serialization(py::class_& cls) { ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); }, py::arg("path"), "Save this object's state to a fast custom binary file (additive alternative " - "to pickle: faster, but NOT portable across lightsim2grid versions)."); + "to pickle). The write is atomic: an existing file at that path is only " + "replaced once the new content has been written completely. The file stays " + "readable by any lightsim2grid version sharing the same binary format number."); cls.def_static("load_binary", [](const std::string& path) { return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); }, py::arg("path"), "Load an object previously saved with save_binary(). Raises RuntimeError on " - "a version mismatch or a corrupted / truncated file."); + "an incompatible binary format, a wrong object type, or a corrupted / " + "truncated file (including corrupted internal sizes: no attempt is made to " + "allocate more data than the file actually contains)."); } #endif // BINARY_HELPERS_HPP diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index c9cc2efd..a8ace1ab 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -8,6 +8,7 @@ #include "BinaryArchive.hpp" +#include // std::remove, std::rename #include #include #include @@ -15,27 +16,98 @@ namespace ls2g { namespace { -const char MAGIC[4] = {'L', 'S', 'B', '1'}; + +const char MAGIC[4] = {'L', 'S', 'B', '2'}; + +// Binary format versions this build can load. Almost always just the +// current one: reading an older format requires dedicated migration code +// (the StateRes tuples are read structurally, with the current layout). +// If such code is ever written, add the older format number here. +const std::uint32_t SUPPORTED_BINARY_FORMATS[] = {BINARY_FORMAT_VERSION}; + +bool is_format_supported(std::uint32_t format) +{ + for (std::uint32_t f : SUPPORTED_BINARY_FORMATS) { + if (f == format) return true; + } + return false; +} + +std::string supported_formats_str() +{ + std::ostringstream oss; + bool first = true; + for (std::uint32_t f : SUPPORTED_BINARY_FORMATS) { + if (!first) oss << ", "; + oss << f; + first = false; + } + return oss.str(); +} + } // anonymous namespace BinaryArchive::BinaryArchive(const std::string & path, Mode mode): mode_(mode), - path_(path) + path_(path), + tmp_path_(), + committed_(false), + file_size_(0) { if (mode_ == Mode::Write) { - ofs_.open(path_, std::ios::binary | std::ios::out | std::ios::trunc); + // write to a temporary file: the destination is only replaced in + // commit(), after the whole state has been written successfully. + tmp_path_ = path_ + ".lsb_tmp"; + ofs_.open(tmp_path_, std::ios::binary | std::ios::out | std::ios::trunc); if (!ofs_.is_open()) { - throw std::runtime_error("BinaryArchive: cannot open file for writing: '" + path_ + "'"); + throw std::runtime_error( + "BinaryArchive: cannot open file for writing: '" + path_ + + "' (temporary file '" + tmp_path_ + "' could not be created)"); } } else { ifs_.open(path_, std::ios::binary | std::ios::in); if (!ifs_.is_open()) { throw std::runtime_error("BinaryArchive: cannot open file for reading: '" + path_ + "'"); } + // capture the file size once: every count / length field read from + // the file is validated against it before any allocation. + ifs_.seekg(0, std::ios::end); + file_size_ = static_cast(ifs_.tellg()); + ifs_.seekg(0, std::ios::beg); } } -BinaryArchive::~BinaryArchive() = default; +BinaryArchive::~BinaryArchive() +{ + if (mode_ == Mode::Write && !committed_) { + // interrupted write (exception during serialization, or commit() + // failed): drop the temporary file, the destination is untouched. + if (ofs_.is_open()) ofs_.close(); + std::remove(tmp_path_.c_str()); + } +} + +void BinaryArchive::commit() +{ + if (mode_ != Mode::Write) { + throw std::runtime_error("BinaryArchive: commit() called on a read-mode archive for '" + path_ + "'"); + } + ofs_.flush(); + if (!ofs_) { + throw std::runtime_error("BinaryArchive: failed to write to file '" + path_ + "'"); + } + ofs_.close(); + // atomically replace the destination. std::rename overwrites on POSIX + // but fails on Windows when the destination exists, so remove it first + // (failure to remove is ignored: rename below reports the real error). + std::remove(path_.c_str()); + if (std::rename(tmp_path_.c_str(), path_.c_str()) != 0) { + throw std::runtime_error( + "BinaryArchive: could not move the temporary file '" + tmp_path_ + + "' to its final destination '" + path_ + "'"); + } + committed_ = true; +} void BinaryArchive::write_raw(const void * data, std::size_t nbytes) { @@ -57,6 +129,27 @@ void BinaryArchive::read_raw(void * data, std::size_t nbytes) } } +std::uint64_t BinaryArchive::remaining_bytes() +{ + const std::uint64_t pos = static_cast(ifs_.tellg()); + return (pos <= file_size_) ? (file_size_ - pos) : 0; +} + +void BinaryArchive::require_count(std::uint64_t count, std::uint64_t elem_size) +{ + // division (not multiplication) so a huge corrupted count cannot + // overflow back into an "acceptable" byte total + if (elem_size == 0) return; + if (count > remaining_bytes() / elem_size) { + std::ostringstream oss; + oss << "BinaryArchive: invalid file '" << path_ << "': it declares a block of " + << count << " element(s) of " << elem_size << " byte(s), but only " + << remaining_bytes() << " byte(s) remain in the file. " + << "The file is truncated or corrupted."; + throw std::runtime_error(oss.str()); + } +} + void BinaryArchive::write_magic() { write_raw(MAGIC, sizeof(MAGIC)); @@ -73,28 +166,60 @@ void BinaryArchive::check_magic() } } -void BinaryArchive::write_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor) +void BinaryArchive::write_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { write_magic(); + write_scalar(BINARY_FORMAT_VERSION); + write_string(type_tag); write_string(v_major); write_string(v_medium); write_string(v_minor); } -void BinaryArchive::check_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor) +void BinaryArchive::check_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { check_magic(); - std::string file_major, file_medium, file_minor; + // the header layout (magic, format, tag, writer version) is a stable + // contract across binary format versions: it can be parsed even when + // the format number does not match, to produce a precise error below. + std::uint32_t file_format = 0; + read_scalar(file_format); + std::string file_tag, file_major, file_medium, file_minor; + read_string(file_tag); read_string(file_major); read_string(file_medium); read_string(file_minor); - if (file_major != v_major || file_medium != v_medium || file_minor != v_minor) { + if (!is_format_supported(file_format)) { + std::ostringstream oss; + oss << "BinaryArchive: incompatible file '" << path_ << "': it was written by lightsim2grid version " + << file_major << "." << file_medium << "." << file_minor + << " using binary format " << file_format + << ", but the currently installed lightsim2grid (version " + << v_major << "." << v_medium << "." << v_minor + << ") only supports binary format(s) {" << supported_formats_str() << "}. " + << "Re-save the file with a compatible lightsim2grid version (the binary format only " + << "changes when the serialized layout changes, so versions sharing the same format " + << "number can read each other's files)."; + throw std::runtime_error(oss.str()); + } + if (file_tag != type_tag) { + std::ostringstream oss; + oss << "BinaryArchive: wrong object type in file '" << path_ << "': it contains a '" + << file_tag << "', not a '" << type_tag << "'."; + throw std::runtime_error(oss.str()); + } +} + +void BinaryArchive::check_fully_consumed() +{ + const std::uint64_t left = remaining_bytes(); + if (left != 0) { std::ostringstream oss; - oss << "BinaryArchive: version mismatch when loading '" << path_ << "'. " - << "This file was saved with lightsim2grid version " << file_major << "." << file_medium << "." << file_minor - << ", but the currently installed version is " << v_major << "." << v_medium << "." << v_minor - << ". Binary files can only be reloaded with the exact same lightsim2grid version they were saved with " - << "(unlike pickle, this format does not attempt any cross-version migration)."; + oss << "BinaryArchive: invalid file '" << path_ << "': " << left + << " unexpected byte(s) remain after the end of the data. " + << "The file is corrupted, or was written by an incompatible build."; throw std::runtime_error(oss.str()); } } @@ -110,6 +235,7 @@ void BinaryArchive::read_string(std::string & s) { std::uint32_t len = 0; read_scalar(len); + require_count(len, 1); s.resize(len); if (len) read_raw(&s[0], len); } @@ -127,6 +253,7 @@ void BinaryArchive::read_bool_vector(std::vector & v) { std::uint64_t n = 0; read_scalar(n); + require_count(n, 1); std::vector buf(static_cast(n)); if (!buf.empty()) read_raw(buf.data(), buf.size()); v.resize(static_cast(n)); @@ -144,6 +271,8 @@ void BinaryArchive::read_string_vector(std::vector & v) { std::uint64_t n = 0; read_scalar(n); + // each string takes at least its own 4-byte length prefix + require_count(n, sizeof(std::uint32_t)); v.clear(); v.reserve(static_cast(n)); for (std::uint64_t i = 0; i < n; ++i) { diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index c376ea3f..0d271d9d 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -15,6 +15,27 @@ // replace pickle: it trades portability for speed (no python-level // marshalling, raw contiguous writes for vector data). // +// Robustness guarantees (all failures are std::runtime_error naming the +// file, never a crash, a std::bad_alloc or an out-of-memory situation): +// - a magic number rejects files that are not lightsim2grid binary files; +// - a *binary format version* (see BINARY_FORMAT_VERSION below) decides +// compatibility across lightsim2grid releases; +// - a type tag rejects loading a file into the wrong class (eg a +// LoadContainer file into StorageContainer.load_binary, whose StateRes +// layouts are otherwise identical); +// - every count/length field read from the file is checked against the +// actual file size *before* any allocation, so a corrupted count cannot +// trigger a multi-gigabyte allocation; +// - after the state is fully read, the file must be fully consumed +// (trailing bytes are treated as corruption); +// - writes go to a temporary file that is atomically renamed over the +// destination only on success, so a crash / full disk mid-save never +// destroys a previously good file. +// NOT covered (possible future work): a payload checksum (bit flips inside +// numeric data that keep all counts consistent load silently), and an +// ABI descriptor (endianness / sizeof(real_type)): files are assumed to be +// written and read by builds with the same data layout. +// // C++14 only (see project policy, eg LSGrid.hpp): no `if constexpr`, no // `std::apply`, no fold expressions. The recursive walk over an arbitrary // StateRes tuple is done via partial-specialization of the `ValueArchiver` @@ -37,16 +58,43 @@ namespace ls2g { +// Version of the binary *format* itself, decoupled from the lightsim2grid +// package version: all lightsim2grid releases sharing the same format number +// can read each other's binary files (most releases do not touch the +// serialized layout at all). The lightsim2grid version that wrote a file is +// still stored in its header, but only for diagnostics / error messages. +// +// >>> BUMP THIS NUMBER whenever anything about the serialized layout +// >>> changes: any StateRes tuple (fields added / removed / reordered / +// >>> retyped, in LSGrid or any container), or the encoding in this file. +// A bumped format makes older files fail to load with a clear error instead +// of being silently mis-read. If a future version wants to keep reading an +// older format, add that format number to SUPPORTED_BINARY_FORMATS (in +// BinaryArchive.cpp) together with the required migration code. +constexpr std::uint32_t BINARY_FORMAT_VERSION = 1; + class LS2G_API BinaryArchive { public: enum class Mode { Write, Read }; + // Write mode writes to a temporary file (path + ".lsb_tmp"); the + // data only replaces `path` when commit() is called after a fully + // successful write. Read mode opens `path` directly and captures + // its size for the bounds checks below. BinaryArchive(const std::string & path, Mode mode); ~BinaryArchive(); BinaryArchive(const BinaryArchive &) = delete; BinaryArchive & operator=(const BinaryArchive &) = delete; + // Write mode only: flush + close the temporary file and atomically + // rename it over the destination path. Throws std::runtime_error on + // any failure (the destination is left untouched in that case). If + // commit() is never called (eg an exception during the write), the + // destructor removes the temporary file and the destination keeps + // its previous content. + void commit(); + // low level: the only methods that touch the underlying stream. // Both throw std::runtime_error (including the file path) on any // stream failure, which uniformly covers "file does not exist", @@ -54,22 +102,41 @@ class LS2G_API BinaryArchive void write_raw(const void * data, std::size_t nbytes); void read_raw(void * data, std::size_t nbytes); - // magic number ("LSB1"), to catch garbage files early + // magic number ("LSB2"), to catch garbage files early void write_magic(); void check_magic(); // throws std::runtime_error on mismatch - // magic + 3 length-prefixed version strings. Version strings are - // passed in explicitly by the caller (never read from the - // VERSION_MAJOR/MEDIUM/MINOR macros in this shared header): those - // macros are only defined via target_compile_definitions on the - // python bindings target, not on lightsim2grid_core, so referencing - // them here would silently embed different values (even risk an ODR - // violation) depending on which translation unit instantiates this - // header first. Callers (each container's own .cpp, or - // binary_helpers.hpp on the bindings side) supply the macros - // themselves, each from a single, consistent translation unit. - void write_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor); - void check_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor); // throws std::runtime_error on any mismatch + // Full header: magic, BINARY_FORMAT_VERSION (uint32), type tag + // (length-prefixed string, eg "LoadContainer"), then 3 + // length-prefixed strings with the lightsim2grid version that wrote + // the file. This header layout is a stable contract across all + // binary format versions (so any version can at least *parse* the + // header of any file and produce a precise error message); only the + // state payload after the header is governed by the format number. + // + // check_header() throws std::runtime_error when the file's format + // version is not supported by this build, or when the type tag does + // not match. The writer version strings are NOT compared: two + // different lightsim2grid versions sharing the same binary format + // read each other's files. + // + // Version strings are passed in explicitly by the caller (never read + // from the VERSION_MAJOR/MEDIUM/MINOR macros in this shared header): + // those macros are only defined via target_compile_definitions on + // the python bindings target, not on lightsim2grid_core, so + // referencing them here would silently embed different values (even + // risk an ODR violation) depending on which translation unit + // instantiates this header first. Callers (each container's own + // .cpp, or binary_helpers.hpp on the bindings side) supply the + // macros themselves, each from a single, consistent translation unit. + void write_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor); + void check_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor); + + // Read mode only: throws std::runtime_error if the whole file has + // not been consumed (trailing bytes = corrupted / wrong file). + void check_fully_consumed(); void write_string(const std::string & s); void read_string(std::string & s); @@ -103,15 +170,29 @@ class LS2G_API BinaryArchive void read_vector_raw(std::vector & v) { std::uint64_t n = 0; read_scalar(n); + // reject a corrupted count before allocating anything + require_count(n, sizeof(T)); v.resize(static_cast(n)); if (n) read_raw(v.data(), static_cast(n) * sizeof(T)); } + // Read mode: throws std::runtime_error if the file does not contain + // at least `count * elem_size` more bytes after the current read + // position (overflow-safe: does the check by division). Called + // before every count-driven allocation so ill-formed counts turn + // into clean errors instead of std::bad_alloc / OOM. + void require_count(std::uint64_t count, std::uint64_t elem_size); + private: + std::uint64_t remaining_bytes(); // read mode: bytes after the current position + std::ofstream ofs_; std::ifstream ifs_; Mode mode_; std::string path_; + std::string tmp_path_; // write mode: temporary file actually written + bool committed_; // write mode: commit() completed + std::uint64_t file_size_; // read mode: total size of the file }; namespace archive_detail { @@ -150,9 +231,10 @@ struct ValueArchiver::value>::t }; // cplx_type (std::complex), standard-guaranteed layout -// compatible with real_type[2] -- safe as a raw scalar for a same-build -// round trip (no cross-platform guarantee needed, consistent with the -// hard version-mismatch policy below). +// compatible with real_type[2] -- safe as a raw scalar for a same-layout +// round trip (no cross-platform guarantee needed: the binary format is +// only supported on builds sharing the same data layout, see the +// robustness notes at the top of this file). template<> struct ValueArchiver { static void write(BinaryArchive & ar, const cplx_type & v) { ar.write_scalar(v); } @@ -197,6 +279,8 @@ struct ValueArchiver > > { static void read(BinaryArchive & ar, std::vector > & v) { std::uint64_t n = 0; ar.read_scalar(n); + // each inner vector takes at least its own 8-byte count + ar.require_count(n, sizeof(std::uint64_t)); v.resize(static_cast(n)); for (auto & inner : v) ValueArchiver >::read(ar, inner); } @@ -232,6 +316,24 @@ struct ValueArchiver::value> } }; +// ---- optional per-class fixup applied after a state has been read from a +// binary file, before set_state(). Detected via the standard C++14 +// detection idiom: a class opting in declares +// static void fixup_binary_state(StateRes & state); +// Today only LSGrid uses it (its StateRes embeds the writing build's +// version strings, which its pickle-shared set_state() checks against the +// *current* build -- fine for pickle, but it would wrongly reject a +// cross-version binary load that the format number allows, so the fixup +// rewrites those fields to the current build's values). +template +struct BinaryLoadFixup { + static void apply(typename T::StateRes &) {} +}; +template +struct BinaryLoadFixup()))> > { + static void apply(typename T::StateRes & state) { T::fixup_binary_state(state); } +}; + } // namespace archive_detail // Public entry points into the dispatcher above. @@ -247,23 +349,28 @@ void archive_read_value(BinaryArchive & ar, T & v) { // Top-level helpers reused by every serializable class's save_binary()/ // load_binary() (LSGrid + every element container with its own StateRes) -- // the single shared implementation the per-class methods delegate to, so -// none of them duplicate any serialization logic. +// none of them duplicate any serialization logic. T must additionally +// expose `static const char * binary_type_tag()` (its class name), written +// into / checked against the file header. template void save_binary_generic(const T & obj, const std::string & path, const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { BinaryArchive ar(path, BinaryArchive::Mode::Write); - ar.write_header(v_major, v_medium, v_minor); + ar.write_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state = obj.get_state(); archive_write_value(ar, state); + ar.commit(); } template T load_binary_generic(const std::string & path, const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { BinaryArchive ar(path, BinaryArchive::Mode::Read); - ar.check_header(v_major, v_medium, v_minor); + ar.check_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state{}; archive_read_value(ar, state); + ar.check_fully_consumed(); + archive_detail::BinaryLoadFixup::apply(state); T res{}; res.set_state(state); return res; diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 7676a1a8..c1cb5f79 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -230,6 +230,15 @@ LSGrid LSGrid::load_binary(const std::string & path) { return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); } +void LSGrid::fixup_binary_state(LSGrid::StateRes & state) { + // must be defined in this translation unit: the VERSION_* macros here are + // exactly the ones get_state()/set_state() above are compiled with, so + // the rewritten fields always pass set_state()'s equality check + std::get(state) = VERSION_MAJOR; + std::get(state) = VERSION_MEDIUM; + std::get(state) = VERSION_MINOR; +} + void LSGrid::set_ls_to_orig(const IntVect & ls_to_orig){ if(ls_to_orig.size() == 0){ _ls_to_orig = IntVect(); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 663b7d95..7c3bbd3a 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -53,6 +53,8 @@ class LS2G_API LSGrid final public: using IntVectRowMaj = Eigen::Array; + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< std::string, // version major std::string, // version medium @@ -499,10 +501,17 @@ class LS2G_API LSGrid final void set_state(LSGrid::StateRes & my_state) ; // fast binary serialization (additive alternative to pickle, see - // BinaryArchive.hpp -- NOT cross-version compatible, same lightsim2grid - // version required to reload) + // BinaryArchive.hpp -- readable by any lightsim2grid version sharing + // the same BINARY_FORMAT_VERSION) void save_binary(const std::string & path) const; static LSGrid load_binary(const std::string & path); + static const char * binary_type_tag() { return "LSGrid"; } // written into / checked against the binary file header + // binary-load hook (detected by load_binary_generic): rewrites the + // version strings embedded in a StateRes read from a binary file to + // this build's values, so the pickle-shared exact-version check in + // set_state() does not reject a cross-version load that the binary + // format number allows + static void fixup_binary_state(LSGrid::StateRes & state); // algo config (scaling/refactor policy params) — also part of StateRes, // see LSGrid::get_state()/set_state() diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index ab226879..8f04b251 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -53,6 +53,8 @@ class LS2G_API SubstationContainer final : public IteratorAdder, // type_ diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 623b3c7c..5d8cd4db 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -55,6 +55,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter using DataInfo = GenInfo; public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes, bool, // turnedoff_gen_pv_ @@ -95,6 +96,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static GeneratorContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "GeneratorContainer"; } // written into / checked against the binary file header // slack handling /** diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index d5f397c2..ca54dbda 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -109,6 +109,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer::StateRes, std::vector, // loss_percent_ @@ -148,6 +149,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer::StateRes >; @@ -79,6 +80,7 @@ class LS2G_API LineContainer final: public TwoSidesContainer_rxh_A & Va, const Eigen::Ref & Vm, diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 848184a8..27c5bace 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -51,6 +51,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA // regular implementation public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes // state of the base class > ; @@ -65,6 +66,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static LoadContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "LoadContainer"; } // written into / checked against the binary file header void init(const RealVect & load_p_mw, const RealVect & load_q_mvar, diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index afb6994b..9d4486e3 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -320,6 +320,8 @@ class OneSideContainer : public GenericContainer return res; } + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< std::vector, std::vector, // bus_id diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 3bf0b4eb..096417fb 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -115,6 +115,8 @@ class OneSideContainer_PQ : public OneSideContainer } } + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< OneSideContainer::StateRes, std::vector, // p_mw diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index b37e4f73..d84f894c 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -67,6 +67,8 @@ class OneSideContainer_ForBranch : public OneSideContainer // public generic API + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< OneSideContainer::StateRes > ; diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index 4cb7d552..cf153a5a 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -55,6 +55,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA using DataInfo = SGenInfo; public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes, std::vector, // p_min @@ -73,6 +74,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static SGenContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "SGenContainer"; } // written into / checked against the binary file header void init(const RealVect & sgen_p, diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index 19a770b6..c43ff8b7 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -43,6 +43,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator using DataInfo = ShuntInfo; public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple; ShuntContainer() noexcept = default; @@ -68,6 +69,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static ShuntContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "ShuntContainer"; } // written into / checked against the binary file header virtual void fillYbus(std::vector > & res, bool ac, diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index de69ac33..e763f157 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -53,6 +53,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat // regular implementation public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes // state of the base class > ; @@ -67,6 +68,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static StorageContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "StorageContainer"; } // written into / checked against the binary file header void init(const RealVect & storage_p_mw, const RealVect & storage_q_mvar, diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 2adcba24..d05020ff 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -65,6 +65,8 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder, // regulation_mode_ @@ -106,6 +108,7 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder::StateRes, std::vector, // ratio_ @@ -113,6 +114,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A virtual ~TwoSidesContainer_rxh_A() noexcept = default; // pickle + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< StateResSuper, std::vector, // branch_r From 1a35a27f86b7a7278136c7390ffd961b59c275fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 16:58:54 +0000 Subject: [PATCH 066/166] Harden binary serialization against ill-formed input Robustness improvements to save_binary/load_binary (BinaryArchive): - version compatibility is now decided by a dedicated binary format version (BINARY_FORMAT_VERSION, stored in the file header) instead of requiring the exact same lightsim2grid version: releases sharing the same format number read each other's files, and an unsupported format raises a RuntimeError naming both versions and format numbers - every count/length field read from a file is validated against the real file size before any allocation, so a corrupted count raises a clean RuntimeError instead of a MemoryError or a huge allocation - the header now stores the object type tag: loading a file into the wrong class (eg a LoadContainer file via StorageContainer.load_binary, whose StateRes layouts are identical) is rejected instead of silently succeeding - trailing bytes after the end of the data are rejected as corruption - save_binary is now atomic: it writes to a temporary file renamed over the destination only on success, so an interrupted save never destroys a previously saved file A committed reference file (lightsim2grid/tests/binary_format_fixture/) guards against StateRes layout changes made without bumping BINARY_FORMAT_VERSION, and each StateRes definition carries a reminder comment. Pickle behaviour is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 33 ++- docs/binary_serialization.rst | 27 +- .../case14_sandbox_format1.lsb | Bin 0 -> 4731 bytes .../tests/test_binary_serialization.py | 241 ++++++++++++++++-- src/bindings/python/binary_helpers.hpp | 19 +- src/core/BinaryArchive.cpp | 157 +++++++++++- src/core/BinaryArchive.hpp | 145 +++++++++-- src/core/LSGrid.cpp | 9 + src/core/LSGrid.hpp | 13 +- src/core/SubstationContainer.hpp | 3 + .../ConverterStationContainer.hpp | 2 + .../element_container/GeneratorContainer.hpp | 2 + .../element_container/HvdcLineContainer.hpp | 2 + src/core/element_container/LineContainer.hpp | 2 + src/core/element_container/LoadContainer.hpp | 2 + .../element_container/OneSideContainer.hpp | 2 + .../element_container/OneSideContainer_PQ.hpp | 2 + .../OneSideContainer_forBranch.hpp | 2 + src/core/element_container/SGenContainer.hpp | 2 + src/core/element_container/ShuntContainer.hpp | 2 + .../element_container/StorageContainer.hpp | 2 + src/core/element_container/SvcContainer.hpp | 3 + src/core/element_container/TrafoContainer.hpp | 2 + .../element_container/TwoSidesContainer.hpp | 2 + .../TwoSidesContainer_rxh_A.hpp | 1 + 25 files changed, 606 insertions(+), 71 deletions(-) create mode 100644 lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 31b760fd..0fdf2963 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -335,13 +335,32 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. container that has its own state (*eg* `gridmodel.get_loads().save_binary(...)`), mirroring what is already picklable. This is **not** a replacement for pickle: it trades portability for speed, meant for repeatedly re-loading the *same* grid on the - *same* machine / lightsim2grid build. **NB** as with pickle, a file can only be - reloaded with the exact same lightsim2grid version it was saved with; a version - mismatch or a corrupted / truncated file raises a `RuntimeError` (no cross-version - migration is attempted). See the new "Fast binary serialization" documentation page - and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against - pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster - to read than pickle on grids with ~9000 buses). + *same* machine / lightsim2grid build. See the new "Fast binary serialization" + documentation page and `benchmarks/benchmark_binary_serialization.py` for speed + comparisons against pickle (the speed up grows with grid size: up to ~17x faster to + write and ~8x faster to read than pickle on grids with ~9000 buses). +- [IMPROVED] robustness of `save_binary` / `load_binary` against ill-formed input: + + - version compatibility is now decided by a dedicated **binary format version** + (`BINARY_FORMAT_VERSION` in `src/core/BinaryArchive.hpp`, stored in the file + header) instead of requiring the exact same lightsim2grid version: the format + number is only bumped when the serialized layout changes, so all lightsim2grid + versions sharing the same format number read each other's files. An unsupported + format raises a `RuntimeError` naming both versions and format numbers. A + committed reference file (`lightsim2grid/tests/binary_format_fixture/`) guards + against layout changes made without bumping the format number. + - every count / length field read from a file is now validated against the real + file size *before* any allocation: a corrupted count raises a clean + `RuntimeError` instead of a `MemoryError` (or worse, an actual multi-gigabyte + allocation). + - the file header now stores the **object type** (*eg* `"LoadContainer"`): + loading a file into the wrong class raises a `RuntimeError` instead of silently + succeeding when the layouts happen to match (*eg* `LoadContainer` vs + `StorageContainer`). + - trailing bytes after the end of the data are now rejected as corruption. + - `save_binary` is now **atomic**: it writes to a temporary file that only + replaces the destination once fully written, so an interrupted save (crash, + full disk, ...) never destroys a previously saved file. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 0b91d6a2..10a91c20 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -46,18 +46,33 @@ Individual element containers work the same way: from lightsim2grid.elements import LoadContainer loads_reloaded = LoadContainer.load_binary("my_loads.lsb") +.. note:: + Version compatibility is decided by a **binary format version** (an integer stored in the file + header, see ``BINARY_FORMAT_VERSION`` in ``src/core/BinaryArchive.hpp``), not by the lightsim2grid + version: the format number is only bumped when the serialized layout actually changes, so all + lightsim2grid versions sharing the same format number can read each other's files. Loading a file + with an unsupported format number raises a ``RuntimeError`` naming both versions and both format + numbers. + .. warning:: - ``load_binary`` performs a **hard version check**: the file can only be reloaded with the exact same - lightsim2grid version (major.medium.minor) that was used to write it. As opposed to pickle, there is - no attempt at cross-version migration: a mismatch always raises a ``RuntimeError``. The same is true - of a corrupted or truncated file: ``load_binary`` raises a ``RuntimeError`` rather than crashing or - silently returning garbage. + ``load_binary`` validates its input and always raises a ``RuntimeError`` (rather than crashing, + exhausting memory, or silently returning garbage) on ill-formed files: garbage or truncated files, + corrupted internal sizes (every count in the file is checked against the real file size *before* + anything is allocated), files that contain an object of a different type (*eg* loading a + ``LoadContainer`` file with ``StorageContainer.load_binary``), and files with unexpected trailing + bytes are all rejected. ``save_binary`` on the other hand is **atomic**: it writes to a temporary + file that only replaces the destination once fully written, so an interrupted save never destroys + a previously saved file. Note that the format stores raw native data: files are meant to be written + and read by builds sharing the same data layout (same endianness, ``real_type``, ...) -- there is + no checksum nor cross-platform migration, pickle remains the portable format. .. note:: The binary format walks the exact same internal state (``get_state`` / ``set_state``) that pickle uses, so the two stay structurally in sync: anything that can be pickled can be saved with ``save_binary``. See ``lightsim2grid/tests/test_binary_serialization.py`` for the full test suite - (round trip, AC/DC powerflow round trip, version mismatch, corrupted file). + (round trip, AC/DC powerflow round trip, format mismatch, cross-version load, corrupted / truncated / + wrong-type files, atomicity, and a committed reference file guarding the layout against accidental + changes). .. _binary_serialization_benchmarks: diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb new file mode 100644 index 0000000000000000000000000000000000000000..eac26c82f11f96e4a0903bba601a91ed6976843f GIT binary patch literal 4731 zcmeHKZBUd|6yAkIQUt><6iP@*G|_$c-CY(d@1`QdfE6;0IyGG=BAc-Y9Y&=uo1C$k zD2EhBG#eZ%Ga@y!BvW=$8Bu1!nj!s%OvOxPU>`J1KkPjBea~fg2Wlt#_>pJk+4G!p z?>*<-ckg@lZc)hsx6|Qp#1V>0@>f4v4$HuYOMS!;%Qiwk$)V)+C+-;a#h!j)St|l* zw%-{hw$oOLq!EiHnl(H}tqGI`qS6kJHP12I)>*b@f0X)P%!g+K89`W6{h)L)F_^fS zm`prOyi9yda+vs;xkw z8d9~n$)jpB$>T!`FQu0`HZ!E(W^Sp<^UGWsA4&84(lDiAfWjpWcRW$UAVMRGgGj@} z#f6B?sbg5#ZP;G-C3`u*osD4;uy7F(P)W{WRyV#@3rf zG2;kazo?h=9IJd7!ta+&BljNkg zL{<9q(c6Q~;>iBk-rB5Yk(Bq`{JI$j#n>8O)BI&Giqu(czwW%}h&cMgiq}T2Z53aY z?e6mQ9T0t~^2v&VMsa+}vjfxef}&)^o>%5M4~wzWHs+`1cZi(sA5X+zJS8}PeC(&k zHWj`r?%R4HeM@bJC|G&6W&E;sQF*lU{=Ic=qVoI)S%>ay6Riu28}I(|lvq>Plu@<$ z4RLJgwv3mn+r_-)S9)EtQ*3p8KCu6tpr}lKK6qyLVR3e&{HAxwIdNfaUVm%-X)*Bq z=3m~fZx@YkZfpKIuS@5rN1nqb~s+jx-{mvt3liAvNNXsv}&8SUp<+A zVD-v6ZI@5(Sw88*L3_@^v~PQSHF})${MVYFJJ<1l**EVuQlnWw(5;x$JF9$_*QSHR z?BGe3Z3Z1qbQ}n}jwyZ8@LG)euegFe`a_naFK z0-{zPv-%5LL(SAxjiqHb5GE4R2{Q;R93V72el}q9Sa_j_woI|44>Hxi~1uz&*)4R#bQ4JiFxF?7H*7eGf!V?#@EAY@(S z_#fK3AZr65VM-6KSLca!V?)DboU=%+#zWT8ltsbtoGG>)t~YEt5e#2f5U|!QvbBFm zP3yw9tM_27j`p)(aQ5N8Tzo+g^wd?AW#u?Wn$tnj+?49_tmb&6x;!iHl^#9EFTI-Q zN+0fJR!q9B4;pk~A2jIBj=Q8ajc(#DE{F@}#*Y(;;8+@Z`^0Tiud$CPl=jg$PF*x{ z^dcm}RW_M0iEtAki*PGp4#6OJ34Q`6B1+;5C>6XHy0qf!#g^?zvvn3U9rN3vODife zph*$}N;xJ%lYb^Oox|)vNPP0og+E{(x(k}(3(GdgK?gfJ(rs}Y_rh-~$*VT%1*-yLD|Z~tJ#-vNTC Bxv2mE literal 0 HcmV?d00001 diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 7442c66d..44fdc2ff 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -218,11 +218,61 @@ def test_binary_algo_config(self): assert list(dc_cfg_1.int_params) == list(dc_cfg.int_params) assert np.allclose(dc_cfg_1.real_params, dc_cfg.real_params) - def test_cannot_load_wrong_version(self): + @staticmethod + def _header_end(data): + """Return the offset of the first state byte. + + File header layout (stable across binary format versions): 4-byte + magic "LSB2", uint32 little-endian binary format version, then 4 + length-prefixed strings (uint32 little-endian length + raw bytes): + type tag, major / medium / minor writer version. + """ + off = 4 + 4 # magic + format version + for _ in range(4): # type tag + 3 version strings + (str_len,) = struct.unpack_from(" 0, "ac powerflow diverged on the reference grid" + + def test_roundtrip_matches_reference_env(self): + """The fixture must stay equivalent to a freshly-built grid of the + same environment (guards against regenerating it with wrong data).""" + from lightsim2grid.network import LSGrid + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make(FIXTURE_ENV_NAME, test=True, backend=LightSimBackend()) + grid_ref = env.backend._grid + grid_fixture = LSGrid.load_binary(FIXTURE_PATH) + assert len(compare_network_input(grid_ref, grid_fixture)) == 0 + + +def _regenerate_fixture(): + """Manual helper: regenerate the committed reference file after a + deliberate BINARY_FORMAT_VERSION bump (never for any other reason).""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make(FIXTURE_ENV_NAME, test=True, backend=LightSimBackend()) + os.makedirs(os.path.dirname(FIXTURE_PATH), exist_ok=True) + env.backend._grid.save_binary(FIXTURE_PATH) + print(f"fixture regenerated: {FIXTURE_PATH}") + print("update the FIXTURE_* expected values and the file name if the format number changed") + + if __name__ == "__main__": - unittest.main() + import sys + if len(sys.argv) > 1 and sys.argv[1] == "regen": + _regenerate_fixture() + else: + unittest.main() diff --git a/src/bindings/python/binary_helpers.hpp b/src/bindings/python/binary_helpers.hpp index 063d3a29..985761c1 100644 --- a/src/bindings/python/binary_helpers.hpp +++ b/src/bindings/python/binary_helpers.hpp @@ -15,10 +15,13 @@ namespace py = pybind11; // Helper: attach save_binary()/load_binary() python methods to any container -// that exposes get_state()/set_state() and a nested StateRes type (the same -// contract used by add_pickle in pickle_helpers.hpp). This is an additive, -// faster alternative to pickle -- NOT a replacement, and NOT cross-version -// compatible (a version mismatch is a hard failure, see BinaryArchive.hpp). +// that exposes get_state()/set_state(), a nested StateRes type (the same +// contract used by add_pickle in pickle_helpers.hpp) and binary_type_tag(). +// This is an additive, faster alternative to pickle -- NOT a replacement. +// Files are readable by any lightsim2grid version sharing the same +// BINARY_FORMAT_VERSION (see BinaryArchive.hpp), and ill-formed input +// (garbage, truncation, corrupted sizes, wrong object type, trailing bytes) +// raises RuntimeError instead of crashing or over-allocating. // // These lambdas call ls2g::save_binary_generic/load_binary_generic directly // (rather than T::save_binary/T::load_binary): VERSION_MAJOR/MEDIUM/MINOR are @@ -31,12 +34,16 @@ void add_binary_serialization(py::class_& cls) { ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); }, py::arg("path"), "Save this object's state to a fast custom binary file (additive alternative " - "to pickle: faster, but NOT portable across lightsim2grid versions)."); + "to pickle). The write is atomic: an existing file at that path is only " + "replaced once the new content has been written completely. The file stays " + "readable by any lightsim2grid version sharing the same binary format number."); cls.def_static("load_binary", [](const std::string& path) { return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); }, py::arg("path"), "Load an object previously saved with save_binary(). Raises RuntimeError on " - "a version mismatch or a corrupted / truncated file."); + "an incompatible binary format, a wrong object type, or a corrupted / " + "truncated file (including corrupted internal sizes: no attempt is made to " + "allocate more data than the file actually contains)."); } #endif // BINARY_HELPERS_HPP diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index c9cc2efd..a8ace1ab 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -8,6 +8,7 @@ #include "BinaryArchive.hpp" +#include // std::remove, std::rename #include #include #include @@ -15,27 +16,98 @@ namespace ls2g { namespace { -const char MAGIC[4] = {'L', 'S', 'B', '1'}; + +const char MAGIC[4] = {'L', 'S', 'B', '2'}; + +// Binary format versions this build can load. Almost always just the +// current one: reading an older format requires dedicated migration code +// (the StateRes tuples are read structurally, with the current layout). +// If such code is ever written, add the older format number here. +const std::uint32_t SUPPORTED_BINARY_FORMATS[] = {BINARY_FORMAT_VERSION}; + +bool is_format_supported(std::uint32_t format) +{ + for (std::uint32_t f : SUPPORTED_BINARY_FORMATS) { + if (f == format) return true; + } + return false; +} + +std::string supported_formats_str() +{ + std::ostringstream oss; + bool first = true; + for (std::uint32_t f : SUPPORTED_BINARY_FORMATS) { + if (!first) oss << ", "; + oss << f; + first = false; + } + return oss.str(); +} + } // anonymous namespace BinaryArchive::BinaryArchive(const std::string & path, Mode mode): mode_(mode), - path_(path) + path_(path), + tmp_path_(), + committed_(false), + file_size_(0) { if (mode_ == Mode::Write) { - ofs_.open(path_, std::ios::binary | std::ios::out | std::ios::trunc); + // write to a temporary file: the destination is only replaced in + // commit(), after the whole state has been written successfully. + tmp_path_ = path_ + ".lsb_tmp"; + ofs_.open(tmp_path_, std::ios::binary | std::ios::out | std::ios::trunc); if (!ofs_.is_open()) { - throw std::runtime_error("BinaryArchive: cannot open file for writing: '" + path_ + "'"); + throw std::runtime_error( + "BinaryArchive: cannot open file for writing: '" + path_ + + "' (temporary file '" + tmp_path_ + "' could not be created)"); } } else { ifs_.open(path_, std::ios::binary | std::ios::in); if (!ifs_.is_open()) { throw std::runtime_error("BinaryArchive: cannot open file for reading: '" + path_ + "'"); } + // capture the file size once: every count / length field read from + // the file is validated against it before any allocation. + ifs_.seekg(0, std::ios::end); + file_size_ = static_cast(ifs_.tellg()); + ifs_.seekg(0, std::ios::beg); } } -BinaryArchive::~BinaryArchive() = default; +BinaryArchive::~BinaryArchive() +{ + if (mode_ == Mode::Write && !committed_) { + // interrupted write (exception during serialization, or commit() + // failed): drop the temporary file, the destination is untouched. + if (ofs_.is_open()) ofs_.close(); + std::remove(tmp_path_.c_str()); + } +} + +void BinaryArchive::commit() +{ + if (mode_ != Mode::Write) { + throw std::runtime_error("BinaryArchive: commit() called on a read-mode archive for '" + path_ + "'"); + } + ofs_.flush(); + if (!ofs_) { + throw std::runtime_error("BinaryArchive: failed to write to file '" + path_ + "'"); + } + ofs_.close(); + // atomically replace the destination. std::rename overwrites on POSIX + // but fails on Windows when the destination exists, so remove it first + // (failure to remove is ignored: rename below reports the real error). + std::remove(path_.c_str()); + if (std::rename(tmp_path_.c_str(), path_.c_str()) != 0) { + throw std::runtime_error( + "BinaryArchive: could not move the temporary file '" + tmp_path_ + + "' to its final destination '" + path_ + "'"); + } + committed_ = true; +} void BinaryArchive::write_raw(const void * data, std::size_t nbytes) { @@ -57,6 +129,27 @@ void BinaryArchive::read_raw(void * data, std::size_t nbytes) } } +std::uint64_t BinaryArchive::remaining_bytes() +{ + const std::uint64_t pos = static_cast(ifs_.tellg()); + return (pos <= file_size_) ? (file_size_ - pos) : 0; +} + +void BinaryArchive::require_count(std::uint64_t count, std::uint64_t elem_size) +{ + // division (not multiplication) so a huge corrupted count cannot + // overflow back into an "acceptable" byte total + if (elem_size == 0) return; + if (count > remaining_bytes() / elem_size) { + std::ostringstream oss; + oss << "BinaryArchive: invalid file '" << path_ << "': it declares a block of " + << count << " element(s) of " << elem_size << " byte(s), but only " + << remaining_bytes() << " byte(s) remain in the file. " + << "The file is truncated or corrupted."; + throw std::runtime_error(oss.str()); + } +} + void BinaryArchive::write_magic() { write_raw(MAGIC, sizeof(MAGIC)); @@ -73,28 +166,60 @@ void BinaryArchive::check_magic() } } -void BinaryArchive::write_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor) +void BinaryArchive::write_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { write_magic(); + write_scalar(BINARY_FORMAT_VERSION); + write_string(type_tag); write_string(v_major); write_string(v_medium); write_string(v_minor); } -void BinaryArchive::check_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor) +void BinaryArchive::check_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { check_magic(); - std::string file_major, file_medium, file_minor; + // the header layout (magic, format, tag, writer version) is a stable + // contract across binary format versions: it can be parsed even when + // the format number does not match, to produce a precise error below. + std::uint32_t file_format = 0; + read_scalar(file_format); + std::string file_tag, file_major, file_medium, file_minor; + read_string(file_tag); read_string(file_major); read_string(file_medium); read_string(file_minor); - if (file_major != v_major || file_medium != v_medium || file_minor != v_minor) { + if (!is_format_supported(file_format)) { + std::ostringstream oss; + oss << "BinaryArchive: incompatible file '" << path_ << "': it was written by lightsim2grid version " + << file_major << "." << file_medium << "." << file_minor + << " using binary format " << file_format + << ", but the currently installed lightsim2grid (version " + << v_major << "." << v_medium << "." << v_minor + << ") only supports binary format(s) {" << supported_formats_str() << "}. " + << "Re-save the file with a compatible lightsim2grid version (the binary format only " + << "changes when the serialized layout changes, so versions sharing the same format " + << "number can read each other's files)."; + throw std::runtime_error(oss.str()); + } + if (file_tag != type_tag) { + std::ostringstream oss; + oss << "BinaryArchive: wrong object type in file '" << path_ << "': it contains a '" + << file_tag << "', not a '" << type_tag << "'."; + throw std::runtime_error(oss.str()); + } +} + +void BinaryArchive::check_fully_consumed() +{ + const std::uint64_t left = remaining_bytes(); + if (left != 0) { std::ostringstream oss; - oss << "BinaryArchive: version mismatch when loading '" << path_ << "'. " - << "This file was saved with lightsim2grid version " << file_major << "." << file_medium << "." << file_minor - << ", but the currently installed version is " << v_major << "." << v_medium << "." << v_minor - << ". Binary files can only be reloaded with the exact same lightsim2grid version they were saved with " - << "(unlike pickle, this format does not attempt any cross-version migration)."; + oss << "BinaryArchive: invalid file '" << path_ << "': " << left + << " unexpected byte(s) remain after the end of the data. " + << "The file is corrupted, or was written by an incompatible build."; throw std::runtime_error(oss.str()); } } @@ -110,6 +235,7 @@ void BinaryArchive::read_string(std::string & s) { std::uint32_t len = 0; read_scalar(len); + require_count(len, 1); s.resize(len); if (len) read_raw(&s[0], len); } @@ -127,6 +253,7 @@ void BinaryArchive::read_bool_vector(std::vector & v) { std::uint64_t n = 0; read_scalar(n); + require_count(n, 1); std::vector buf(static_cast(n)); if (!buf.empty()) read_raw(buf.data(), buf.size()); v.resize(static_cast(n)); @@ -144,6 +271,8 @@ void BinaryArchive::read_string_vector(std::vector & v) { std::uint64_t n = 0; read_scalar(n); + // each string takes at least its own 4-byte length prefix + require_count(n, sizeof(std::uint32_t)); v.clear(); v.reserve(static_cast(n)); for (std::uint64_t i = 0; i < n; ++i) { diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index c376ea3f..0d271d9d 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -15,6 +15,27 @@ // replace pickle: it trades portability for speed (no python-level // marshalling, raw contiguous writes for vector data). // +// Robustness guarantees (all failures are std::runtime_error naming the +// file, never a crash, a std::bad_alloc or an out-of-memory situation): +// - a magic number rejects files that are not lightsim2grid binary files; +// - a *binary format version* (see BINARY_FORMAT_VERSION below) decides +// compatibility across lightsim2grid releases; +// - a type tag rejects loading a file into the wrong class (eg a +// LoadContainer file into StorageContainer.load_binary, whose StateRes +// layouts are otherwise identical); +// - every count/length field read from the file is checked against the +// actual file size *before* any allocation, so a corrupted count cannot +// trigger a multi-gigabyte allocation; +// - after the state is fully read, the file must be fully consumed +// (trailing bytes are treated as corruption); +// - writes go to a temporary file that is atomically renamed over the +// destination only on success, so a crash / full disk mid-save never +// destroys a previously good file. +// NOT covered (possible future work): a payload checksum (bit flips inside +// numeric data that keep all counts consistent load silently), and an +// ABI descriptor (endianness / sizeof(real_type)): files are assumed to be +// written and read by builds with the same data layout. +// // C++14 only (see project policy, eg LSGrid.hpp): no `if constexpr`, no // `std::apply`, no fold expressions. The recursive walk over an arbitrary // StateRes tuple is done via partial-specialization of the `ValueArchiver` @@ -37,16 +58,43 @@ namespace ls2g { +// Version of the binary *format* itself, decoupled from the lightsim2grid +// package version: all lightsim2grid releases sharing the same format number +// can read each other's binary files (most releases do not touch the +// serialized layout at all). The lightsim2grid version that wrote a file is +// still stored in its header, but only for diagnostics / error messages. +// +// >>> BUMP THIS NUMBER whenever anything about the serialized layout +// >>> changes: any StateRes tuple (fields added / removed / reordered / +// >>> retyped, in LSGrid or any container), or the encoding in this file. +// A bumped format makes older files fail to load with a clear error instead +// of being silently mis-read. If a future version wants to keep reading an +// older format, add that format number to SUPPORTED_BINARY_FORMATS (in +// BinaryArchive.cpp) together with the required migration code. +constexpr std::uint32_t BINARY_FORMAT_VERSION = 1; + class LS2G_API BinaryArchive { public: enum class Mode { Write, Read }; + // Write mode writes to a temporary file (path + ".lsb_tmp"); the + // data only replaces `path` when commit() is called after a fully + // successful write. Read mode opens `path` directly and captures + // its size for the bounds checks below. BinaryArchive(const std::string & path, Mode mode); ~BinaryArchive(); BinaryArchive(const BinaryArchive &) = delete; BinaryArchive & operator=(const BinaryArchive &) = delete; + // Write mode only: flush + close the temporary file and atomically + // rename it over the destination path. Throws std::runtime_error on + // any failure (the destination is left untouched in that case). If + // commit() is never called (eg an exception during the write), the + // destructor removes the temporary file and the destination keeps + // its previous content. + void commit(); + // low level: the only methods that touch the underlying stream. // Both throw std::runtime_error (including the file path) on any // stream failure, which uniformly covers "file does not exist", @@ -54,22 +102,41 @@ class LS2G_API BinaryArchive void write_raw(const void * data, std::size_t nbytes); void read_raw(void * data, std::size_t nbytes); - // magic number ("LSB1"), to catch garbage files early + // magic number ("LSB2"), to catch garbage files early void write_magic(); void check_magic(); // throws std::runtime_error on mismatch - // magic + 3 length-prefixed version strings. Version strings are - // passed in explicitly by the caller (never read from the - // VERSION_MAJOR/MEDIUM/MINOR macros in this shared header): those - // macros are only defined via target_compile_definitions on the - // python bindings target, not on lightsim2grid_core, so referencing - // them here would silently embed different values (even risk an ODR - // violation) depending on which translation unit instantiates this - // header first. Callers (each container's own .cpp, or - // binary_helpers.hpp on the bindings side) supply the macros - // themselves, each from a single, consistent translation unit. - void write_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor); - void check_header(const std::string & v_major, const std::string & v_medium, const std::string & v_minor); // throws std::runtime_error on any mismatch + // Full header: magic, BINARY_FORMAT_VERSION (uint32), type tag + // (length-prefixed string, eg "LoadContainer"), then 3 + // length-prefixed strings with the lightsim2grid version that wrote + // the file. This header layout is a stable contract across all + // binary format versions (so any version can at least *parse* the + // header of any file and produce a precise error message); only the + // state payload after the header is governed by the format number. + // + // check_header() throws std::runtime_error when the file's format + // version is not supported by this build, or when the type tag does + // not match. The writer version strings are NOT compared: two + // different lightsim2grid versions sharing the same binary format + // read each other's files. + // + // Version strings are passed in explicitly by the caller (never read + // from the VERSION_MAJOR/MEDIUM/MINOR macros in this shared header): + // those macros are only defined via target_compile_definitions on + // the python bindings target, not on lightsim2grid_core, so + // referencing them here would silently embed different values (even + // risk an ODR violation) depending on which translation unit + // instantiates this header first. Callers (each container's own + // .cpp, or binary_helpers.hpp on the bindings side) supply the + // macros themselves, each from a single, consistent translation unit. + void write_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor); + void check_header(const std::string & type_tag, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor); + + // Read mode only: throws std::runtime_error if the whole file has + // not been consumed (trailing bytes = corrupted / wrong file). + void check_fully_consumed(); void write_string(const std::string & s); void read_string(std::string & s); @@ -103,15 +170,29 @@ class LS2G_API BinaryArchive void read_vector_raw(std::vector & v) { std::uint64_t n = 0; read_scalar(n); + // reject a corrupted count before allocating anything + require_count(n, sizeof(T)); v.resize(static_cast(n)); if (n) read_raw(v.data(), static_cast(n) * sizeof(T)); } + // Read mode: throws std::runtime_error if the file does not contain + // at least `count * elem_size` more bytes after the current read + // position (overflow-safe: does the check by division). Called + // before every count-driven allocation so ill-formed counts turn + // into clean errors instead of std::bad_alloc / OOM. + void require_count(std::uint64_t count, std::uint64_t elem_size); + private: + std::uint64_t remaining_bytes(); // read mode: bytes after the current position + std::ofstream ofs_; std::ifstream ifs_; Mode mode_; std::string path_; + std::string tmp_path_; // write mode: temporary file actually written + bool committed_; // write mode: commit() completed + std::uint64_t file_size_; // read mode: total size of the file }; namespace archive_detail { @@ -150,9 +231,10 @@ struct ValueArchiver::value>::t }; // cplx_type (std::complex), standard-guaranteed layout -// compatible with real_type[2] -- safe as a raw scalar for a same-build -// round trip (no cross-platform guarantee needed, consistent with the -// hard version-mismatch policy below). +// compatible with real_type[2] -- safe as a raw scalar for a same-layout +// round trip (no cross-platform guarantee needed: the binary format is +// only supported on builds sharing the same data layout, see the +// robustness notes at the top of this file). template<> struct ValueArchiver { static void write(BinaryArchive & ar, const cplx_type & v) { ar.write_scalar(v); } @@ -197,6 +279,8 @@ struct ValueArchiver > > { static void read(BinaryArchive & ar, std::vector > & v) { std::uint64_t n = 0; ar.read_scalar(n); + // each inner vector takes at least its own 8-byte count + ar.require_count(n, sizeof(std::uint64_t)); v.resize(static_cast(n)); for (auto & inner : v) ValueArchiver >::read(ar, inner); } @@ -232,6 +316,24 @@ struct ValueArchiver::value> } }; +// ---- optional per-class fixup applied after a state has been read from a +// binary file, before set_state(). Detected via the standard C++14 +// detection idiom: a class opting in declares +// static void fixup_binary_state(StateRes & state); +// Today only LSGrid uses it (its StateRes embeds the writing build's +// version strings, which its pickle-shared set_state() checks against the +// *current* build -- fine for pickle, but it would wrongly reject a +// cross-version binary load that the format number allows, so the fixup +// rewrites those fields to the current build's values). +template +struct BinaryLoadFixup { + static void apply(typename T::StateRes &) {} +}; +template +struct BinaryLoadFixup()))> > { + static void apply(typename T::StateRes & state) { T::fixup_binary_state(state); } +}; + } // namespace archive_detail // Public entry points into the dispatcher above. @@ -247,23 +349,28 @@ void archive_read_value(BinaryArchive & ar, T & v) { // Top-level helpers reused by every serializable class's save_binary()/ // load_binary() (LSGrid + every element container with its own StateRes) -- // the single shared implementation the per-class methods delegate to, so -// none of them duplicate any serialization logic. +// none of them duplicate any serialization logic. T must additionally +// expose `static const char * binary_type_tag()` (its class name), written +// into / checked against the file header. template void save_binary_generic(const T & obj, const std::string & path, const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { BinaryArchive ar(path, BinaryArchive::Mode::Write); - ar.write_header(v_major, v_medium, v_minor); + ar.write_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state = obj.get_state(); archive_write_value(ar, state); + ar.commit(); } template T load_binary_generic(const std::string & path, const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { BinaryArchive ar(path, BinaryArchive::Mode::Read); - ar.check_header(v_major, v_medium, v_minor); + ar.check_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state{}; archive_read_value(ar, state); + ar.check_fully_consumed(); + archive_detail::BinaryLoadFixup::apply(state); T res{}; res.set_state(state); return res; diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 7676a1a8..c1cb5f79 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -230,6 +230,15 @@ LSGrid LSGrid::load_binary(const std::string & path) { return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); } +void LSGrid::fixup_binary_state(LSGrid::StateRes & state) { + // must be defined in this translation unit: the VERSION_* macros here are + // exactly the ones get_state()/set_state() above are compiled with, so + // the rewritten fields always pass set_state()'s equality check + std::get(state) = VERSION_MAJOR; + std::get(state) = VERSION_MEDIUM; + std::get(state) = VERSION_MINOR; +} + void LSGrid::set_ls_to_orig(const IntVect & ls_to_orig){ if(ls_to_orig.size() == 0){ _ls_to_orig = IntVect(); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 663b7d95..7c3bbd3a 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -53,6 +53,8 @@ class LS2G_API LSGrid final public: using IntVectRowMaj = Eigen::Array; + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< std::string, // version major std::string, // version medium @@ -499,10 +501,17 @@ class LS2G_API LSGrid final void set_state(LSGrid::StateRes & my_state) ; // fast binary serialization (additive alternative to pickle, see - // BinaryArchive.hpp -- NOT cross-version compatible, same lightsim2grid - // version required to reload) + // BinaryArchive.hpp -- readable by any lightsim2grid version sharing + // the same BINARY_FORMAT_VERSION) void save_binary(const std::string & path) const; static LSGrid load_binary(const std::string & path); + static const char * binary_type_tag() { return "LSGrid"; } // written into / checked against the binary file header + // binary-load hook (detected by load_binary_generic): rewrites the + // version strings embedded in a StateRes read from a binary file to + // this build's values, so the pickle-shared exact-version check in + // set_state() does not reject a cross-version load that the binary + // format number allows + static void fixup_binary_state(LSGrid::StateRes & state); // algo config (scaling/refactor policy params) — also part of StateRes, // see LSGrid::get_state()/set_state() diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index ab226879..8f04b251 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -53,6 +53,8 @@ class LS2G_API SubstationContainer final : public IteratorAdder, // type_ diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 623b3c7c..5d8cd4db 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -55,6 +55,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter using DataInfo = GenInfo; public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes, bool, // turnedoff_gen_pv_ @@ -95,6 +96,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static GeneratorContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "GeneratorContainer"; } // written into / checked against the binary file header // slack handling /** diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index d5f397c2..ca54dbda 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -109,6 +109,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer::StateRes, std::vector, // loss_percent_ @@ -148,6 +149,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer::StateRes >; @@ -79,6 +80,7 @@ class LS2G_API LineContainer final: public TwoSidesContainer_rxh_A & Va, const Eigen::Ref & Vm, diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 848184a8..27c5bace 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -51,6 +51,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA // regular implementation public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes // state of the base class > ; @@ -65,6 +66,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static LoadContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "LoadContainer"; } // written into / checked against the binary file header void init(const RealVect & load_p_mw, const RealVect & load_q_mvar, diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index afb6994b..9d4486e3 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -320,6 +320,8 @@ class OneSideContainer : public GenericContainer return res; } + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< std::vector, std::vector, // bus_id diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 3bf0b4eb..096417fb 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -115,6 +115,8 @@ class OneSideContainer_PQ : public OneSideContainer } } + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< OneSideContainer::StateRes, std::vector, // p_mw diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index b37e4f73..d84f894c 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -67,6 +67,8 @@ class OneSideContainer_ForBranch : public OneSideContainer // public generic API + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) + using StateRes = std::tuple< OneSideContainer::StateRes > ; diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index 4cb7d552..cf153a5a 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -55,6 +55,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA using DataInfo = SGenInfo; public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes, std::vector, // p_min @@ -73,6 +74,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static SGenContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "SGenContainer"; } // written into / checked against the binary file header void init(const RealVect & sgen_p, diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index 19a770b6..c43ff8b7 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -43,6 +43,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator using DataInfo = ShuntInfo; public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple; ShuntContainer() noexcept = default; @@ -68,6 +69,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static ShuntContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "ShuntContainer"; } // written into / checked against the binary file header virtual void fillYbus(std::vector > & res, bool ac, diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index de69ac33..e763f157 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -53,6 +53,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat // regular implementation public: + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< OneSideContainer_PQ::StateRes // state of the base class > ; @@ -67,6 +68,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path) const; static StorageContainer load_binary(const std::string & path); + static const char * binary_type_tag() { return "StorageContainer"; } // written into / checked against the binary file header void init(const RealVect & storage_p_mw, const RealVect & storage_q_mvar, diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 2adcba24..d05020ff 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -65,6 +65,8 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder, // regulation_mode_ @@ -106,6 +108,7 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder::StateRes, std::vector, // ratio_ @@ -113,6 +114,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A virtual ~TwoSidesContainer_rxh_A() noexcept = default; // pickle + // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< StateResSuper, std::vector, // branch_r From 9f4b77a6289d24e10cbc531ee05ce50c07f73e09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 05:08:41 +0000 Subject: [PATCH 067/166] Address review: optional atomic save + serialized-enum value guard - save_binary now takes atomic=True (default): pass atomic=False to write the destination directly, skipping the temporary file + rename, as the marginally faster but unprotected variant (review request: the feature is about speed, atomicity should be a choice) - the integer values of the enums serialized verbatim in binary files and pickles (AlgorithmType, SvcContainer::RegulationMode, HvdcLineContainer::ConvertersMode, ConverterStationContainer::ConverterType) now carry a comment stating that renumbering them requires bumping BINARY_FORMAT_VERSION, the three container enums are exposed to python, and a new python test (TestSerializedEnumValues) pins all four value sets with an error message pointing at BINARY_FORMAT_VERSION Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 13 +++- docs/binary_serialization.rst | 7 +- .../tests/test_binary_serialization.py | 70 +++++++++++++++++++ src/bindings/python/binary_helpers.hpp | 13 ++-- src/bindings/python/binding_containers.cpp | 19 ++++- src/core/AlgorithmSelector.hpp | 6 ++ src/core/BinaryArchive.cpp | 48 ++++++++----- src/core/BinaryArchive.hpp | 44 +++++++----- src/core/LSGrid.cpp | 4 +- src/core/LSGrid.hpp | 2 +- src/core/SubstationContainer.cpp | 4 +- src/core/SubstationContainer.hpp | 2 +- .../ConverterStationContainer.hpp | 4 ++ .../element_container/GeneratorContainer.cpp | 4 +- .../element_container/GeneratorContainer.hpp | 2 +- .../element_container/HvdcLineContainer.cpp | 4 +- .../element_container/HvdcLineContainer.hpp | 6 +- src/core/element_container/LineContainer.cpp | 4 +- src/core/element_container/LineContainer.hpp | 2 +- src/core/element_container/LoadContainer.cpp | 4 +- src/core/element_container/LoadContainer.hpp | 2 +- src/core/element_container/SGenContainer.cpp | 4 +- src/core/element_container/SGenContainer.hpp | 2 +- src/core/element_container/ShuntContainer.cpp | 4 +- src/core/element_container/ShuntContainer.hpp | 2 +- .../element_container/StorageContainer.cpp | 4 +- .../element_container/StorageContainer.hpp | 2 +- src/core/element_container/SvcContainer.cpp | 4 +- src/core/element_container/SvcContainer.hpp | 6 +- src/core/element_container/TrafoContainer.cpp | 4 +- src/core/element_container/TrafoContainer.hpp | 2 +- 31 files changed, 218 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0fdf2963..a2564a44 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -358,9 +358,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. succeeding when the layouts happen to match (*eg* `LoadContainer` vs `StorageContainer`). - trailing bytes after the end of the data are now rejected as corruption. - - `save_binary` is now **atomic**: it writes to a temporary file that only - replaces the destination once fully written, so an interrupted save (crash, - full disk, ...) never destroys a previously saved file. + - `save_binary` is now **atomic by default**: it writes to a temporary file + that only replaces the destination once fully written, so an interrupted + save (crash, full disk, ...) never destroys a previously saved file. Pass + `atomic=False` for the marginally faster direct write without that + protection. + - the integer values of the serialized enums (`AlgorithmType`, + `SvcContainer.RegulationMode`, `HvdcLineContainer.ConvertersMode`, + `ConverterStationInfo.ConverterType` -- the last three are now exposed to + python) are pinned by a test (`TestSerializedEnumValues`) that fails with + a "bump BINARY_FORMAT_VERSION" message if they are renumbered. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 10a91c20..5ffe442d 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -60,9 +60,10 @@ Individual element containers work the same way: corrupted internal sizes (every count in the file is checked against the real file size *before* anything is allocated), files that contain an object of a different type (*eg* loading a ``LoadContainer`` file with ``StorageContainer.load_binary``), and files with unexpected trailing - bytes are all rejected. ``save_binary`` on the other hand is **atomic**: it writes to a temporary - file that only replaces the destination once fully written, so an interrupted save never destroys - a previously saved file. Note that the format stores raw native data: files are meant to be written + bytes are all rejected. ``save_binary`` on the other hand is **atomic by default**: it writes to a + temporary file that only replaces the destination once fully written, so an interrupted save never + destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead + (marginally faster -- it skips one temporary file and rename -- but without that protection). Note that the format stores raw native data: files are meant to be written and read by builds sharing the same data layout (same endianness, ``real_type``, ...) -- there is no checksum nor cross-platform migration, pickle remains the portable format. diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 44fdc2ff..f98a133b 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -372,6 +372,23 @@ def test_atomic_save(self): grid_1 = type(grid).load_binary(path) assert len(compare_network_input(grid, grid_1)) == 0 + def test_non_atomic_save(self): + """atomic=False writes the destination directly (the marginally + faster path, no temporary file): the file must still round-trip.""" + grid = self._make_grid() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_binary_fast.lsb") + grid.save_binary(path, atomic=False) + assert not os.path.exists(path + ".lsb_tmp") + grid_1 = type(grid).load_binary(path) + assert len(compare_network_input(grid, grid_1)) == 0 + + # overwriting an existing file the fast way works too + grid.save_binary(path, atomic=False) + grid_1 = type(grid).load_binary(path) + assert len(compare_network_input(grid, grid_1)) == 0 + def test_corrupted_file(self): grid = self._make_grid() @@ -396,6 +413,59 @@ def test_corrupted_file(self): type(grid).load_binary(garbage_path) +class TestSerializedEnumValues(unittest.TestCase): + """The integer values of these C++ enums are written verbatim into the + binary files (and into pickles), so they are part of the serialized + format even though no StateRes tuple changes when they are renumbered. + + If this test fails: either restore the previous numbering (if the change + was accidental -- note that for AlgorithmType, inserting a member + anywhere but at the very end shifts every following value), or, if the + renumbering is deliberate, bump BINARY_FORMAT_VERSION in + src/core/BinaryArchive.hpp (and regenerate the reference fixture of + TestBinaryLayoutUnchanged), then update the expected values here. + """ + + def _check_enum(self, enum_cls, expected): + actual = {name: int(value) for name, value in enum_cls.__members__.items()} + assert actual == expected, ( + f"The integer values of {enum_cls.__name__} changed, and they are " + f"serialized verbatim in binary files / pickles: bump " + f"BINARY_FORMAT_VERSION in src/core/BinaryArchive.hpp if this is " + f"deliberate (see this test's docstring). Expected {expected}, " + f"got {actual}") + + def test_algorithm_type(self): + from lightsim2grid.lightsim2grid_cpp import AlgorithmType + self._check_enum(AlgorithmType, { + "NR_SparseLU": 0, "NR_KLU": 1, "GaussSeidel": 2, "DC_SparseLU": 3, + "GaussSeidelSynch": 4, "NR_NICSLU": 5, + "NRSing_SparseLU": 6, "NRSing_KLU": 7, "NRSing_NICSLU": 8, + "DC_KLU": 9, "DC_NICSLU": 10, + "NR_CKTSO": 11, "NRSing_CKTSO": 12, "DC_CKTSO": 13, + "FDPF_XB_SparseLU": 14, "FDPF_BX_SparseLU": 15, + "FDPF_XB_KLU": 16, "FDPF_BX_KLU": 17, + "FDPF_XB_NICSLU": 18, "FDPF_BX_NICSLU": 19, + "FDPF_XB_CKTSO": 20, "FDPF_BX_CKTSO": 21, + "Custom": 22, + }) + + def test_svc_regulation_mode(self): + from lightsim2grid.lightsim2grid_cpp import SvcContainer + self._check_enum(SvcContainer.RegulationMode, + {"OFF": 0, "VOLTAGE": 1, "REACTIVE_POWER": 2}) + + def test_hvdc_converters_mode(self): + from lightsim2grid.lightsim2grid_cpp import HvdcLineContainer + self._check_enum(HvdcLineContainer.ConvertersMode, + {"SIDE_1_RECTIFIER": 0, "SIDE_2_RECTIFIER": 1}) + + def test_converter_station_type(self): + from lightsim2grid.lightsim2grid_cpp import ConverterStationInfo + self._check_enum(ConverterStationInfo.ConverterType, + {"VSC": 0, "LCC": 1}) + + # reference file saved with binary format 1 (see BINARY_FORMAT_VERSION in # src/core/BinaryArchive.hpp) + a few values it is known to contain, used by # TestBinaryLayoutUnchanged below. Regenerate (only after a deliberate format diff --git a/src/bindings/python/binary_helpers.hpp b/src/bindings/python/binary_helpers.hpp index 985761c1..d320c8ff 100644 --- a/src/bindings/python/binary_helpers.hpp +++ b/src/bindings/python/binary_helpers.hpp @@ -30,12 +30,15 @@ namespace py = pybind11; // unit where those macros carry the real version (mirrors pickle_helpers.hpp). template void add_binary_serialization(py::class_& cls) { - cls.def("save_binary", [](const T& obj, const std::string& path) { - ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); - }, py::arg("path"), + cls.def("save_binary", [](const T& obj, const std::string& path, bool atomic) { + ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); + }, py::arg("path"), py::arg("atomic") = true, "Save this object's state to a fast custom binary file (additive alternative " - "to pickle). The write is atomic: an existing file at that path is only " - "replaced once the new content has been written completely. The file stays " + "to pickle). By default (atomic=True) the write is atomic: an existing file " + "at that path is only replaced once the new content has been written " + "completely (an interrupted save never destroys a previous file). Pass " + "atomic=False to write the destination directly instead -- marginally faster " + "(skips one temporary file + rename), without that protection. The file stays " "readable by any lightsim2grid version sharing the same binary format number."); cls.def_static("load_binary", [](const std::string& path) { return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); diff --git a/src/bindings/python/binding_containers.cpp b/src/bindings/python/binding_containers.cpp index 014ea843..482bfbf5 100644 --- a/src/bindings/python/binding_containers.cpp +++ b/src/bindings/python/binding_containers.cpp @@ -65,6 +65,12 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()); add_pickle(svc_cls, "SvcContainer"); add_binary_serialization(svc_cls); + // exposed (also) so TestSerializedEnumValues can pin the integer values, + // which are serialized verbatim in binary files and pickles + py::enum_(svc_cls, "RegulationMode", "Regulation mode of an SVC (values follow the IIDM model of powsybl)") + .value("OFF", SvcContainer::RegulationMode::OFF) + .value("VOLTAGE", SvcContainer::RegulationMode::VOLTAGE) + .value("REACTIVE_POWER", SvcContainer::RegulationMode::REACTIVE_POWER); py::class_(m, "SvcInfo", "Information about one Static Var Compensator (SVC).") .def_readonly("id", &SvcInfo::id, DocIterator::id.c_str()) @@ -323,7 +329,13 @@ void bind_containers(py::module_& m) { .def_readonly("voltage_level1_id", &LineInfo::sub_1_id, DocIterator::sub_id.c_str()) .def_readonly("voltage_level2_id", &LineInfo::sub_2_id, DocIterator::sub_id.c_str()); - py::class_(m, "ConverterStationInfo", "Information about one hvdc converter station (VSC or LCC)") + auto cs_info_cls = py::class_(m, "ConverterStationInfo", "Information about one hvdc converter station (VSC or LCC)"); + // exposed (also) so TestSerializedEnumValues can pin the integer values, + // which are serialized verbatim in binary files and pickles + py::enum_(cs_info_cls, "ConverterType", "Type of an hvdc converter station") + .value("VSC", ConverterStationContainer::ConverterType::VSC) + .value("LCC", ConverterStationContainer::ConverterType::LCC); + cs_info_cls .def_readonly("id", &ConverterStationInfo::id, DocIterator::id.c_str()) .def_readonly("name", &ConverterStationInfo::name, DocIterator::name.c_str()) .def_readonly("sub_id", &ConverterStationInfo::sub_id, DocIterator::sub_id.c_str()) @@ -356,6 +368,11 @@ void bind_containers(py::module_& m) { .def("get_bus_id_side_2", &HvdcLineContainer::get_bus_id_side_2_numpy); add_pickle(dcline_cls, "HvdcLineContainer"); add_binary_serialization(dcline_cls); + // exposed (also) so TestSerializedEnumValues can pin the integer values, + // which are serialized verbatim in binary files and pickles + py::enum_(dcline_cls, "ConvertersMode", "Which side of an hvdc line is the rectifier (the other being the inverter)") + .value("SIDE_1_RECTIFIER", HvdcLineContainer::ConvertersMode::SIDE_1_RECTIFIER) + .value("SIDE_2_RECTIFIER", HvdcLineContainer::ConvertersMode::SIDE_2_RECTIFIER); py::class_(m, "HvdcLineInfo", DocIterator::DCLineInfo.c_str()) .def_readonly("id", &HvdcLineInfo::id, DocIterator::id.c_str()) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index c0a67030..6976dd02 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -22,6 +22,12 @@ namespace ls2g { +// /!\ the integer values of this enum are serialized verbatim (binary +// save_binary files and pickles, via LSGrid::StateRes): only APPEND new +// members at the end (before Custom is NOT safe either: Custom's value +// would shift). Renumbering existing members requires bumping +// BINARY_FORMAT_VERSION (BinaryArchive.hpp). Guarded python side by +// TestSerializedEnumValues in test_binary_serialization.py. enum class LS2G_API AlgorithmType {NR_SparseLU, NR_KLU, GaussSeidel, DC_SparseLU, GaussSeidelSynch, NR_NICSLU, NRSing_SparseLU, NRSing_KLU, NRSing_NICSLU, DC_KLU, DC_NICSLU, diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index a8ace1ab..9e4c951c 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -47,22 +47,32 @@ std::string supported_formats_str() } // anonymous namespace -BinaryArchive::BinaryArchive(const std::string & path, Mode mode): +BinaryArchive::BinaryArchive(const std::string & path, Mode mode, bool atomic_write): mode_(mode), path_(path), + atomic_write_(atomic_write), tmp_path_(), committed_(false), file_size_(0) { if (mode_ == Mode::Write) { - // write to a temporary file: the destination is only replaced in - // commit(), after the whole state has been written successfully. - tmp_path_ = path_ + ".lsb_tmp"; - ofs_.open(tmp_path_, std::ios::binary | std::ios::out | std::ios::trunc); - if (!ofs_.is_open()) { - throw std::runtime_error( - "BinaryArchive: cannot open file for writing: '" + path_ + - "' (temporary file '" + tmp_path_ + "' could not be created)"); + if (atomic_write_) { + // write to a temporary file: the destination is only replaced in + // commit(), after the whole state has been written successfully. + tmp_path_ = path_ + ".lsb_tmp"; + ofs_.open(tmp_path_, std::ios::binary | std::ios::out | std::ios::trunc); + if (!ofs_.is_open()) { + throw std::runtime_error( + "BinaryArchive: cannot open file for writing: '" + path_ + + "' (temporary file '" + tmp_path_ + "' could not be created)"); + } + } else { + // fast path (atomic=false): truncate + write the destination in + // place, no rename; an interrupted save leaves a truncated file + ofs_.open(path_, std::ios::binary | std::ios::out | std::ios::trunc); + if (!ofs_.is_open()) { + throw std::runtime_error("BinaryArchive: cannot open file for writing: '" + path_ + "'"); + } } } else { ifs_.open(path_, std::ios::binary | std::ios::in); @@ -79,7 +89,7 @@ BinaryArchive::BinaryArchive(const std::string & path, Mode mode): BinaryArchive::~BinaryArchive() { - if (mode_ == Mode::Write && !committed_) { + if (mode_ == Mode::Write && atomic_write_ && !committed_) { // interrupted write (exception during serialization, or commit() // failed): drop the temporary file, the destination is untouched. if (ofs_.is_open()) ofs_.close(); @@ -97,14 +107,16 @@ void BinaryArchive::commit() throw std::runtime_error("BinaryArchive: failed to write to file '" + path_ + "'"); } ofs_.close(); - // atomically replace the destination. std::rename overwrites on POSIX - // but fails on Windows when the destination exists, so remove it first - // (failure to remove is ignored: rename below reports the real error). - std::remove(path_.c_str()); - if (std::rename(tmp_path_.c_str(), path_.c_str()) != 0) { - throw std::runtime_error( - "BinaryArchive: could not move the temporary file '" + tmp_path_ + - "' to its final destination '" + path_ + "'"); + if (atomic_write_) { + // atomically replace the destination. std::rename overwrites on POSIX + // but fails on Windows when the destination exists, so remove it first + // (failure to remove is ignored: rename below reports the real error). + std::remove(path_.c_str()); + if (std::rename(tmp_path_.c_str(), path_.c_str()) != 0) { + throw std::runtime_error( + "BinaryArchive: could not move the temporary file '" + tmp_path_ + + "' to its final destination '" + path_ + "'"); + } } committed_ = true; } diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 0d271d9d..58c804a1 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -28,9 +28,11 @@ // trigger a multi-gigabyte allocation; // - after the state is fully read, the file must be fully consumed // (trailing bytes are treated as corruption); -// - writes go to a temporary file that is atomically renamed over the -// destination only on success, so a crash / full disk mid-save never -// destroys a previously good file. +// - by default (atomic=true), writes go to a temporary file that is +// atomically renamed over the destination only on success, so a crash / +// full disk mid-save never destroys a previously good file; pass +// atomic=false to save_binary to skip the temporary file (marginally +// faster, no such protection). // NOT covered (possible future work): a payload checksum (bit flips inside // numeric data that keep all counts consistent load silently), and an // ABI descriptor (endianness / sizeof(real_type)): files are assumed to be @@ -78,21 +80,27 @@ class LS2G_API BinaryArchive public: enum class Mode { Write, Read }; - // Write mode writes to a temporary file (path + ".lsb_tmp"); the - // data only replaces `path` when commit() is called after a fully - // successful write. Read mode opens `path` directly and captures - // its size for the bounds checks below. - BinaryArchive(const std::string & path, Mode mode); + // Write mode: when `atomic_write` is true (the default) the data + // goes to a temporary file (path + ".lsb_tmp") that only replaces + // `path` when commit() is called after a fully successful write; + // when false, `path` is truncated and written directly (marginally + // faster -- skips one rename -- but an interrupted save leaves a + // truncated file at the destination, destroying any previous one). + // Read mode opens `path` directly and captures its size for the + // bounds checks below (`atomic_write` is ignored). + BinaryArchive(const std::string & path, Mode mode, bool atomic_write = true); ~BinaryArchive(); BinaryArchive(const BinaryArchive &) = delete; BinaryArchive & operator=(const BinaryArchive &) = delete; - // Write mode only: flush + close the temporary file and atomically - // rename it over the destination path. Throws std::runtime_error on - // any failure (the destination is left untouched in that case). If - // commit() is never called (eg an exception during the write), the - // destructor removes the temporary file and the destination keeps - // its previous content. + // Write mode only: flush + close the file; in atomic mode, then + // atomically rename the temporary file over the destination path. + // Throws std::runtime_error on any failure (in atomic mode the + // destination is left untouched in that case). If commit() is never + // called (eg an exception during the write), the destructor removes + // the temporary file in atomic mode and the destination keeps its + // previous content (in non-atomic mode the partial file remains, + // and is rejected at load time as truncated). void commit(); // low level: the only methods that touch the underlying stream. @@ -190,7 +198,8 @@ class LS2G_API BinaryArchive std::ifstream ifs_; Mode mode_; std::string path_; - std::string tmp_path_; // write mode: temporary file actually written + bool atomic_write_; // write mode: write to temp file + rename on commit() + std::string tmp_path_; // write mode, atomic: temporary file actually written bool committed_; // write mode: commit() completed std::uint64_t file_size_; // read mode: total size of the file }; @@ -354,8 +363,9 @@ void archive_read_value(BinaryArchive & ar, T & v) { // into / checked against the file header. template void save_binary_generic(const T & obj, const std::string & path, - const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { - BinaryArchive ar(path, BinaryArchive::Mode::Write); + const std::string & v_major, const std::string & v_medium, const std::string & v_minor, + bool atomic = true) { + BinaryArchive ar(path, BinaryArchive::Mode::Write, atomic); ar.write_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state = obj.get_state(); archive_write_value(ar, state); diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index c1cb5f79..4694d03e 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -222,8 +222,8 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) set_dc_algo_config(dc_algo_cfg); }; -void LSGrid::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void LSGrid::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } LSGrid LSGrid::load_binary(const std::string & path) { diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 7c3bbd3a..183bf2cd 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -503,7 +503,7 @@ class LS2G_API LSGrid final // fast binary serialization (additive alternative to pickle, see // BinaryArchive.hpp -- readable by any lightsim2grid version sharing // the same BINARY_FORMAT_VERSION) - void save_binary(const std::string & path) const; + void save_binary(const std::string & path, bool atomic = true) const; static LSGrid load_binary(const std::string & path); static const char * binary_type_tag() { return "LSGrid"; } // written into / checked against the binary file header // binary-load hook (detected by load_binary_generic): rewrites the diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index b7d7966a..9aa598db 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -58,8 +58,8 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(&bus_vmax_kv[0], bus_vmax_kv.size()); } -void SubstationContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void SubstationContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } SubstationContainer SubstationContainer::load_binary(const std::string & path) { diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 8f04b251..9c45ad20 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -72,7 +72,7 @@ class LS2G_API SubstationContainer final : public IteratorAdder & Va, } } -void HvdcLineContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void HvdcLineContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } HvdcLineContainer HvdcLineContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index ca54dbda..0203de95 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -94,6 +94,10 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & /*Va*/, } } -void ShuntContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void ShuntContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } ShuntContainer ShuntContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index c43ff8b7..6b6fd1e0 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -67,7 +67,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator void set_state(ShuntContainer::StateRes & my_state ); // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) - void save_binary(const std::string & path) const; + void save_binary(const std::string & path, bool atomic = true) const; static ShuntContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "ShuntContainer"; } // written into / checked against the binary file header diff --git a/src/core/element_container/StorageContainer.cpp b/src/core/element_container/StorageContainer.cpp index 1c2c98e7..27a3372d 100644 --- a/src/core/element_container/StorageContainer.cpp +++ b/src/core/element_container/StorageContainer.cpp @@ -61,8 +61,8 @@ void StorageContainer::fillSbus(CplxVect & Sbus, } } -void StorageContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void StorageContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } StorageContainer StorageContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index e763f157..6197a6f4 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -66,7 +66,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat void set_state(StorageContainer::StateRes & my_state); // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) - void save_binary(const std::string & path) const; + void save_binary(const std::string & path, bool atomic = true) const; static StorageContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "StorageContainer"; } // written into / checked against the binary file header diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index dd016314..ed853672 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -236,8 +236,8 @@ bool SvcContainer::_change_bus(int svc_id, GridModelBusId new_bus_id, DualAlgoCo return true; } -void SvcContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void SvcContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } SvcContainer SvcContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index d05020ff..30be9933 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -59,6 +59,10 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder Date: Sun, 12 Jul 2026 05:08:41 +0000 Subject: [PATCH 068/166] Address review: optional atomic save + serialized-enum value guard - save_binary now takes atomic=True (default): pass atomic=False to write the destination directly, skipping the temporary file + rename, as the marginally faster but unprotected variant (review request: the feature is about speed, atomicity should be a choice) - the integer values of the enums serialized verbatim in binary files and pickles (AlgorithmType, SvcContainer::RegulationMode, HvdcLineContainer::ConvertersMode, ConverterStationContainer::ConverterType) now carry a comment stating that renumbering them requires bumping BINARY_FORMAT_VERSION, the three container enums are exposed to python, and a new python test (TestSerializedEnumValues) pins all four value sets with an error message pointing at BINARY_FORMAT_VERSION Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 13 +++- docs/binary_serialization.rst | 7 +- .../tests/test_binary_serialization.py | 70 +++++++++++++++++++ src/bindings/python/binary_helpers.hpp | 13 ++-- src/bindings/python/binding_containers.cpp | 19 ++++- src/core/AlgorithmSelector.hpp | 6 ++ src/core/BinaryArchive.cpp | 48 ++++++++----- src/core/BinaryArchive.hpp | 44 +++++++----- src/core/LSGrid.cpp | 4 +- src/core/LSGrid.hpp | 2 +- src/core/SubstationContainer.cpp | 4 +- src/core/SubstationContainer.hpp | 2 +- .../ConverterStationContainer.hpp | 4 ++ .../element_container/GeneratorContainer.cpp | 4 +- .../element_container/GeneratorContainer.hpp | 2 +- .../element_container/HvdcLineContainer.cpp | 4 +- .../element_container/HvdcLineContainer.hpp | 6 +- src/core/element_container/LineContainer.cpp | 4 +- src/core/element_container/LineContainer.hpp | 2 +- src/core/element_container/LoadContainer.cpp | 4 +- src/core/element_container/LoadContainer.hpp | 2 +- src/core/element_container/SGenContainer.cpp | 4 +- src/core/element_container/SGenContainer.hpp | 2 +- src/core/element_container/ShuntContainer.cpp | 4 +- src/core/element_container/ShuntContainer.hpp | 2 +- .../element_container/StorageContainer.cpp | 4 +- .../element_container/StorageContainer.hpp | 2 +- src/core/element_container/SvcContainer.cpp | 4 +- src/core/element_container/SvcContainer.hpp | 6 +- src/core/element_container/TrafoContainer.cpp | 4 +- src/core/element_container/TrafoContainer.hpp | 2 +- 31 files changed, 218 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0fdf2963..a2564a44 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -358,9 +358,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. succeeding when the layouts happen to match (*eg* `LoadContainer` vs `StorageContainer`). - trailing bytes after the end of the data are now rejected as corruption. - - `save_binary` is now **atomic**: it writes to a temporary file that only - replaces the destination once fully written, so an interrupted save (crash, - full disk, ...) never destroys a previously saved file. + - `save_binary` is now **atomic by default**: it writes to a temporary file + that only replaces the destination once fully written, so an interrupted + save (crash, full disk, ...) never destroys a previously saved file. Pass + `atomic=False` for the marginally faster direct write without that + protection. + - the integer values of the serialized enums (`AlgorithmType`, + `SvcContainer.RegulationMode`, `HvdcLineContainer.ConvertersMode`, + `ConverterStationInfo.ConverterType` -- the last three are now exposed to + python) are pinned by a test (`TestSerializedEnumValues`) that fails with + a "bump BINARY_FORMAT_VERSION" message if they are renumbered. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 10a91c20..5ffe442d 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -60,9 +60,10 @@ Individual element containers work the same way: corrupted internal sizes (every count in the file is checked against the real file size *before* anything is allocated), files that contain an object of a different type (*eg* loading a ``LoadContainer`` file with ``StorageContainer.load_binary``), and files with unexpected trailing - bytes are all rejected. ``save_binary`` on the other hand is **atomic**: it writes to a temporary - file that only replaces the destination once fully written, so an interrupted save never destroys - a previously saved file. Note that the format stores raw native data: files are meant to be written + bytes are all rejected. ``save_binary`` on the other hand is **atomic by default**: it writes to a + temporary file that only replaces the destination once fully written, so an interrupted save never + destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead + (marginally faster -- it skips one temporary file and rename -- but without that protection). Note that the format stores raw native data: files are meant to be written and read by builds sharing the same data layout (same endianness, ``real_type``, ...) -- there is no checksum nor cross-platform migration, pickle remains the portable format. diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 44fdc2ff..f98a133b 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -372,6 +372,23 @@ def test_atomic_save(self): grid_1 = type(grid).load_binary(path) assert len(compare_network_input(grid, grid_1)) == 0 + def test_non_atomic_save(self): + """atomic=False writes the destination directly (the marginally + faster path, no temporary file): the file must still round-trip.""" + grid = self._make_grid() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test_binary_fast.lsb") + grid.save_binary(path, atomic=False) + assert not os.path.exists(path + ".lsb_tmp") + grid_1 = type(grid).load_binary(path) + assert len(compare_network_input(grid, grid_1)) == 0 + + # overwriting an existing file the fast way works too + grid.save_binary(path, atomic=False) + grid_1 = type(grid).load_binary(path) + assert len(compare_network_input(grid, grid_1)) == 0 + def test_corrupted_file(self): grid = self._make_grid() @@ -396,6 +413,59 @@ def test_corrupted_file(self): type(grid).load_binary(garbage_path) +class TestSerializedEnumValues(unittest.TestCase): + """The integer values of these C++ enums are written verbatim into the + binary files (and into pickles), so they are part of the serialized + format even though no StateRes tuple changes when they are renumbered. + + If this test fails: either restore the previous numbering (if the change + was accidental -- note that for AlgorithmType, inserting a member + anywhere but at the very end shifts every following value), or, if the + renumbering is deliberate, bump BINARY_FORMAT_VERSION in + src/core/BinaryArchive.hpp (and regenerate the reference fixture of + TestBinaryLayoutUnchanged), then update the expected values here. + """ + + def _check_enum(self, enum_cls, expected): + actual = {name: int(value) for name, value in enum_cls.__members__.items()} + assert actual == expected, ( + f"The integer values of {enum_cls.__name__} changed, and they are " + f"serialized verbatim in binary files / pickles: bump " + f"BINARY_FORMAT_VERSION in src/core/BinaryArchive.hpp if this is " + f"deliberate (see this test's docstring). Expected {expected}, " + f"got {actual}") + + def test_algorithm_type(self): + from lightsim2grid.lightsim2grid_cpp import AlgorithmType + self._check_enum(AlgorithmType, { + "NR_SparseLU": 0, "NR_KLU": 1, "GaussSeidel": 2, "DC_SparseLU": 3, + "GaussSeidelSynch": 4, "NR_NICSLU": 5, + "NRSing_SparseLU": 6, "NRSing_KLU": 7, "NRSing_NICSLU": 8, + "DC_KLU": 9, "DC_NICSLU": 10, + "NR_CKTSO": 11, "NRSing_CKTSO": 12, "DC_CKTSO": 13, + "FDPF_XB_SparseLU": 14, "FDPF_BX_SparseLU": 15, + "FDPF_XB_KLU": 16, "FDPF_BX_KLU": 17, + "FDPF_XB_NICSLU": 18, "FDPF_BX_NICSLU": 19, + "FDPF_XB_CKTSO": 20, "FDPF_BX_CKTSO": 21, + "Custom": 22, + }) + + def test_svc_regulation_mode(self): + from lightsim2grid.lightsim2grid_cpp import SvcContainer + self._check_enum(SvcContainer.RegulationMode, + {"OFF": 0, "VOLTAGE": 1, "REACTIVE_POWER": 2}) + + def test_hvdc_converters_mode(self): + from lightsim2grid.lightsim2grid_cpp import HvdcLineContainer + self._check_enum(HvdcLineContainer.ConvertersMode, + {"SIDE_1_RECTIFIER": 0, "SIDE_2_RECTIFIER": 1}) + + def test_converter_station_type(self): + from lightsim2grid.lightsim2grid_cpp import ConverterStationInfo + self._check_enum(ConverterStationInfo.ConverterType, + {"VSC": 0, "LCC": 1}) + + # reference file saved with binary format 1 (see BINARY_FORMAT_VERSION in # src/core/BinaryArchive.hpp) + a few values it is known to contain, used by # TestBinaryLayoutUnchanged below. Regenerate (only after a deliberate format diff --git a/src/bindings/python/binary_helpers.hpp b/src/bindings/python/binary_helpers.hpp index 985761c1..d320c8ff 100644 --- a/src/bindings/python/binary_helpers.hpp +++ b/src/bindings/python/binary_helpers.hpp @@ -30,12 +30,15 @@ namespace py = pybind11; // unit where those macros carry the real version (mirrors pickle_helpers.hpp). template void add_binary_serialization(py::class_& cls) { - cls.def("save_binary", [](const T& obj, const std::string& path) { - ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); - }, py::arg("path"), + cls.def("save_binary", [](const T& obj, const std::string& path, bool atomic) { + ls2g::save_binary_generic(obj, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); + }, py::arg("path"), py::arg("atomic") = true, "Save this object's state to a fast custom binary file (additive alternative " - "to pickle). The write is atomic: an existing file at that path is only " - "replaced once the new content has been written completely. The file stays " + "to pickle). By default (atomic=True) the write is atomic: an existing file " + "at that path is only replaced once the new content has been written " + "completely (an interrupted save never destroys a previous file). Pass " + "atomic=False to write the destination directly instead -- marginally faster " + "(skips one temporary file + rename), without that protection. The file stays " "readable by any lightsim2grid version sharing the same binary format number."); cls.def_static("load_binary", [](const std::string& path) { return ls2g::load_binary_generic(path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); diff --git a/src/bindings/python/binding_containers.cpp b/src/bindings/python/binding_containers.cpp index 014ea843..482bfbf5 100644 --- a/src/bindings/python/binding_containers.cpp +++ b/src/bindings/python/binding_containers.cpp @@ -65,6 +65,12 @@ void bind_containers(py::module_& m) { }, py::keep_alive<0, 1>()); add_pickle(svc_cls, "SvcContainer"); add_binary_serialization(svc_cls); + // exposed (also) so TestSerializedEnumValues can pin the integer values, + // which are serialized verbatim in binary files and pickles + py::enum_(svc_cls, "RegulationMode", "Regulation mode of an SVC (values follow the IIDM model of powsybl)") + .value("OFF", SvcContainer::RegulationMode::OFF) + .value("VOLTAGE", SvcContainer::RegulationMode::VOLTAGE) + .value("REACTIVE_POWER", SvcContainer::RegulationMode::REACTIVE_POWER); py::class_(m, "SvcInfo", "Information about one Static Var Compensator (SVC).") .def_readonly("id", &SvcInfo::id, DocIterator::id.c_str()) @@ -323,7 +329,13 @@ void bind_containers(py::module_& m) { .def_readonly("voltage_level1_id", &LineInfo::sub_1_id, DocIterator::sub_id.c_str()) .def_readonly("voltage_level2_id", &LineInfo::sub_2_id, DocIterator::sub_id.c_str()); - py::class_(m, "ConverterStationInfo", "Information about one hvdc converter station (VSC or LCC)") + auto cs_info_cls = py::class_(m, "ConverterStationInfo", "Information about one hvdc converter station (VSC or LCC)"); + // exposed (also) so TestSerializedEnumValues can pin the integer values, + // which are serialized verbatim in binary files and pickles + py::enum_(cs_info_cls, "ConverterType", "Type of an hvdc converter station") + .value("VSC", ConverterStationContainer::ConverterType::VSC) + .value("LCC", ConverterStationContainer::ConverterType::LCC); + cs_info_cls .def_readonly("id", &ConverterStationInfo::id, DocIterator::id.c_str()) .def_readonly("name", &ConverterStationInfo::name, DocIterator::name.c_str()) .def_readonly("sub_id", &ConverterStationInfo::sub_id, DocIterator::sub_id.c_str()) @@ -356,6 +368,11 @@ void bind_containers(py::module_& m) { .def("get_bus_id_side_2", &HvdcLineContainer::get_bus_id_side_2_numpy); add_pickle(dcline_cls, "HvdcLineContainer"); add_binary_serialization(dcline_cls); + // exposed (also) so TestSerializedEnumValues can pin the integer values, + // which are serialized verbatim in binary files and pickles + py::enum_(dcline_cls, "ConvertersMode", "Which side of an hvdc line is the rectifier (the other being the inverter)") + .value("SIDE_1_RECTIFIER", HvdcLineContainer::ConvertersMode::SIDE_1_RECTIFIER) + .value("SIDE_2_RECTIFIER", HvdcLineContainer::ConvertersMode::SIDE_2_RECTIFIER); py::class_(m, "HvdcLineInfo", DocIterator::DCLineInfo.c_str()) .def_readonly("id", &HvdcLineInfo::id, DocIterator::id.c_str()) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index c0a67030..6976dd02 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -22,6 +22,12 @@ namespace ls2g { +// /!\ the integer values of this enum are serialized verbatim (binary +// save_binary files and pickles, via LSGrid::StateRes): only APPEND new +// members at the end (before Custom is NOT safe either: Custom's value +// would shift). Renumbering existing members requires bumping +// BINARY_FORMAT_VERSION (BinaryArchive.hpp). Guarded python side by +// TestSerializedEnumValues in test_binary_serialization.py. enum class LS2G_API AlgorithmType {NR_SparseLU, NR_KLU, GaussSeidel, DC_SparseLU, GaussSeidelSynch, NR_NICSLU, NRSing_SparseLU, NRSing_KLU, NRSing_NICSLU, DC_KLU, DC_NICSLU, diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index a8ace1ab..9e4c951c 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -47,22 +47,32 @@ std::string supported_formats_str() } // anonymous namespace -BinaryArchive::BinaryArchive(const std::string & path, Mode mode): +BinaryArchive::BinaryArchive(const std::string & path, Mode mode, bool atomic_write): mode_(mode), path_(path), + atomic_write_(atomic_write), tmp_path_(), committed_(false), file_size_(0) { if (mode_ == Mode::Write) { - // write to a temporary file: the destination is only replaced in - // commit(), after the whole state has been written successfully. - tmp_path_ = path_ + ".lsb_tmp"; - ofs_.open(tmp_path_, std::ios::binary | std::ios::out | std::ios::trunc); - if (!ofs_.is_open()) { - throw std::runtime_error( - "BinaryArchive: cannot open file for writing: '" + path_ + - "' (temporary file '" + tmp_path_ + "' could not be created)"); + if (atomic_write_) { + // write to a temporary file: the destination is only replaced in + // commit(), after the whole state has been written successfully. + tmp_path_ = path_ + ".lsb_tmp"; + ofs_.open(tmp_path_, std::ios::binary | std::ios::out | std::ios::trunc); + if (!ofs_.is_open()) { + throw std::runtime_error( + "BinaryArchive: cannot open file for writing: '" + path_ + + "' (temporary file '" + tmp_path_ + "' could not be created)"); + } + } else { + // fast path (atomic=false): truncate + write the destination in + // place, no rename; an interrupted save leaves a truncated file + ofs_.open(path_, std::ios::binary | std::ios::out | std::ios::trunc); + if (!ofs_.is_open()) { + throw std::runtime_error("BinaryArchive: cannot open file for writing: '" + path_ + "'"); + } } } else { ifs_.open(path_, std::ios::binary | std::ios::in); @@ -79,7 +89,7 @@ BinaryArchive::BinaryArchive(const std::string & path, Mode mode): BinaryArchive::~BinaryArchive() { - if (mode_ == Mode::Write && !committed_) { + if (mode_ == Mode::Write && atomic_write_ && !committed_) { // interrupted write (exception during serialization, or commit() // failed): drop the temporary file, the destination is untouched. if (ofs_.is_open()) ofs_.close(); @@ -97,14 +107,16 @@ void BinaryArchive::commit() throw std::runtime_error("BinaryArchive: failed to write to file '" + path_ + "'"); } ofs_.close(); - // atomically replace the destination. std::rename overwrites on POSIX - // but fails on Windows when the destination exists, so remove it first - // (failure to remove is ignored: rename below reports the real error). - std::remove(path_.c_str()); - if (std::rename(tmp_path_.c_str(), path_.c_str()) != 0) { - throw std::runtime_error( - "BinaryArchive: could not move the temporary file '" + tmp_path_ + - "' to its final destination '" + path_ + "'"); + if (atomic_write_) { + // atomically replace the destination. std::rename overwrites on POSIX + // but fails on Windows when the destination exists, so remove it first + // (failure to remove is ignored: rename below reports the real error). + std::remove(path_.c_str()); + if (std::rename(tmp_path_.c_str(), path_.c_str()) != 0) { + throw std::runtime_error( + "BinaryArchive: could not move the temporary file '" + tmp_path_ + + "' to its final destination '" + path_ + "'"); + } } committed_ = true; } diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 0d271d9d..58c804a1 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -28,9 +28,11 @@ // trigger a multi-gigabyte allocation; // - after the state is fully read, the file must be fully consumed // (trailing bytes are treated as corruption); -// - writes go to a temporary file that is atomically renamed over the -// destination only on success, so a crash / full disk mid-save never -// destroys a previously good file. +// - by default (atomic=true), writes go to a temporary file that is +// atomically renamed over the destination only on success, so a crash / +// full disk mid-save never destroys a previously good file; pass +// atomic=false to save_binary to skip the temporary file (marginally +// faster, no such protection). // NOT covered (possible future work): a payload checksum (bit flips inside // numeric data that keep all counts consistent load silently), and an // ABI descriptor (endianness / sizeof(real_type)): files are assumed to be @@ -78,21 +80,27 @@ class LS2G_API BinaryArchive public: enum class Mode { Write, Read }; - // Write mode writes to a temporary file (path + ".lsb_tmp"); the - // data only replaces `path` when commit() is called after a fully - // successful write. Read mode opens `path` directly and captures - // its size for the bounds checks below. - BinaryArchive(const std::string & path, Mode mode); + // Write mode: when `atomic_write` is true (the default) the data + // goes to a temporary file (path + ".lsb_tmp") that only replaces + // `path` when commit() is called after a fully successful write; + // when false, `path` is truncated and written directly (marginally + // faster -- skips one rename -- but an interrupted save leaves a + // truncated file at the destination, destroying any previous one). + // Read mode opens `path` directly and captures its size for the + // bounds checks below (`atomic_write` is ignored). + BinaryArchive(const std::string & path, Mode mode, bool atomic_write = true); ~BinaryArchive(); BinaryArchive(const BinaryArchive &) = delete; BinaryArchive & operator=(const BinaryArchive &) = delete; - // Write mode only: flush + close the temporary file and atomically - // rename it over the destination path. Throws std::runtime_error on - // any failure (the destination is left untouched in that case). If - // commit() is never called (eg an exception during the write), the - // destructor removes the temporary file and the destination keeps - // its previous content. + // Write mode only: flush + close the file; in atomic mode, then + // atomically rename the temporary file over the destination path. + // Throws std::runtime_error on any failure (in atomic mode the + // destination is left untouched in that case). If commit() is never + // called (eg an exception during the write), the destructor removes + // the temporary file in atomic mode and the destination keeps its + // previous content (in non-atomic mode the partial file remains, + // and is rejected at load time as truncated). void commit(); // low level: the only methods that touch the underlying stream. @@ -190,7 +198,8 @@ class LS2G_API BinaryArchive std::ifstream ifs_; Mode mode_; std::string path_; - std::string tmp_path_; // write mode: temporary file actually written + bool atomic_write_; // write mode: write to temp file + rename on commit() + std::string tmp_path_; // write mode, atomic: temporary file actually written bool committed_; // write mode: commit() completed std::uint64_t file_size_; // read mode: total size of the file }; @@ -354,8 +363,9 @@ void archive_read_value(BinaryArchive & ar, T & v) { // into / checked against the file header. template void save_binary_generic(const T & obj, const std::string & path, - const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { - BinaryArchive ar(path, BinaryArchive::Mode::Write); + const std::string & v_major, const std::string & v_medium, const std::string & v_minor, + bool atomic = true) { + BinaryArchive ar(path, BinaryArchive::Mode::Write, atomic); ar.write_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state = obj.get_state(); archive_write_value(ar, state); diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index c1cb5f79..4694d03e 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -222,8 +222,8 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) set_dc_algo_config(dc_algo_cfg); }; -void LSGrid::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void LSGrid::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } LSGrid LSGrid::load_binary(const std::string & path) { diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 7c3bbd3a..183bf2cd 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -503,7 +503,7 @@ class LS2G_API LSGrid final // fast binary serialization (additive alternative to pickle, see // BinaryArchive.hpp -- readable by any lightsim2grid version sharing // the same BINARY_FORMAT_VERSION) - void save_binary(const std::string & path) const; + void save_binary(const std::string & path, bool atomic = true) const; static LSGrid load_binary(const std::string & path); static const char * binary_type_tag() { return "LSGrid"; } // written into / checked against the binary file header // binary-load hook (detected by load_binary_generic): rewrites the diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index b7d7966a..9aa598db 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -58,8 +58,8 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(&bus_vmax_kv[0], bus_vmax_kv.size()); } -void SubstationContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void SubstationContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } SubstationContainer SubstationContainer::load_binary(const std::string & path) { diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 8f04b251..9c45ad20 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -72,7 +72,7 @@ class LS2G_API SubstationContainer final : public IteratorAdder & Va, } } -void HvdcLineContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void HvdcLineContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } HvdcLineContainer HvdcLineContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index ca54dbda..0203de95 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -94,6 +94,10 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & /*Va*/, } } -void ShuntContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void ShuntContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } ShuntContainer ShuntContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index c43ff8b7..6b6fd1e0 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -67,7 +67,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator void set_state(ShuntContainer::StateRes & my_state ); // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) - void save_binary(const std::string & path) const; + void save_binary(const std::string & path, bool atomic = true) const; static ShuntContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "ShuntContainer"; } // written into / checked against the binary file header diff --git a/src/core/element_container/StorageContainer.cpp b/src/core/element_container/StorageContainer.cpp index 1c2c98e7..27a3372d 100644 --- a/src/core/element_container/StorageContainer.cpp +++ b/src/core/element_container/StorageContainer.cpp @@ -61,8 +61,8 @@ void StorageContainer::fillSbus(CplxVect & Sbus, } } -void StorageContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void StorageContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } StorageContainer StorageContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index e763f157..6197a6f4 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -66,7 +66,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat void set_state(StorageContainer::StateRes & my_state); // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) - void save_binary(const std::string & path) const; + void save_binary(const std::string & path, bool atomic = true) const; static StorageContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "StorageContainer"; } // written into / checked against the binary file header diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index dd016314..ed853672 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -236,8 +236,8 @@ bool SvcContainer::_change_bus(int svc_id, GridModelBusId new_bus_id, DualAlgoCo return true; } -void SvcContainer::save_binary(const std::string & path) const { - ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR); +void SvcContainer::save_binary(const std::string & path, bool atomic) const { + ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } SvcContainer SvcContainer::load_binary(const std::string & path) { diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index d05020ff..30be9933 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -59,6 +59,10 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder Date: Sat, 11 Jul 2026 20:23:36 +0000 Subject: [PATCH 069/166] Add memory-hardening CI: ASan+UBSan and debug-asserts jobs New GitHub Actions workflow (sanitizers.yml) complementing the functional test suites that run on CircleCI: - asan_ubsan job: builds the C++ core and bindings with -fsanitize=address,undefined (new __SANITIZE=1 env-driven CMake option) and runs the binary serialization, solver control, time series and contingency analysis test modules under AddressSanitizer + UndefinedBehaviorSanitizer - debug_asserts job: builds with -UNDEBUG -D_GLIBCXX_ASSERTIONS (new __DEBUG_ASSERTS=1 option), re-enabling the Eigen bounds assertions silenced by NDEBUG in Release builds plus the ABI-safe libstdc++ container checks, and runs the solver-heavy modules Also adds TestCorruptionSweep to test_binary_serialization.py: a deterministic mini-fuzzer that corrupts a valid binary file at every byte offset (single-byte flip, 8-byte 0xFF stamp, truncation) and checks load_binary never does anything worse than raising a clean RuntimeError. Runs in the regular suites too, but is most potent under the ASan job where any out-of-bounds read becomes a failure. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .github/workflows/sanitizers.yml | 99 +++++++++++++++++++ CHANGELOG.rst | 10 ++ .../tests/test_binary_serialization.py | 72 ++++++++++++++ src/bindings/python/CMakeLists.txt | 17 ++++ src/core/CMakeLists.txt | 30 ++++++ 5 files changed, 228 insertions(+) create mode 100644 .github/workflows/sanitizers.yml diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 00000000..21061ead --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,99 @@ +# Memory-hardening CI: builds the C++ core + bindings with instrumentation +# that the release wheels do not carry, and runs the test modules most likely +# to surface memory issues (out-of-bounds, use-after-free, UB, silenced +# assertions). Complements the functional suites on CircleCI, which run +# against plain optimized builds. + +name: Sanitizers + +on: + push: + branches: + - '*' + tags: + - 'v*.*.*' + +jobs: + asan_ubsan: + # AddressSanitizer + UndefinedBehaviorSanitizer over the untrusted-input + # path (binary serialization) and the heaviest C++ compute paths (solver + # control, time series, contingency analysis). + name: ASan + UBSan tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements_compile_ci.txt + python -m pip install grid2op scipy pandapower + + - name: Build lightsim2grid with ASan + UBSan + # __SANITIZE=1 is picked up by src/core/CMakeLists.txt and + # src/bindings/python/CMakeLists.txt: -fsanitize=address,undefined + # on both lightsim2grid_core and lightsim2grid_cpp + run: __SANITIZE=1 python -m pip install -v . --no-build-isolation + + - name: Run tests under ASan + UBSan + # The sanitized code lives in a python extension, so the ASan runtime + # must be the first thing loaded into the (uninstrumented) python + # process: hence LD_PRELOAD. Leak detection stays off: CPython's + # allocator keeps interned objects alive by design and would drown + # real reports in noise. + env: + ASAN_OPTIONS: detect_leaks=0 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: | + export LD_PRELOAD=$(gcc -print-file-name=libasan.so) + cd lightsim2grid/tests + python -W ignore -m unittest -v \ + test_binary_serialization \ + test_solver_control \ + test_TimeSerie \ + test_time_series_dc \ + test_SecurityAnlysis \ + test_DCSecurityAnlysis \ + test_SecurityAnlysis_cpp \ + test_ContingencyAnalysis_limit_violations \ + test_ContingencyAnalysis_split + + debug_asserts: + # Release wheels are compiled with NDEBUG, which silences every Eigen + # bounds assertion: logically-wrong-but-in-bounds indexing is invisible + # to ASan. This build re-enables them (plus the ABI-safe libstdc++ + # container checks) and runs the solver-heavy modules. + name: Eigen/libstdc++ assertions tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements_compile_ci.txt + python -m pip install grid2op scipy pandapower + + - name: Build lightsim2grid with assertions re-enabled + # __DEBUG_ASSERTS=1 -> -UNDEBUG -D_GLIBCXX_ASSERTIONS on both targets + run: __DEBUG_ASSERTS=1 python -m pip install -v . --no-build-isolation + + - name: Run solver tests with assertions active + run: | + cd lightsim2grid/tests + python -W ignore -m unittest -v \ + test_ACPF \ + test_DCPF \ + test_solver_control diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f202f5f2..b8b42f14 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -393,6 +393,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `ConverterStationInfo.ConverterType` -- the last three are now exposed to python) are pinned by a test (`TestSerializedEnumValues`) that fails with a "bump BINARY_FORMAT_VERSION" message if they are renumbered. +- [ADDED] memory-hardening CI (`.github/workflows/sanitizers.yml`): an + **ASan + UBSan** job (build with `__SANITIZE=1`, runs the binary + serialization, solver control, time series and contingency analysis test + modules under AddressSanitizer + UndefinedBehaviorSanitizer) and a + **debug-assertions** job (build with `__DEBUG_ASSERTS=1`: + `-UNDEBUG -D_GLIBCXX_ASSERTIONS`, re-enabling the Eigen bounds assertions + that Release builds silence, runs the solver-heavy modules). Also a new + corruption-sweep test (`TestCorruptionSweep`) that corrupts a valid binary + file at every byte offset and checks `load_binary` never does anything + worse than raising a clean `RuntimeError`. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index f98a133b..4cf2cbde 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -413,6 +413,78 @@ def test_corrupted_file(self): type(grid).load_binary(garbage_path) +class TestCorruptionSweep(unittest.TestCase): + """Deterministic mini-fuzzer for load_binary: corrupt a valid file at + EVERY byte offset and check the loader never does anything worse than + raising a clean RuntimeError. + + Loading a corrupted file has exactly two acceptable outcomes: success + (the corruption hit payload data and produced a different but structurally + valid grid) or RuntimeError (the corruption was detected). A segfault, a + MemoryError (a corrupted count being trusted before any bounds check) or + any other exception type is a bug in the reader. This test is most potent + in the ASan+UBSan CI job (.github/workflows/sanitizers.yml), where any + out-of-bounds read also becomes a hard failure even if it would not have + crashed a regular build. + """ + + @classmethod + def setUpClass(cls): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + cls.grid_cls = type(env.backend._grid) + cls.tmpdir = tempfile.TemporaryDirectory() + path = os.path.join(cls.tmpdir.name, "sweep_reference.lsb") + env.backend._grid.save_binary(path) + with open(path, "rb") as f: + cls.data = f.read() + env.close() + + @classmethod + def tearDownClass(cls): + cls.tmpdir.cleanup() + + def _check_load(self, corrupted, description): + bad_path = os.path.join(self.tmpdir.name, "sweep_corrupted.lsb") + with open(bad_path, "wb") as f: + f.write(corrupted) + try: + self.grid_cls.load_binary(bad_path) + except RuntimeError: + pass # corruption detected and rejected cleanly: fine + except BaseException as exc: + self.fail(f"{description}: load_binary raised {type(exc).__name__} " + f"instead of RuntimeError: {exc}") + + def test_single_byte_flips(self): + """Invert one byte at every offset of the file.""" + for offset in range(len(self.data)): + corrupted = bytearray(self.data) + corrupted[offset] ^= 0xFF + self._check_load(bytes(corrupted), f"byte flip at offset {offset}") + + def test_uint64_stamps(self): + """Stamp 8 bytes of 0xFF at every offset: turns any count/length + field it lands on into a huge value, the worst case for the + bounds-checked allocations.""" + for offset in range(len(self.data) - 7): + corrupted = bytearray(self.data) + corrupted[offset:offset + 8] = b"\xff" * 8 + self._check_load(bytes(corrupted), f"0xFF uint64 stamp at offset {offset}") + + def test_truncations(self): + """Cut the file at every length: must always raise RuntimeError + (a strictly shorter file can never be a complete state).""" + for length in range(len(self.data)): + bad_path = os.path.join(self.tmpdir.name, "sweep_truncated.lsb") + with open(bad_path, "wb") as f: + f.write(self.data[:length]) + with self.assertRaises(RuntimeError, + msg=f"no error for a file truncated at {length} bytes"): + self.grid_cls.load_binary(bad_path) + + class TestSerializedEnumValues(unittest.TestCase): """The integer values of these C++ enums are written verbatim into the binary files (and into pickles), so they are part of the serialized diff --git a/src/bindings/python/CMakeLists.txt b/src/bindings/python/CMakeLists.txt index 7518a81c..ed99a809 100644 --- a/src/bindings/python/CMakeLists.txt +++ b/src/bindings/python/CMakeLists.txt @@ -55,6 +55,23 @@ target_include_directories(lightsim2grid_cpp PRIVATE target_link_libraries(lightsim2grid_cpp PRIVATE lightsim2grid_core) +# CI hardening builds (see .github/workflows/sanitizers.yml). The env vars are +# read again here (rather than relying on the USE_SANITIZERS/USE_DEBUG_ASSERTS +# options defined in src/core/CMakeLists.txt) so the flags also apply when +# lightsim2grid_core is imported pre-built and src/core is never processed. +if(NOT MSVC) + set(ENV_SANITIZE "$ENV{__SANITIZE}") + if(ENV_SANITIZE STREQUAL "1" OR ENV_SANITIZE STREQUAL "True") + target_compile_options(lightsim2grid_cpp PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1) + target_link_options(lightsim2grid_cpp PRIVATE -fsanitize=address,undefined) + endif() + set(ENV_DEBUG_ASSERTS "$ENV{__DEBUG_ASSERTS}") + if(ENV_DEBUG_ASSERTS STREQUAL "1" OR ENV_DEBUG_ASSERTS STREQUAL "True") + target_compile_options(lightsim2grid_cpp PRIVATE -UNDEBUG -D_GLIBCXX_ASSERTIONS) + endif() +endif() + if(APPLE) set(_LS2G_RPATH "@loader_path") elseif(NOT WIN32) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 708eeb74..38302e0c 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -254,6 +254,24 @@ else() option(USE_O3_OPTIM "Enable O3 optimization" ON) endif() +# CI hardening builds (see .github/workflows/sanitizers.yml), not meant for +# distributed wheels. __SANITIZE=1 compiles with ASan+UBSan; __DEBUG_ASSERTS=1 +# re-enables the assertions Release builds silence with NDEBUG (Eigen bounds +# checks) plus the ABI-safe libstdc++ hardening checks. +set(ENV_SANITIZE "$ENV{__SANITIZE}") +if(ENV_SANITIZE STREQUAL "1" OR ENV_SANITIZE STREQUAL "True") + option(USE_SANITIZERS "Compile with AddressSanitizer + UBSan" ON) +else() + option(USE_SANITIZERS "Compile with AddressSanitizer + UBSan" OFF) +endif() + +set(ENV_DEBUG_ASSERTS "$ENV{__DEBUG_ASSERTS}") +if(ENV_DEBUG_ASSERTS STREQUAL "1" OR ENV_DEBUG_ASSERTS STREQUAL "True") + option(USE_DEBUG_ASSERTS "Re-enable NDEBUG-silenced assertions (Eigen bounds checks, _GLIBCXX_ASSERTIONS)" ON) +else() + option(USE_DEBUG_ASSERTS "Re-enable NDEBUG-silenced assertions (Eigen bounds checks, _GLIBCXX_ASSERTIONS)" OFF) +endif() + # ============================================================================= # Core library sources @@ -362,6 +380,18 @@ if(NOT MSVC) if(USE_MARCH_NATIVE) target_compile_options(lightsim2grid_core PRIVATE -march=native) endif() + if(USE_SANITIZERS) + # placed after the -O3 block on purpose: the later -O1 wins, keeping + # ASan stack traces / line info usable + target_compile_options(lightsim2grid_core PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1) + target_link_options(lightsim2grid_core PRIVATE -fsanitize=address,undefined) + endif() + if(USE_DEBUG_ASSERTS) + # -UNDEBUG comes after CMAKE_CXX_FLAGS_RELEASE's -DNDEBUG on the + # command line, so Eigen's bounds assertions come back to life + target_compile_options(lightsim2grid_core PRIVATE -UNDEBUG -D_GLIBCXX_ASSERTIONS) + endif() else() if(USE_O3_OPTIM) target_compile_options(lightsim2grid_core PRIVATE /O2) From dc25c4a98cf804874c74f38cc64fd778375737c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:23:36 +0000 Subject: [PATCH 070/166] Add memory-hardening CI: ASan+UBSan and debug-asserts jobs New GitHub Actions workflow (sanitizers.yml) complementing the functional test suites that run on CircleCI: - asan_ubsan job: builds the C++ core and bindings with -fsanitize=address,undefined (new __SANITIZE=1 env-driven CMake option) and runs the binary serialization, solver control, time series and contingency analysis test modules under AddressSanitizer + UndefinedBehaviorSanitizer - debug_asserts job: builds with -UNDEBUG -D_GLIBCXX_ASSERTIONS (new __DEBUG_ASSERTS=1 option), re-enabling the Eigen bounds assertions silenced by NDEBUG in Release builds plus the ABI-safe libstdc++ container checks, and runs the solver-heavy modules Also adds TestCorruptionSweep to test_binary_serialization.py: a deterministic mini-fuzzer that corrupts a valid binary file at every byte offset (single-byte flip, 8-byte 0xFF stamp, truncation) and checks load_binary never does anything worse than raising a clean RuntimeError. Runs in the regular suites too, but is most potent under the ASan job where any out-of-bounds read becomes a failure. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .github/workflows/sanitizers.yml | 99 +++++++++++++++++++ CHANGELOG.rst | 10 ++ .../tests/test_binary_serialization.py | 72 ++++++++++++++ src/bindings/python/CMakeLists.txt | 17 ++++ src/core/CMakeLists.txt | 30 ++++++ 5 files changed, 228 insertions(+) create mode 100644 .github/workflows/sanitizers.yml diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 00000000..21061ead --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,99 @@ +# Memory-hardening CI: builds the C++ core + bindings with instrumentation +# that the release wheels do not carry, and runs the test modules most likely +# to surface memory issues (out-of-bounds, use-after-free, UB, silenced +# assertions). Complements the functional suites on CircleCI, which run +# against plain optimized builds. + +name: Sanitizers + +on: + push: + branches: + - '*' + tags: + - 'v*.*.*' + +jobs: + asan_ubsan: + # AddressSanitizer + UndefinedBehaviorSanitizer over the untrusted-input + # path (binary serialization) and the heaviest C++ compute paths (solver + # control, time series, contingency analysis). + name: ASan + UBSan tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements_compile_ci.txt + python -m pip install grid2op scipy pandapower + + - name: Build lightsim2grid with ASan + UBSan + # __SANITIZE=1 is picked up by src/core/CMakeLists.txt and + # src/bindings/python/CMakeLists.txt: -fsanitize=address,undefined + # on both lightsim2grid_core and lightsim2grid_cpp + run: __SANITIZE=1 python -m pip install -v . --no-build-isolation + + - name: Run tests under ASan + UBSan + # The sanitized code lives in a python extension, so the ASan runtime + # must be the first thing loaded into the (uninstrumented) python + # process: hence LD_PRELOAD. Leak detection stays off: CPython's + # allocator keeps interned objects alive by design and would drown + # real reports in noise. + env: + ASAN_OPTIONS: detect_leaks=0 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: | + export LD_PRELOAD=$(gcc -print-file-name=libasan.so) + cd lightsim2grid/tests + python -W ignore -m unittest -v \ + test_binary_serialization \ + test_solver_control \ + test_TimeSerie \ + test_time_series_dc \ + test_SecurityAnlysis \ + test_DCSecurityAnlysis \ + test_SecurityAnlysis_cpp \ + test_ContingencyAnalysis_limit_violations \ + test_ContingencyAnalysis_split + + debug_asserts: + # Release wheels are compiled with NDEBUG, which silences every Eigen + # bounds assertion: logically-wrong-but-in-bounds indexing is invisible + # to ASan. This build re-enables them (plus the ABI-safe libstdc++ + # container checks) and runs the solver-heavy modules. + name: Eigen/libstdc++ assertions tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements_compile_ci.txt + python -m pip install grid2op scipy pandapower + + - name: Build lightsim2grid with assertions re-enabled + # __DEBUG_ASSERTS=1 -> -UNDEBUG -D_GLIBCXX_ASSERTIONS on both targets + run: __DEBUG_ASSERTS=1 python -m pip install -v . --no-build-isolation + + - name: Run solver tests with assertions active + run: | + cd lightsim2grid/tests + python -W ignore -m unittest -v \ + test_ACPF \ + test_DCPF \ + test_solver_control diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f202f5f2..b8b42f14 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -393,6 +393,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. `ConverterStationInfo.ConverterType` -- the last three are now exposed to python) are pinned by a test (`TestSerializedEnumValues`) that fails with a "bump BINARY_FORMAT_VERSION" message if they are renumbered. +- [ADDED] memory-hardening CI (`.github/workflows/sanitizers.yml`): an + **ASan + UBSan** job (build with `__SANITIZE=1`, runs the binary + serialization, solver control, time series and contingency analysis test + modules under AddressSanitizer + UndefinedBehaviorSanitizer) and a + **debug-assertions** job (build with `__DEBUG_ASSERTS=1`: + `-UNDEBUG -D_GLIBCXX_ASSERTIONS`, re-enabling the Eigen bounds assertions + that Release builds silence, runs the solver-heavy modules). Also a new + corruption-sweep test (`TestCorruptionSweep`) that corrupts a valid binary + file at every byte offset and checks `load_binary` never does anything + worse than raising a clean `RuntimeError`. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index f98a133b..4cf2cbde 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -413,6 +413,78 @@ def test_corrupted_file(self): type(grid).load_binary(garbage_path) +class TestCorruptionSweep(unittest.TestCase): + """Deterministic mini-fuzzer for load_binary: corrupt a valid file at + EVERY byte offset and check the loader never does anything worse than + raising a clean RuntimeError. + + Loading a corrupted file has exactly two acceptable outcomes: success + (the corruption hit payload data and produced a different but structurally + valid grid) or RuntimeError (the corruption was detected). A segfault, a + MemoryError (a corrupted count being trusted before any bounds check) or + any other exception type is a bug in the reader. This test is most potent + in the ASan+UBSan CI job (.github/workflows/sanitizers.yml), where any + out-of-bounds read also becomes a hard failure even if it would not have + crashed a regular build. + """ + + @classmethod + def setUpClass(cls): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", test=True, backend=LightSimBackend()) + cls.grid_cls = type(env.backend._grid) + cls.tmpdir = tempfile.TemporaryDirectory() + path = os.path.join(cls.tmpdir.name, "sweep_reference.lsb") + env.backend._grid.save_binary(path) + with open(path, "rb") as f: + cls.data = f.read() + env.close() + + @classmethod + def tearDownClass(cls): + cls.tmpdir.cleanup() + + def _check_load(self, corrupted, description): + bad_path = os.path.join(self.tmpdir.name, "sweep_corrupted.lsb") + with open(bad_path, "wb") as f: + f.write(corrupted) + try: + self.grid_cls.load_binary(bad_path) + except RuntimeError: + pass # corruption detected and rejected cleanly: fine + except BaseException as exc: + self.fail(f"{description}: load_binary raised {type(exc).__name__} " + f"instead of RuntimeError: {exc}") + + def test_single_byte_flips(self): + """Invert one byte at every offset of the file.""" + for offset in range(len(self.data)): + corrupted = bytearray(self.data) + corrupted[offset] ^= 0xFF + self._check_load(bytes(corrupted), f"byte flip at offset {offset}") + + def test_uint64_stamps(self): + """Stamp 8 bytes of 0xFF at every offset: turns any count/length + field it lands on into a huge value, the worst case for the + bounds-checked allocations.""" + for offset in range(len(self.data) - 7): + corrupted = bytearray(self.data) + corrupted[offset:offset + 8] = b"\xff" * 8 + self._check_load(bytes(corrupted), f"0xFF uint64 stamp at offset {offset}") + + def test_truncations(self): + """Cut the file at every length: must always raise RuntimeError + (a strictly shorter file can never be a complete state).""" + for length in range(len(self.data)): + bad_path = os.path.join(self.tmpdir.name, "sweep_truncated.lsb") + with open(bad_path, "wb") as f: + f.write(self.data[:length]) + with self.assertRaises(RuntimeError, + msg=f"no error for a file truncated at {length} bytes"): + self.grid_cls.load_binary(bad_path) + + class TestSerializedEnumValues(unittest.TestCase): """The integer values of these C++ enums are written verbatim into the binary files (and into pickles), so they are part of the serialized diff --git a/src/bindings/python/CMakeLists.txt b/src/bindings/python/CMakeLists.txt index 7518a81c..ed99a809 100644 --- a/src/bindings/python/CMakeLists.txt +++ b/src/bindings/python/CMakeLists.txt @@ -55,6 +55,23 @@ target_include_directories(lightsim2grid_cpp PRIVATE target_link_libraries(lightsim2grid_cpp PRIVATE lightsim2grid_core) +# CI hardening builds (see .github/workflows/sanitizers.yml). The env vars are +# read again here (rather than relying on the USE_SANITIZERS/USE_DEBUG_ASSERTS +# options defined in src/core/CMakeLists.txt) so the flags also apply when +# lightsim2grid_core is imported pre-built and src/core is never processed. +if(NOT MSVC) + set(ENV_SANITIZE "$ENV{__SANITIZE}") + if(ENV_SANITIZE STREQUAL "1" OR ENV_SANITIZE STREQUAL "True") + target_compile_options(lightsim2grid_cpp PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1) + target_link_options(lightsim2grid_cpp PRIVATE -fsanitize=address,undefined) + endif() + set(ENV_DEBUG_ASSERTS "$ENV{__DEBUG_ASSERTS}") + if(ENV_DEBUG_ASSERTS STREQUAL "1" OR ENV_DEBUG_ASSERTS STREQUAL "True") + target_compile_options(lightsim2grid_cpp PRIVATE -UNDEBUG -D_GLIBCXX_ASSERTIONS) + endif() +endif() + if(APPLE) set(_LS2G_RPATH "@loader_path") elseif(NOT WIN32) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 708eeb74..38302e0c 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -254,6 +254,24 @@ else() option(USE_O3_OPTIM "Enable O3 optimization" ON) endif() +# CI hardening builds (see .github/workflows/sanitizers.yml), not meant for +# distributed wheels. __SANITIZE=1 compiles with ASan+UBSan; __DEBUG_ASSERTS=1 +# re-enables the assertions Release builds silence with NDEBUG (Eigen bounds +# checks) plus the ABI-safe libstdc++ hardening checks. +set(ENV_SANITIZE "$ENV{__SANITIZE}") +if(ENV_SANITIZE STREQUAL "1" OR ENV_SANITIZE STREQUAL "True") + option(USE_SANITIZERS "Compile with AddressSanitizer + UBSan" ON) +else() + option(USE_SANITIZERS "Compile with AddressSanitizer + UBSan" OFF) +endif() + +set(ENV_DEBUG_ASSERTS "$ENV{__DEBUG_ASSERTS}") +if(ENV_DEBUG_ASSERTS STREQUAL "1" OR ENV_DEBUG_ASSERTS STREQUAL "True") + option(USE_DEBUG_ASSERTS "Re-enable NDEBUG-silenced assertions (Eigen bounds checks, _GLIBCXX_ASSERTIONS)" ON) +else() + option(USE_DEBUG_ASSERTS "Re-enable NDEBUG-silenced assertions (Eigen bounds checks, _GLIBCXX_ASSERTIONS)" OFF) +endif() + # ============================================================================= # Core library sources @@ -362,6 +380,18 @@ if(NOT MSVC) if(USE_MARCH_NATIVE) target_compile_options(lightsim2grid_core PRIVATE -march=native) endif() + if(USE_SANITIZERS) + # placed after the -O3 block on purpose: the later -O1 wins, keeping + # ASan stack traces / line info usable + target_compile_options(lightsim2grid_core PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1) + target_link_options(lightsim2grid_core PRIVATE -fsanitize=address,undefined) + endif() + if(USE_DEBUG_ASSERTS) + # -UNDEBUG comes after CMAKE_CXX_FLAGS_RELEASE's -DNDEBUG on the + # command line, so Eigen's bounds assertions come back to life + target_compile_options(lightsim2grid_core PRIVATE -UNDEBUG -D_GLIBCXX_ASSERTIONS) + endif() else() if(USE_O3_OPTIM) target_compile_options(lightsim2grid_core PRIVATE /O2) From e0a49d261bfcf8ad551040207ab8109458b6ca60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:29:01 +0000 Subject: [PATCH 071/166] Fix UB found by UBSan: Eigen::Map on &vec[0] of empty vectors First catch of the new sanitizer CI: UndefinedBehaviorSanitizer flags 'reference binding to null pointer' in SubstationContainer::set_state (via std::vector::operator[]). The Eigen::Map(&vec[0], vec.size()) pattern used across the containers is undefined behavior whenever the vector is empty (grids without SVCs, storages, trafos, ...): vec[0] forms a reference to element 0 that does not exist. Replace all 50 occurrences with Map(vec.data(), vec.size()): identical for non-empty vectors, and .data() on an empty vector returns a (possibly null) pointer without forming a reference, which Eigen never dereferences for a size-0 map. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/SubstationContainer.cpp | 8 +++---- .../ConverterStationContainer.cpp | 12 +++++----- .../element_container/GeneratorContainer.cpp | 10 ++++----- .../element_container/HvdcLineContainer.cpp | 22 +++++++++---------- .../element_container/OneSideContainer.hpp | 4 ++-- .../element_container/OneSideContainer_PQ.hpp | 4 ++-- src/core/element_container/SGenContainer.cpp | 8 +++---- src/core/element_container/SvcContainer.cpp | 12 +++++----- src/core/element_container/TrafoContainer.cpp | 8 +++---- .../TwoSidesContainer_rxh_A.hpp | 12 +++++----- 10 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 9aa598db..611e6ca6 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -47,15 +47,15 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) // TODO dev switches // assign data - sub_vn_kv_ = RealVect::Map(&sub_vn_kv[0], sub_vn_kv.size()); + sub_vn_kv_ = RealVect::Map(sub_vn_kv.data(), sub_vn_kv.size()); bus_status_ = bus_status; - bus_vn_kv_ = RealVect::Map(&bus_vn_kv[0], bus_vn_kv.size()); + bus_vn_kv_ = RealVect::Map(bus_vn_kv.data(), bus_vn_kv.size()); sub_names_ = std::get<5>(my_state); std::vector & bus_vmin_kv = std::get<6>(my_state); std::vector & bus_vmax_kv = std::get<7>(my_state); - bus_vmin_kv_ = bus_vmin_kv.empty() ? RealVect() : RealVect::Map(&bus_vmin_kv[0], bus_vmin_kv.size()); - bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(&bus_vmax_kv[0], bus_vmax_kv.size()); + bus_vmin_kv_ = bus_vmin_kv.empty() ? RealVect() : RealVect::Map(bus_vmin_kv.data(), bus_vmin_kv.size()); + bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(bus_vmax_kv.data(), bus_vmax_kv.size()); } void SubstationContainer::save_binary(const std::string & path, bool atomic) const { diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index 7d881193..663365f3 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -110,13 +110,13 @@ void ConverterStationContainer::set_state(ConverterStationContainer::StateRes & check_size(max_q, size, "max_q"); check_size(power_factor, size, "power_factor"); - type_ = IntVect::Map(&type[0], type.size()); - loss_factor_ = RealVect::Map(&loss_factor[0], loss_factor.size()); + type_ = IntVect::Map(type.data(), type.size()); + loss_factor_ = RealVect::Map(loss_factor.data(), loss_factor.size()); voltage_regulator_on_ = voltage_regulator_on; - target_vm_pu_ = RealVect::Map(&vm_pu[0], vm_pu.size()); - min_q_ = RealVect::Map(&min_q[0], min_q.size()); - max_q_ = RealVect::Map(&max_q[0], max_q.size()); - power_factor_ = RealVect::Map(&power_factor[0], power_factor.size()); + target_vm_pu_ = RealVect::Map(vm_pu.data(), vm_pu.size()); + min_q_ = RealVect::Map(min_q.data(), min_q.size()); + max_q_ = RealVect::Map(max_q.data(), max_q.size()); + power_factor_ = RealVect::Map(power_factor.data(), power_factor.size()); reset_results(); } diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index b0772ee6..6d07cd18 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -117,12 +117,12 @@ void GeneratorContainer::set_state(GeneratorContainer::StateRes & my_state) // assign data voltage_regulator_on_ = voltage_regulator_on; - target_vm_pu_ = RealVect::Map(&vm_pu[0], vm_pu.size()); - min_q_ = RealVect::Map(&min_q[0], min_q.size()); - max_q_ = RealVect::Map(&max_q[0], max_q.size()); + target_vm_pu_ = RealVect::Map(vm_pu.data(), vm_pu.size()); + min_q_ = RealVect::Map(min_q.data(), min_q.size()); + max_q_ = RealVect::Map(max_q.data(), max_q.size()); gen_slackbus_ = slack_bus; gen_slack_weight_ = slack_weight; - regulated_bus_id_ = Eigen::VectorXi::Map(®ulated_bus[0], regulated_bus.size()); + regulated_bus_id_ = Eigen::VectorXi::Map(regulated_bus.data(), regulated_bus.size()); reset_results(); } @@ -548,7 +548,7 @@ void GeneratorContainer::update_slack_weights( { if(could_be_slack(gen_id)) gen_slack_id.push_back(gen_id); } - Eigen::Ref gen_slack_id_ref = IntVect::Map(&gen_slack_id[0], gen_slack_id.size()); + Eigen::Ref gen_slack_id_ref = IntVect::Map(gen_slack_id.data(), gen_slack_id.size()); update_slack_weights_by_id( gen_slack_id_ref, solver_control); diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index f78809bc..bd9b0bd5 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -216,18 +216,18 @@ void HvdcLineContainer::set_state(HvdcLineContainer::StateRes & my_state) check_size(pmax_2to1_mw, size, "pmax_2to1_mw"); check_size(status_droop, size, "status_droop"); - loss_percent_ = RealVect::Map(&loss_percent[0], loss_percent.size()); - loss_mw_ = RealVect::Map(&loss_mw[0], loss_mw.size()); - converters_mode_ = IntVect::Map(&converters_mode[0], converters_mode.size()); - p_setpoint_mw_ = RealVect::Map(&p_setpoint_mw[0], p_setpoint_mw.size()); - r_ohm_ = RealVect::Map(&r_ohm[0], r_ohm.size()); - nominal_v_kv_ = RealVect::Map(&nominal_v_kv[0], nominal_v_kv.size()); + loss_percent_ = RealVect::Map(loss_percent.data(), loss_percent.size()); + loss_mw_ = RealVect::Map(loss_mw.data(), loss_mw.size()); + converters_mode_ = IntVect::Map(converters_mode.data(), converters_mode.size()); + p_setpoint_mw_ = RealVect::Map(p_setpoint_mw.data(), p_setpoint_mw.size()); + r_ohm_ = RealVect::Map(r_ohm.data(), r_ohm.size()); + nominal_v_kv_ = RealVect::Map(nominal_v_kv.data(), nominal_v_kv.size()); droop_enabled_ = droop_enabled; - p0_mw_ = RealVect::Map(&p0_mw[0], p0_mw.size()); - k_mw_per_rad_ = RealVect::Map(&k_mw_per_rad[0], k_mw_per_rad.size()); - pmax_1to2_mw_ = RealVect::Map(&pmax_1to2_mw[0], pmax_1to2_mw.size()); - pmax_2to1_mw_ = RealVect::Map(&pmax_2to1_mw[0], pmax_2to1_mw.size()); - status_droop_ = IntVect::Map(&status_droop[0], status_droop.size()); + p0_mw_ = RealVect::Map(p0_mw.data(), p0_mw.size()); + k_mw_per_rad_ = RealVect::Map(k_mw_per_rad.data(), k_mw_per_rad.size()); + pmax_1to2_mw_ = RealVect::Map(pmax_1to2_mw.data(), pmax_1to2_mw.size()); + pmax_2to1_mw_ = RealVect::Map(pmax_2to1_mw.data(), pmax_2to1_mw.size()); + status_droop_ = IntVect::Map(status_droop.data(), status_droop.size()); reset_results(); } diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 2f7af9d1..97637a94 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -381,13 +381,13 @@ class OneSideContainer : public GenericContainer { const std::vector & subid = std::get<4>(my_state); check_size(subid, size, "subid"); - subid_ = IntVect::Map(&subid[0], subid.size()); + subid_ = IntVect::Map(subid.data(), subid.size()); } if(has_topo_vect_info) { const std::vector & topo_vect = std::get<6>(my_state); check_size(topo_vect, size, "topo_vect"); - pos_topo_vect_ = IntVect::Map(&topo_vect[0], topo_vect.size()); + pos_topo_vect_ = IntVect::Map(topo_vect.data(), topo_vect.size()); } // input data diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 096417fb..be966155 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -150,8 +150,8 @@ class OneSideContainer_PQ : public OneSideContainer check_size(q_mvar, size, "q_mvar"); // input data - target_p_mw_ = RealVect::Map(&p_mw[0], p_mw.size()); - target_q_mvar_ = RealVect::Map(&q_mvar[0], q_mvar.size()); + target_p_mw_ = RealVect::Map(p_mw.data(), p_mw.size()); + target_q_mvar_ = RealVect::Map(q_mvar.data(), q_mvar.size()); // initialize properly the right "results" vectors (ie res_XXX RealVect) this->reset_results(); diff --git a/src/core/element_container/SGenContainer.cpp b/src/core/element_container/SGenContainer.cpp index 1f822d24..496b5f4d 100644 --- a/src/core/element_container/SGenContainer.cpp +++ b/src/core/element_container/SGenContainer.cpp @@ -61,10 +61,10 @@ void SGenContainer::set_state(SGenContainer::StateRes & my_state ) GenericContainer::check_size(q_min, size, "q_min"); GenericContainer::check_size(q_max, size, "q_max"); - p_min_mw_ = RealVect::Map(&p_min[0], size); - p_max_mw_ = RealVect::Map(&p_max[0], size); - q_min_mvar_ = RealVect::Map(&q_min[0], size); - q_max_mvar_ = RealVect::Map(&q_max[0], size); + p_min_mw_ = RealVect::Map(p_min.data(), size); + p_max_mw_ = RealVect::Map(p_max.data(), size); + q_min_mvar_ = RealVect::Map(q_min.data(), size); + q_max_mvar_ = RealVect::Map(q_max.data(), size); reset_results(); } diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index ed853672..b74ee468 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -76,12 +76,12 @@ void SvcContainer::set_state(SvcContainer::StateRes & my_state) check_size(bmax, size, "b_max"); check_size(regulated_bus, size, "regulated_bus"); - regulation_mode_ = IntVect::Map(&mode[0], mode.size()); - target_vm_pu_ = RealVect::Map(&vm_pu[0], vm_pu.size()); - slope_pu_ = RealVect::Map(&slope[0], slope.size()); - b_min_ = RealVect::Map(&bmin[0], bmin.size()); - b_max_ = RealVect::Map(&bmax[0], bmax.size()); - regulated_bus_id_ = Eigen::VectorXi::Map(®ulated_bus[0], regulated_bus.size()); + regulation_mode_ = IntVect::Map(mode.data(), mode.size()); + target_vm_pu_ = RealVect::Map(vm_pu.data(), vm_pu.size()); + slope_pu_ = RealVect::Map(slope.data(), slope.size()); + b_min_ = RealVect::Map(bmin.data(), bmin.size()); + b_max_ = RealVect::Map(bmax.data(), bmax.size()); + regulated_bus_id_ = Eigen::VectorXi::Map(regulated_bus.data(), regulated_bus.size()); reset_results(); } diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 47315f55..93889cb7 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -116,8 +116,8 @@ void TrafoContainer::set_state(TrafoContainer::StateRes & my_state) GenericContainer::check_size(is_tap_side1, size, "is_tap_side1"); GenericContainer::check_size(shift, size, "shift"); - ratio_ = RealVect::Map(&ratio[0], size); - shift_ = RealVect::Map(&shift[0], size); + ratio_ = RealVect::Map(ratio.data(), size); + shift_ = RealVect::Map(shift.data(), size); is_tap_side1_ = is_tap_side1; ignore_tap_side_for_shift_ = std::get<4>(my_state); @@ -126,8 +126,8 @@ void TrafoContainer::set_state(TrafoContainer::StateRes & my_state) std::vector & base_x = std::get<7>(my_state); GenericContainer::check_size(base_r, size, "base_r"); GenericContainer::check_size(base_x, size, "base_x"); - base_r_ = RealVect::Map(&base_r[0], size); - base_x_ = RealVect::Map(&base_x[0], size); + base_r_ = RealVect::Map(base_r.data(), size); + base_x_ = RealVect::Map(base_x.data(), size); rx_corr_alpha_ = std::get<8>(my_state); rx_corr_pct_ = std::get<9>(my_state); diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 975fdb31..855a1315 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -848,22 +848,22 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer check_size(branch_h1, size, "branch h (=g+j.b), side 1"); check_size(branch_h2, size, "branch h (=g+j.b), side 2"); - r_ = RealVect::Map(&branch_r[0], size); - x_ = RealVect::Map(&branch_x[0], size); - h_side_1_ = CplxVect::Map(&branch_h1[0], size); - h_side_2_ = CplxVect::Map(&branch_h2[0], size); + r_ = RealVect::Map(branch_r.data(), size); + x_ = RealVect::Map(branch_x.data(), size); + h_side_1_ = CplxVect::Map(branch_h1.data(), size); + h_side_2_ = CplxVect::Map(branch_h2.data(), size); const std::vector & limit_a1_ka = std::get<5>(my_state); const std::vector & limit_a2_ka = std::get<6>(my_state); if(limit_a1_ka.size() > 0){ check_size(limit_a1_ka, size, "limit_a1_ka"); - limit_a1_ka_ = RealVect::Map(&limit_a1_ka[0], size); + limit_a1_ka_ = RealVect::Map(limit_a1_ka.data(), size); } else { limit_a1_ka_ = RealVect(); } if(limit_a2_ka.size() > 0){ check_size(limit_a2_ka, size, "limit_a2_ka"); - limit_a2_ka_ = RealVect::Map(&limit_a2_ka[0], size); + limit_a2_ka_ = RealVect::Map(limit_a2_ka.data(), size); } else { limit_a2_ka_ = RealVect(); } From bf6a4c64d72f53dc4e1a26ac6901861a241e7a16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:29:01 +0000 Subject: [PATCH 072/166] Fix UB found by UBSan: Eigen::Map on &vec[0] of empty vectors First catch of the new sanitizer CI: UndefinedBehaviorSanitizer flags 'reference binding to null pointer' in SubstationContainer::set_state (via std::vector::operator[]). The Eigen::Map(&vec[0], vec.size()) pattern used across the containers is undefined behavior whenever the vector is empty (grids without SVCs, storages, trafos, ...): vec[0] forms a reference to element 0 that does not exist. Replace all 50 occurrences with Map(vec.data(), vec.size()): identical for non-empty vectors, and .data() on an empty vector returns a (possibly null) pointer without forming a reference, which Eigen never dereferences for a size-0 map. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/SubstationContainer.cpp | 8 +++---- .../ConverterStationContainer.cpp | 12 +++++----- .../element_container/GeneratorContainer.cpp | 10 ++++----- .../element_container/HvdcLineContainer.cpp | 22 +++++++++---------- .../element_container/OneSideContainer.hpp | 4 ++-- .../element_container/OneSideContainer_PQ.hpp | 4 ++-- src/core/element_container/SGenContainer.cpp | 8 +++---- src/core/element_container/SvcContainer.cpp | 12 +++++----- src/core/element_container/TrafoContainer.cpp | 8 +++---- .../TwoSidesContainer_rxh_A.hpp | 12 +++++----- 10 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 9aa598db..611e6ca6 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -47,15 +47,15 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) // TODO dev switches // assign data - sub_vn_kv_ = RealVect::Map(&sub_vn_kv[0], sub_vn_kv.size()); + sub_vn_kv_ = RealVect::Map(sub_vn_kv.data(), sub_vn_kv.size()); bus_status_ = bus_status; - bus_vn_kv_ = RealVect::Map(&bus_vn_kv[0], bus_vn_kv.size()); + bus_vn_kv_ = RealVect::Map(bus_vn_kv.data(), bus_vn_kv.size()); sub_names_ = std::get<5>(my_state); std::vector & bus_vmin_kv = std::get<6>(my_state); std::vector & bus_vmax_kv = std::get<7>(my_state); - bus_vmin_kv_ = bus_vmin_kv.empty() ? RealVect() : RealVect::Map(&bus_vmin_kv[0], bus_vmin_kv.size()); - bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(&bus_vmax_kv[0], bus_vmax_kv.size()); + bus_vmin_kv_ = bus_vmin_kv.empty() ? RealVect() : RealVect::Map(bus_vmin_kv.data(), bus_vmin_kv.size()); + bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(bus_vmax_kv.data(), bus_vmax_kv.size()); } void SubstationContainer::save_binary(const std::string & path, bool atomic) const { diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index 7d881193..663365f3 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -110,13 +110,13 @@ void ConverterStationContainer::set_state(ConverterStationContainer::StateRes & check_size(max_q, size, "max_q"); check_size(power_factor, size, "power_factor"); - type_ = IntVect::Map(&type[0], type.size()); - loss_factor_ = RealVect::Map(&loss_factor[0], loss_factor.size()); + type_ = IntVect::Map(type.data(), type.size()); + loss_factor_ = RealVect::Map(loss_factor.data(), loss_factor.size()); voltage_regulator_on_ = voltage_regulator_on; - target_vm_pu_ = RealVect::Map(&vm_pu[0], vm_pu.size()); - min_q_ = RealVect::Map(&min_q[0], min_q.size()); - max_q_ = RealVect::Map(&max_q[0], max_q.size()); - power_factor_ = RealVect::Map(&power_factor[0], power_factor.size()); + target_vm_pu_ = RealVect::Map(vm_pu.data(), vm_pu.size()); + min_q_ = RealVect::Map(min_q.data(), min_q.size()); + max_q_ = RealVect::Map(max_q.data(), max_q.size()); + power_factor_ = RealVect::Map(power_factor.data(), power_factor.size()); reset_results(); } diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index b0772ee6..6d07cd18 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -117,12 +117,12 @@ void GeneratorContainer::set_state(GeneratorContainer::StateRes & my_state) // assign data voltage_regulator_on_ = voltage_regulator_on; - target_vm_pu_ = RealVect::Map(&vm_pu[0], vm_pu.size()); - min_q_ = RealVect::Map(&min_q[0], min_q.size()); - max_q_ = RealVect::Map(&max_q[0], max_q.size()); + target_vm_pu_ = RealVect::Map(vm_pu.data(), vm_pu.size()); + min_q_ = RealVect::Map(min_q.data(), min_q.size()); + max_q_ = RealVect::Map(max_q.data(), max_q.size()); gen_slackbus_ = slack_bus; gen_slack_weight_ = slack_weight; - regulated_bus_id_ = Eigen::VectorXi::Map(®ulated_bus[0], regulated_bus.size()); + regulated_bus_id_ = Eigen::VectorXi::Map(regulated_bus.data(), regulated_bus.size()); reset_results(); } @@ -548,7 +548,7 @@ void GeneratorContainer::update_slack_weights( { if(could_be_slack(gen_id)) gen_slack_id.push_back(gen_id); } - Eigen::Ref gen_slack_id_ref = IntVect::Map(&gen_slack_id[0], gen_slack_id.size()); + Eigen::Ref gen_slack_id_ref = IntVect::Map(gen_slack_id.data(), gen_slack_id.size()); update_slack_weights_by_id( gen_slack_id_ref, solver_control); diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index f78809bc..bd9b0bd5 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -216,18 +216,18 @@ void HvdcLineContainer::set_state(HvdcLineContainer::StateRes & my_state) check_size(pmax_2to1_mw, size, "pmax_2to1_mw"); check_size(status_droop, size, "status_droop"); - loss_percent_ = RealVect::Map(&loss_percent[0], loss_percent.size()); - loss_mw_ = RealVect::Map(&loss_mw[0], loss_mw.size()); - converters_mode_ = IntVect::Map(&converters_mode[0], converters_mode.size()); - p_setpoint_mw_ = RealVect::Map(&p_setpoint_mw[0], p_setpoint_mw.size()); - r_ohm_ = RealVect::Map(&r_ohm[0], r_ohm.size()); - nominal_v_kv_ = RealVect::Map(&nominal_v_kv[0], nominal_v_kv.size()); + loss_percent_ = RealVect::Map(loss_percent.data(), loss_percent.size()); + loss_mw_ = RealVect::Map(loss_mw.data(), loss_mw.size()); + converters_mode_ = IntVect::Map(converters_mode.data(), converters_mode.size()); + p_setpoint_mw_ = RealVect::Map(p_setpoint_mw.data(), p_setpoint_mw.size()); + r_ohm_ = RealVect::Map(r_ohm.data(), r_ohm.size()); + nominal_v_kv_ = RealVect::Map(nominal_v_kv.data(), nominal_v_kv.size()); droop_enabled_ = droop_enabled; - p0_mw_ = RealVect::Map(&p0_mw[0], p0_mw.size()); - k_mw_per_rad_ = RealVect::Map(&k_mw_per_rad[0], k_mw_per_rad.size()); - pmax_1to2_mw_ = RealVect::Map(&pmax_1to2_mw[0], pmax_1to2_mw.size()); - pmax_2to1_mw_ = RealVect::Map(&pmax_2to1_mw[0], pmax_2to1_mw.size()); - status_droop_ = IntVect::Map(&status_droop[0], status_droop.size()); + p0_mw_ = RealVect::Map(p0_mw.data(), p0_mw.size()); + k_mw_per_rad_ = RealVect::Map(k_mw_per_rad.data(), k_mw_per_rad.size()); + pmax_1to2_mw_ = RealVect::Map(pmax_1to2_mw.data(), pmax_1to2_mw.size()); + pmax_2to1_mw_ = RealVect::Map(pmax_2to1_mw.data(), pmax_2to1_mw.size()); + status_droop_ = IntVect::Map(status_droop.data(), status_droop.size()); reset_results(); } diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 2f7af9d1..97637a94 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -381,13 +381,13 @@ class OneSideContainer : public GenericContainer { const std::vector & subid = std::get<4>(my_state); check_size(subid, size, "subid"); - subid_ = IntVect::Map(&subid[0], subid.size()); + subid_ = IntVect::Map(subid.data(), subid.size()); } if(has_topo_vect_info) { const std::vector & topo_vect = std::get<6>(my_state); check_size(topo_vect, size, "topo_vect"); - pos_topo_vect_ = IntVect::Map(&topo_vect[0], topo_vect.size()); + pos_topo_vect_ = IntVect::Map(topo_vect.data(), topo_vect.size()); } // input data diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 096417fb..be966155 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -150,8 +150,8 @@ class OneSideContainer_PQ : public OneSideContainer check_size(q_mvar, size, "q_mvar"); // input data - target_p_mw_ = RealVect::Map(&p_mw[0], p_mw.size()); - target_q_mvar_ = RealVect::Map(&q_mvar[0], q_mvar.size()); + target_p_mw_ = RealVect::Map(p_mw.data(), p_mw.size()); + target_q_mvar_ = RealVect::Map(q_mvar.data(), q_mvar.size()); // initialize properly the right "results" vectors (ie res_XXX RealVect) this->reset_results(); diff --git a/src/core/element_container/SGenContainer.cpp b/src/core/element_container/SGenContainer.cpp index 1f822d24..496b5f4d 100644 --- a/src/core/element_container/SGenContainer.cpp +++ b/src/core/element_container/SGenContainer.cpp @@ -61,10 +61,10 @@ void SGenContainer::set_state(SGenContainer::StateRes & my_state ) GenericContainer::check_size(q_min, size, "q_min"); GenericContainer::check_size(q_max, size, "q_max"); - p_min_mw_ = RealVect::Map(&p_min[0], size); - p_max_mw_ = RealVect::Map(&p_max[0], size); - q_min_mvar_ = RealVect::Map(&q_min[0], size); - q_max_mvar_ = RealVect::Map(&q_max[0], size); + p_min_mw_ = RealVect::Map(p_min.data(), size); + p_max_mw_ = RealVect::Map(p_max.data(), size); + q_min_mvar_ = RealVect::Map(q_min.data(), size); + q_max_mvar_ = RealVect::Map(q_max.data(), size); reset_results(); } diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index ed853672..b74ee468 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -76,12 +76,12 @@ void SvcContainer::set_state(SvcContainer::StateRes & my_state) check_size(bmax, size, "b_max"); check_size(regulated_bus, size, "regulated_bus"); - regulation_mode_ = IntVect::Map(&mode[0], mode.size()); - target_vm_pu_ = RealVect::Map(&vm_pu[0], vm_pu.size()); - slope_pu_ = RealVect::Map(&slope[0], slope.size()); - b_min_ = RealVect::Map(&bmin[0], bmin.size()); - b_max_ = RealVect::Map(&bmax[0], bmax.size()); - regulated_bus_id_ = Eigen::VectorXi::Map(®ulated_bus[0], regulated_bus.size()); + regulation_mode_ = IntVect::Map(mode.data(), mode.size()); + target_vm_pu_ = RealVect::Map(vm_pu.data(), vm_pu.size()); + slope_pu_ = RealVect::Map(slope.data(), slope.size()); + b_min_ = RealVect::Map(bmin.data(), bmin.size()); + b_max_ = RealVect::Map(bmax.data(), bmax.size()); + regulated_bus_id_ = Eigen::VectorXi::Map(regulated_bus.data(), regulated_bus.size()); reset_results(); } diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 47315f55..93889cb7 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -116,8 +116,8 @@ void TrafoContainer::set_state(TrafoContainer::StateRes & my_state) GenericContainer::check_size(is_tap_side1, size, "is_tap_side1"); GenericContainer::check_size(shift, size, "shift"); - ratio_ = RealVect::Map(&ratio[0], size); - shift_ = RealVect::Map(&shift[0], size); + ratio_ = RealVect::Map(ratio.data(), size); + shift_ = RealVect::Map(shift.data(), size); is_tap_side1_ = is_tap_side1; ignore_tap_side_for_shift_ = std::get<4>(my_state); @@ -126,8 +126,8 @@ void TrafoContainer::set_state(TrafoContainer::StateRes & my_state) std::vector & base_x = std::get<7>(my_state); GenericContainer::check_size(base_r, size, "base_r"); GenericContainer::check_size(base_x, size, "base_x"); - base_r_ = RealVect::Map(&base_r[0], size); - base_x_ = RealVect::Map(&base_x[0], size); + base_r_ = RealVect::Map(base_r.data(), size); + base_x_ = RealVect::Map(base_x.data(), size); rx_corr_alpha_ = std::get<8>(my_state); rx_corr_pct_ = std::get<9>(my_state); diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 975fdb31..855a1315 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -848,22 +848,22 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer check_size(branch_h1, size, "branch h (=g+j.b), side 1"); check_size(branch_h2, size, "branch h (=g+j.b), side 2"); - r_ = RealVect::Map(&branch_r[0], size); - x_ = RealVect::Map(&branch_x[0], size); - h_side_1_ = CplxVect::Map(&branch_h1[0], size); - h_side_2_ = CplxVect::Map(&branch_h2[0], size); + r_ = RealVect::Map(branch_r.data(), size); + x_ = RealVect::Map(branch_x.data(), size); + h_side_1_ = CplxVect::Map(branch_h1.data(), size); + h_side_2_ = CplxVect::Map(branch_h2.data(), size); const std::vector & limit_a1_ka = std::get<5>(my_state); const std::vector & limit_a2_ka = std::get<6>(my_state); if(limit_a1_ka.size() > 0){ check_size(limit_a1_ka, size, "limit_a1_ka"); - limit_a1_ka_ = RealVect::Map(&limit_a1_ka[0], size); + limit_a1_ka_ = RealVect::Map(limit_a1_ka.data(), size); } else { limit_a1_ka_ = RealVect(); } if(limit_a2_ka.size() > 0){ check_size(limit_a2_ka, size, "limit_a2_ka"); - limit_a2_ka_ = RealVect::Map(&limit_a2_ka[0], size); + limit_a2_ka_ = RealVect::Map(limit_a2_ka.data(), size); } else { limit_a2_ka_ = RealVect(); } From 6b1757b377968d902ea21d10ce52818747ac9c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:40:16 +0000 Subject: [PATCH 073/166] Fix two reader bugs found by the corruption sweep under UBSan Second batch of findings from the new sanitizer CI, both hit by TestCorruptionSweep on the binary reader: - bool fields were deserialized as raw bytes: a corrupted byte produced a bool object holding eg 255, and merely loading such a value is undefined behavior (UBSan flagged 7 sites in the various set_state implementations). Bools are now normalized to 0/1 when read (still one byte on disk, format unchanged). - corrupted header strings (type tag, writer version) were embedded raw in exception messages: pybind11 converts what() to a python str as UTF-8, so garbage bytes turned the intended RuntimeError into a UnicodeDecodeError. Such strings are now escaped (and truncated) via a printable() helper before entering any message. Also preload libstdc++ together with libasan in the ASan CI job: python does not link libstdc++ itself, and without it ASan's __cxa_throw interceptor cannot resolve the real function, aborting the process on the first C++ exception. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .github/workflows/sanitizers.yml | 11 +++++++---- src/core/BinaryArchive.cpp | 29 ++++++++++++++++++++++++++--- src/core/BinaryArchive.hpp | 19 ++++++++++++++++++- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 21061ead..aa357481 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -44,14 +44,17 @@ jobs: - name: Run tests under ASan + UBSan # The sanitized code lives in a python extension, so the ASan runtime # must be the first thing loaded into the (uninstrumented) python - # process: hence LD_PRELOAD. Leak detection stays off: CPython's - # allocator keeps interned objects alive by design and would drown - # real reports in noise. + # process: hence LD_PRELOAD. libstdc++ must be preloaded along with + # it: python itself does not link it, and without it ASan's + # __cxa_throw interceptor cannot resolve the real function and every + # C++ exception aborts the process. Leak detection stays off: + # CPython's allocator keeps interned objects alive by design and + # would drown real reports in noise. env: ASAN_OPTIONS: detect_leaks=0 UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 run: | - export LD_PRELOAD=$(gcc -print-file-name=libasan.so) + export LD_PRELOAD="$(gcc -print-file-name=libasan.so) $(gcc -print-file-name=libstdc++.so.6)" cd lightsim2grid/tests python -W ignore -m unittest -v \ test_binary_serialization \ diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index 9e4c951c..c830643b 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -8,7 +8,7 @@ #include "BinaryArchive.hpp" -#include // std::remove, std::rename +#include // std::remove, std::rename, std::snprintf #include #include #include @@ -45,6 +45,29 @@ std::string supported_formats_str() return oss.str(); } +// Strings read from a (possibly corrupted) file must be escaped before being +// embedded in an exception message: pybind11 converts what() to a python str +// as UTF-8, and raw garbage bytes would turn the intended RuntimeError into a +// UnicodeDecodeError (found by the corruption-sweep test). Also truncated, +// so a corrupted length cannot produce a message megabytes long. +std::string printable(const std::string & s) +{ + const std::size_t max_len = 64; + std::ostringstream oss; + for (std::size_t i = 0; i < s.size() && i < max_len; ++i) { + const unsigned char c = static_cast(s[i]); + if (c >= 0x20 && c < 0x7f) { + oss << static_cast(c); + } else { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\x%02x", c); + oss << buf; + } + } + if (s.size() > max_len) oss << "... (" << s.size() << " chars)"; + return oss.str(); +} + } // anonymous namespace BinaryArchive::BinaryArchive(const std::string & path, Mode mode, bool atomic_write): @@ -206,7 +229,7 @@ void BinaryArchive::check_header(const std::string & type_tag, if (!is_format_supported(file_format)) { std::ostringstream oss; oss << "BinaryArchive: incompatible file '" << path_ << "': it was written by lightsim2grid version " - << file_major << "." << file_medium << "." << file_minor + << printable(file_major) << "." << printable(file_medium) << "." << printable(file_minor) << " using binary format " << file_format << ", but the currently installed lightsim2grid (version " << v_major << "." << v_medium << "." << v_minor @@ -219,7 +242,7 @@ void BinaryArchive::check_header(const std::string & type_tag, if (file_tag != type_tag) { std::ostringstream oss; oss << "BinaryArchive: wrong object type in file '" << path_ << "': it contains a '" - << file_tag << "', not a '" << type_tag << "'."; + << printable(file_tag) << "', not a '" << type_tag << "'."; throw std::runtime_error(oss.str()); } } diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 58c804a1..e6e9cf38 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -232,13 +232,30 @@ struct is_raw_vector_elem : std::integral_constant struct ValueArchiver; // intentionally incomplete: every real field type below has a specialization -// arithmetic scalars (real_type, int, bool, ...) +// arithmetic scalars (real_type, int, ...) template struct ValueArchiver::value>::type> { static void write(BinaryArchive & ar, const T & v) { ar.write_scalar(v); } static void read(BinaryArchive & ar, T & v) { ar.read_scalar(v); } }; +// bool: one byte on disk (same size as before), but normalized to 0/1 on +// read. Reading a corrupted byte straight into a bool object would create +// a bool holding eg 255, and merely loading such a value later is undefined +// behavior (found by UBSan under the corruption-sweep test). +template<> +struct ValueArchiver { + static void write(BinaryArchive & ar, const bool & v) { + const std::uint8_t b = v ? 1 : 0; + ar.write_scalar(b); + } + static void read(BinaryArchive & ar, bool & v) { + std::uint8_t b = 0; + ar.read_scalar(b); + v = (b != 0); + } +}; + // cplx_type (std::complex), standard-guaranteed layout // compatible with real_type[2] -- safe as a raw scalar for a same-layout // round trip (no cross-platform guarantee needed: the binary format is From 87f1e5670b83f65c8aad321619d44b897ce82727 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 20:40:16 +0000 Subject: [PATCH 074/166] Fix two reader bugs found by the corruption sweep under UBSan Second batch of findings from the new sanitizer CI, both hit by TestCorruptionSweep on the binary reader: - bool fields were deserialized as raw bytes: a corrupted byte produced a bool object holding eg 255, and merely loading such a value is undefined behavior (UBSan flagged 7 sites in the various set_state implementations). Bools are now normalized to 0/1 when read (still one byte on disk, format unchanged). - corrupted header strings (type tag, writer version) were embedded raw in exception messages: pybind11 converts what() to a python str as UTF-8, so garbage bytes turned the intended RuntimeError into a UnicodeDecodeError. Such strings are now escaped (and truncated) via a printable() helper before entering any message. Also preload libstdc++ together with libasan in the ASan CI job: python does not link libstdc++ itself, and without it ASan's __cxa_throw interceptor cannot resolve the real function, aborting the process on the first C++ exception. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .github/workflows/sanitizers.yml | 11 +++++++---- src/core/BinaryArchive.cpp | 29 ++++++++++++++++++++++++++--- src/core/BinaryArchive.hpp | 19 ++++++++++++++++++- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 21061ead..aa357481 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -44,14 +44,17 @@ jobs: - name: Run tests under ASan + UBSan # The sanitized code lives in a python extension, so the ASan runtime # must be the first thing loaded into the (uninstrumented) python - # process: hence LD_PRELOAD. Leak detection stays off: CPython's - # allocator keeps interned objects alive by design and would drown - # real reports in noise. + # process: hence LD_PRELOAD. libstdc++ must be preloaded along with + # it: python itself does not link it, and without it ASan's + # __cxa_throw interceptor cannot resolve the real function and every + # C++ exception aborts the process. Leak detection stays off: + # CPython's allocator keeps interned objects alive by design and + # would drown real reports in noise. env: ASAN_OPTIONS: detect_leaks=0 UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 run: | - export LD_PRELOAD=$(gcc -print-file-name=libasan.so) + export LD_PRELOAD="$(gcc -print-file-name=libasan.so) $(gcc -print-file-name=libstdc++.so.6)" cd lightsim2grid/tests python -W ignore -m unittest -v \ test_binary_serialization \ diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index 9e4c951c..c830643b 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -8,7 +8,7 @@ #include "BinaryArchive.hpp" -#include // std::remove, std::rename +#include // std::remove, std::rename, std::snprintf #include #include #include @@ -45,6 +45,29 @@ std::string supported_formats_str() return oss.str(); } +// Strings read from a (possibly corrupted) file must be escaped before being +// embedded in an exception message: pybind11 converts what() to a python str +// as UTF-8, and raw garbage bytes would turn the intended RuntimeError into a +// UnicodeDecodeError (found by the corruption-sweep test). Also truncated, +// so a corrupted length cannot produce a message megabytes long. +std::string printable(const std::string & s) +{ + const std::size_t max_len = 64; + std::ostringstream oss; + for (std::size_t i = 0; i < s.size() && i < max_len; ++i) { + const unsigned char c = static_cast(s[i]); + if (c >= 0x20 && c < 0x7f) { + oss << static_cast(c); + } else { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\x%02x", c); + oss << buf; + } + } + if (s.size() > max_len) oss << "... (" << s.size() << " chars)"; + return oss.str(); +} + } // anonymous namespace BinaryArchive::BinaryArchive(const std::string & path, Mode mode, bool atomic_write): @@ -206,7 +229,7 @@ void BinaryArchive::check_header(const std::string & type_tag, if (!is_format_supported(file_format)) { std::ostringstream oss; oss << "BinaryArchive: incompatible file '" << path_ << "': it was written by lightsim2grid version " - << file_major << "." << file_medium << "." << file_minor + << printable(file_major) << "." << printable(file_medium) << "." << printable(file_minor) << " using binary format " << file_format << ", but the currently installed lightsim2grid (version " << v_major << "." << v_medium << "." << v_minor @@ -219,7 +242,7 @@ void BinaryArchive::check_header(const std::string & type_tag, if (file_tag != type_tag) { std::ostringstream oss; oss << "BinaryArchive: wrong object type in file '" << path_ << "': it contains a '" - << file_tag << "', not a '" << type_tag << "'."; + << printable(file_tag) << "', not a '" << type_tag << "'."; throw std::runtime_error(oss.str()); } } diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 58c804a1..e6e9cf38 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -232,13 +232,30 @@ struct is_raw_vector_elem : std::integral_constant struct ValueArchiver; // intentionally incomplete: every real field type below has a specialization -// arithmetic scalars (real_type, int, bool, ...) +// arithmetic scalars (real_type, int, ...) template struct ValueArchiver::value>::type> { static void write(BinaryArchive & ar, const T & v) { ar.write_scalar(v); } static void read(BinaryArchive & ar, T & v) { ar.read_scalar(v); } }; +// bool: one byte on disk (same size as before), but normalized to 0/1 on +// read. Reading a corrupted byte straight into a bool object would create +// a bool holding eg 255, and merely loading such a value later is undefined +// behavior (found by UBSan under the corruption-sweep test). +template<> +struct ValueArchiver { + static void write(BinaryArchive & ar, const bool & v) { + const std::uint8_t b = v ? 1 : 0; + ar.write_scalar(b); + } + static void read(BinaryArchive & ar, bool & v) { + std::uint8_t b = 0; + ar.read_scalar(b); + v = (b != 0); + } +}; + // cplx_type (std::complex), standard-guaranteed layout // compatible with real_type[2] -- safe as a raw scalar for a same-layout // round trip (no cross-platform guarantee needed: the binary format is From f5e333804ed43cb7bf3c028ee98e7f335cf12214 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:01:43 +0000 Subject: [PATCH 075/166] Trigger sanitizer workflow on slashed branch names too In GitHub Actions globs a single '*' does not match '/', so 'branches: *' (the pattern main.yml uses) silently skips topic branches like 'foo/bar'. Use '**' for the sanitizers workflow so it runs on every push. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .github/workflows/sanitizers.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index aa357481..1156e33f 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -9,7 +9,9 @@ name: Sanitizers on: push: branches: - - '*' + # '**' and not '*': a single '*' does not match '/' in GitHub Actions + # globs, so topic branches like 'foo/bar' would silently never run + - '**' tags: - 'v*.*.*' From 22e2ee34a68884a2cb753ff9f36b910bf0b5497b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:01:43 +0000 Subject: [PATCH 076/166] Trigger sanitizer workflow on slashed branch names too In GitHub Actions globs a single '*' does not match '/', so 'branches: *' (the pattern main.yml uses) silently skips topic branches like 'foo/bar'. Use '**' for the sanitizers workflow so it runs on every push. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .github/workflows/sanitizers.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index aa357481..1156e33f 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -9,7 +9,9 @@ name: Sanitizers on: push: branches: - - '*' + # '**' and not '*': a single '*' does not match '/' in GitHub Actions + # globs, so topic branches like 'foo/bar' would silently never run + - '**' tags: - 'v*.*.*' From f5eef8ae211b0c419fb711b2a3e71d8ac317aa6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 12:17:32 +0000 Subject: [PATCH 077/166] Validate powerflow inputs (ac_pf / dc_pf / every solver's compute_pf) Third robustness surface after the binary files (#144) and the element-mutator bindings (#143): the powerflow entry points now reject ill-formed inputs with a clean python exception instead of reaching raw Eigen indexing (out-of-bounds reads/writes in Release builds) or silently computing garbage. - new shared BaseAlgo::check_pf_inputs / check_iter_tol, called at the top of every solver entry (Newton-Raphson, single-slack variants, fast-decoupled, Gauss-Seidel -- which had no validation at all -- and the DC compute_pf_dc): non-square Ybus, V / Sbus / slack_weights not matching the size of Ybus, empty slack_ids, non-positive max_iter and non-positive or non-finite tol raise RuntimeError; out-of-range or negative bus ids in pv / pq / slack_ids raise IndexError; a bus listed in more than one of slack/pv/pq (or twice in the same one) raises RuntimeError. This implements the long-standing "TODO DEBUG MODE" checks of BaseFDPFAlgo, unconditionally (one O(n) pass per call, negligible vs the solve). - LSGrid.ac_pf / dc_pf additionally validate max_iter and tol on top of the existing Vinit size check. - fixed a segfault found by the new tests: a standalone fast-decoupled solver (eg FDPF_XB_SparseLU() built from python, never attached to an LSGrid) dereferenced a null pointer in fillBp_Bpp on any compute_pf call; it now raises a RuntimeError explaining FDPF cannot run standalone (B'/B'' are built from the grid, not from Ybus). - new lightsim2grid/tests/test_pf_input_robustness.py: corrupted-input matrix (sizes, bounds, overlaps, max_iter/tol values, python types) for LSGrid.ac_pf / dc_pf and for every AC solver class available in the build, plus valid-input sanity checks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 15 ++ .../tests/test_pf_input_robustness.py | 247 ++++++++++++++++++ src/core/LSGrid.cpp | 8 +- src/core/SolverInstantiations.cpp | 13 + src/core/powerflow_algorithm/BaseAlgo.cpp | 98 +++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 28 ++ src/core/powerflow_algorithm/BaseDCAlgo.tpp | 2 + src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 21 +- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 5 +- src/core/powerflow_algorithm/NRAlgo.tpp | 15 +- 10 files changed, 419 insertions(+), 33 deletions(-) create mode 100644 lightsim2grid/tests/test_pf_input_robustness.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f202f5f2..debe08ec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -364,6 +364,21 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. documentation page and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). +- [IMPROVED] robustness of the powerflow entry points against ill-formed input: + `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (> 0) and `tol` + (finite and > 0) in addition to the size of `Vinit`, and every solver's + `compute_pf` / `solve` (Newton-Raphson, single-slack, fast-decoupled, + Gauss-Seidel, DC -- shared `BaseAlgo::check_pf_inputs`) now validates its + inputs before touching them: non-square `Ybus`, `V` / `Sbus` / + `slack_weights` not matching the size of `Ybus`, empty `slack_ids` and + non-positive / non-finite `max_iter` / `tol` raise `RuntimeError`; + out-of-range or negative bus ids in `pv` / `pq` / `slack_ids` raise + `IndexError` (previously they reached raw Eigen indexing: out-of-bounds + reads/writes in Release builds); a bus listed in more than one of + slack/pv/pq (or twice in the same one) raises `RuntimeError` instead of + silently producing a wrong system. Tested by the new + `lightsim2grid/tests/test_pf_input_robustness.py` for every solver class + available in the build. - [IMPROVED] robustness of `save_binary` / `load_binary` against ill-formed input: - version compatibility is now decided by a dedicated **binary format version** diff --git a/lightsim2grid/tests/test_pf_input_robustness.py b/lightsim2grid/tests/test_pf_input_robustness.py new file mode 100644 index 00000000..f8a4cccd --- /dev/null +++ b/lightsim2grid/tests/test_pf_input_robustness.py @@ -0,0 +1,247 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Robustness tests for the powerflow entry points: `LSGrid.ac_pf` / `LSGrid.dc_pf` +and the per-solver `compute_pf` / `solve` methods, in the same spirit as the +binary-file corruption tests (test_binary_serialization.py) and the +out-of-bounds binding tests (test_LSGrid_out_of_bounds.py): ill-formed inputs +must raise a clean Python exception, never crash, read out of bounds, or +silently compute garbage. + +Conventions (mirroring test_LSGrid_out_of_bounds.py): + * ``RuntimeError`` for structural problems: mis-sized V / Sbus / + slack_weights, non-square Ybus, empty slack_ids, a bus listed in more + than one of slack/pv/pq, non-positive max_iter, non-positive or + non-finite tol (``BaseAlgo::check_pf_inputs`` / ``check_iter_tol`` throw + ``std::runtime_error``); + * ``IndexError`` for out-of-range / negative bus ids in slack_ids / pv / pq + (``std::out_of_range``); + * ``TypeError`` for python arguments of the wrong type (pybind11). +""" + +import unittest +import warnings +import numpy as np + +import pandapower.networks as pn +from lightsim2grid.network import init_from_pandapower +from lightsim2grid import lightsim2grid_cpp + +MAX_IT = 30 +TOL = 1e-8 + +# every AC solver class importable in this build (DC solver objects only +# expose the throwing complex entry point: the DC validation is exercised +# through LSGrid.dc_pf and the C++ compute_pf_dc path instead) +AC_SOLVER_CLASSES = [ + nm for nm in ( + "NR_SparseLU", "NRSing_SparseLU", + "FDPF_XB_SparseLU", "FDPF_BX_SparseLU", + "NR_KLU", "NRSing_KLU", "FDPF_XB_KLU", "FDPF_BX_KLU", + "NR_NICSLU", "NRSing_NICSLU", "FDPF_XB_NICSLU", "FDPF_BX_NICSLU", + "NR_CKTSO", "NRSing_CKTSO", "FDPF_XB_CKTSO", "FDPF_BX_CKTSO", + "GaussSeidelAlgo", "GaussSeidelSynchAlgo", + ) if hasattr(lightsim2grid_cpp, nm) +] + + +def _make_grid(): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + net = pn.case14() + return init_from_pandapower(net) + + +class TestAcDcPfInputs(unittest.TestCase): + """LSGrid.ac_pf / LSGrid.dc_pf with corrupted arguments.""" + + def setUp(self): + self.grid = _make_grid() + self.n_bus = self.grid.total_bus() + self.v_init = np.ones(self.n_bus, dtype=complex) + + def _pf_methods(self): + yield "ac_pf", self.grid.ac_pf + yield "dc_pf", self.grid.dc_pf + + def test_valid_calls_converge(self): + for name, pf in self._pf_methods(): + with self.subTest(name): + V = pf(1.0 * self.v_init, MAX_IT, TOL) + assert V.shape[0] == self.n_bus, f"{name} diverged on a valid call" + + def test_wrong_v_init_size(self): + for name, pf in self._pf_methods(): + for descr, v in [("too short", self.v_init[:-1]), + ("too long", np.ones(self.n_bus + 3, dtype=complex)), + ("empty", np.zeros(0, dtype=complex))]: + with self.subTest(f"{name}, Vinit {descr}"): + with self.assertRaises(RuntimeError): + pf(v, MAX_IT, TOL) + + def test_bad_max_iter(self): + for name, pf in self._pf_methods(): + for bad_iter in (0, -1, -100): + with self.subTest(f"{name}, max_iter={bad_iter}"): + with self.assertRaises(RuntimeError): + pf(1.0 * self.v_init, bad_iter, TOL) + + def test_bad_tol(self): + for name, pf in self._pf_methods(): + for bad_tol in (0., -1e-8, float("nan"), float("inf"), -float("inf")): + with self.subTest(f"{name}, tol={bad_tol}"): + with self.assertRaises(RuntimeError): + pf(1.0 * self.v_init, MAX_IT, bad_tol) + + def test_wrong_types(self): + for name, pf in self._pf_methods(): + calls = [ + ("V is a string", lambda pf=pf: pf("not a voltage vector", MAX_IT, TOL)), + ("V is a list of strings", lambda pf=pf: pf(["a"] * self.n_bus, MAX_IT, TOL)), + ("max_iter is a string", lambda pf=pf: pf(1.0 * self.v_init, "many", TOL)), + ("max_iter is a float", lambda pf=pf: pf(1.0 * self.v_init, 3.5, TOL)), + ("tol is a string", lambda pf=pf: pf(1.0 * self.v_init, MAX_IT, "tiny")), + ("tol is complex", lambda pf=pf: pf(1.0 * self.v_init, MAX_IT, 1e-8 + 1j)), + ] + for descr, fun in calls: + with self.subTest(f"{name}, {descr}"): + with self.assertRaises(TypeError): + fun() + + +class TestSolverComputePfInputs(unittest.TestCase): + """Direct Solver.compute_pf / Solver.solve with corrupted inputs, for + every AC solver class available in this build. + + A single valid input set is built from the grid (one converged ac_pf, + then the *solver-side* accessors), then exactly one aspect is corrupted + per case. Before the validation in BaseAlgo::check_pf_inputs, several of + these reached raw Eigen indexing (out-of-bounds reads/writes, invisible + in Release builds where Eigen's asserts are compiled out). + """ + + @classmethod + def setUpClass(cls): + grid = _make_grid() + n_bus = grid.total_bus() + grid.ac_pf(np.ones(n_bus, dtype=complex), MAX_IT, TOL) + cls.Ybus = grid.get_Ybus_solver() # scipy sparse, n x n + cls.Sbus = 1.0 * grid.get_Sbus_solver() + cls.V0 = np.ones(cls.Ybus.shape[0], dtype=complex) + cls.slack_ids = np.array(grid.get_slack_ids_solver(), dtype=np.int32) + cls.slack_weights = 1.0 * grid.get_slack_weights_solver() + cls.pv = np.array(grid.get_pv_solver(), dtype=np.int32) + cls.pq = np.array(grid.get_pq_solver(), dtype=np.int32) + cls.n = cls.Ybus.shape[0] + + def _solve(self, solver_cls_name, Ybus=None, V=None, Sbus=None, + slack_ids=None, slack_weights=None, pv=None, pq=None, + max_iter=MAX_IT, tol=TOL): + solver = getattr(lightsim2grid_cpp, solver_cls_name)() + return solver.compute_pf( + self.Ybus if Ybus is None else Ybus, + 1.0 * (self.V0 if V is None else V), # compute_pf mutates V: pass a copy + self.Sbus if Sbus is None else Sbus, + self.slack_ids if slack_ids is None else slack_ids, + self.slack_weights if slack_weights is None else slack_weights, + self.pv if pv is None else pv, + self.pq if pq is None else pq, + max_iter, + tol, + ) + + def _assert_all_solvers_raise(self, exc, descr, **corrupted): + for solver_nm in AC_SOLVER_CLASSES: + with self.subTest(f"{solver_nm}, {descr}"): + with self.assertRaises(exc): + self._solve(solver_nm, **corrupted) + + def test_valid_inputs_converge(self): + # sanity: the validation must not reject a legitimate system. + # FDPF solvers cannot run standalone (their B'/B'' matrices are built + # from the grid, not from Ybus): they must raise a clean RuntimeError + # instead -- writing this very test found a null-pointer segfault there. + for solver_nm in AC_SOLVER_CLASSES: + with self.subTest(solver_nm): + solver = getattr(lightsim2grid_cpp, solver_nm)() + if solver_nm.startswith("FDPF"): + with self.assertRaises(RuntimeError) as cm: + self._solve(solver_nm, max_iter=1000) + assert "not attached to an LSGrid" in str(cm.exception) + continue + conv = solver.compute_pf(self.Ybus, 1.0 * self.V0, self.Sbus, + self.slack_ids, self.slack_weights, + self.pv, self.pq, 1000, TOL) + assert conv, f"{solver_nm} did not converge on a valid system" + + def test_non_square_ybus(self): + self._assert_all_solvers_raise( + RuntimeError, "non-square Ybus", Ybus=self.Ybus[:-1, :]) + + def test_sbus_size_mismatch(self): + self._assert_all_solvers_raise( + RuntimeError, "Sbus too short", Sbus=self.Sbus[:-1]) + self._assert_all_solvers_raise( + RuntimeError, "Sbus too long", + Sbus=np.concatenate([self.Sbus, [0. + 0j]])) + + def test_v_size_mismatch(self): + self._assert_all_solvers_raise( + RuntimeError, "V too short", V=self.V0[:-1]) + self._assert_all_solvers_raise( + RuntimeError, "V too long", + V=np.ones(self.n + 2, dtype=complex)) + + def test_slack_weights_size_mismatch(self): + self._assert_all_solvers_raise( + RuntimeError, "slack_weights too short", + slack_weights=self.slack_weights[:-1]) + + def test_out_of_bounds_ids(self): + for arr_name in ("pv", "pq", "slack_ids"): + valid = getattr(self, arr_name) + for descr, bad_id in (("id == n", self.n), ("huge id", 10**6), ("negative id", -1)): + corrupted = np.concatenate([valid, [bad_id]]).astype(np.int32) + self._assert_all_solvers_raise( + IndexError, f"{arr_name} with {descr}", **{arr_name: corrupted}) + + def test_empty_slack(self): + self._assert_all_solvers_raise( + RuntimeError, "empty slack_ids", + slack_ids=np.zeros(0, dtype=np.int32)) + + def test_overlapping_pv_pq(self): + # the same bus declared both PV and PQ silently breaks the system of + # equations: it must be rejected, not solved + overlap = np.concatenate([self.pq, [self.pv[0]]]).astype(np.int32) + self._assert_all_solvers_raise( + RuntimeError, "bus both pv and pq", pq=overlap) + # a bus listed twice in the same array is just as wrong + twice = np.concatenate([self.pq, [self.pq[0]]]).astype(np.int32) + self._assert_all_solvers_raise( + RuntimeError, "bus twice in pq", pq=twice) + + def test_slack_also_pv(self): + also_pv = np.concatenate([self.pv, [self.slack_ids[0]]]).astype(np.int32) + self._assert_all_solvers_raise( + RuntimeError, "slack bus also in pv", pv=also_pv) + + def test_bad_max_iter(self): + for bad_iter in (0, -1): + self._assert_all_solvers_raise( + RuntimeError, f"max_iter={bad_iter}", max_iter=bad_iter) + + def test_bad_tol(self): + for bad_tol in (0., -1e-8, float("nan"), float("inf")): + self._assert_all_solvers_raise( + RuntimeError, f"tol={bad_tol}", tol=bad_tol) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 4694d03e..337db1b1 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -369,6 +369,7 @@ CplxVect LSGrid::ac_pf(const CplxVect & Vinit, exc_ << "(fyi: Components of Vinit corresponding to deactivated bus will be ignored anyway, so you can put whatever you want there)."; throw std::runtime_error(exc_.str()); } + BaseAlgo::check_iter_tol("LSGrid::ac_pf", max_iter, tol); if(hvdc_lines_.has_droop_active() && !_algo.supports_hvdc_droop(_algo.get_type())){ std::ostringstream exc_; exc_ << "LSGrid::ac_pf: the grid counts hvdc lines with the angle-droop (AC emulation) enabled, "; @@ -1375,8 +1376,8 @@ void LSGrid::reset_results(){ } CplxVect LSGrid::dc_pf(const CplxVect & Vinit, - int /*max_iter*/, // not used for DC - real_type /*tol*/ // not used for DC + int max_iter, // only validated: not used for DC (single linear solve) + real_type tol // only validated: not used for DC (single linear solve) ) { //TODO SLACK: improve distributed slack for DC mode ! @@ -1394,6 +1395,9 @@ CplxVect LSGrid::dc_pf(const CplxVect & Vinit, exc_ << "(fyi: Components of Vinit corresponding to deactivated bus will be ignored anyway, so you can put whatever you want there)."; throw std::runtime_error(exc_.str()); } + // DC ignores max_iter / tol, but nonsensical values still indicate a bug + // at the call site: reject them the same way ac_pf does + BaseAlgo::check_iter_tol("LSGrid::dc_pf", max_iter, tol); bool conv = false; CplxVect res = CplxVect(); diff --git a/src/core/SolverInstantiations.cpp b/src/core/SolverInstantiations.cpp index 7f6fe6d2..b25f48c8 100644 --- a/src/core/SolverInstantiations.cpp +++ b/src/core/SolverInstantiations.cpp @@ -7,6 +7,8 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "Solvers.hpp" +#include + #include "LSGrid.hpp" // required: fillBp_Bpp body references lsgrid_ptr_ (a GridModel*) namespace ls2g { @@ -18,6 +20,17 @@ void BaseFDPFAlgo::fillBp_Bpp( Eigen::SparseMatrix & Bp, Eigen::SparseMatrix & Bpp) const { + if (lsgrid_ptr_ == nullptr) { + // standalone use (eg a bare FDPF_XB_SparseLU() built from python): + // unlike NR / Gauss-Seidel, FDPF cannot work from Ybus alone -- the + // B' / B'' matrices are built from the grid data. Without this check + // the call below is a null-pointer dereference (segfault). + throw std::runtime_error( + "BaseFDPFAlgo::compute_pf: this fast-decoupled solver is not attached to an LSGrid " + "(the B'/B'' matrices are built from the grid data, not from Ybus alone), so it " + "cannot be used standalone: use it through LSGrid instead " + "(change_algorithm(...) then ac_pf(...))."); + } lsgrid_ptr_->fillBp_Bpp(Bp, Bpp, XB_BX); } diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 0514b9aa..6099be9d 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -9,8 +9,106 @@ #include "BaseAlgo.hpp" #include "LSGrid.hpp" // needs to be included here because of the forward declaration +#include // std::isfinite +#include + namespace ls2g { +namespace { + +// bounds + duplicate / overlap check for one of slack_ids / pv / pq. +// `marks` holds, for each bus, 0 (unseen) or the 1-based index into +// `array_names` of the array that already claimed it. +void check_bus_id_array(const std::string & caller, + Eigen::Ref ids, + Eigen::Index n, + std::vector & marks, + int array_code, + const char * const array_names[]) +{ + const char * name = array_names[array_code - 1]; + for (Eigen::Index i = 0; i < ids.size(); ++i) { + const int id = ids(i); + if (id < 0 || id >= n) { + std::ostringstream exc_; + exc_ << caller << ": " << name << "[" << i << "] = " << id + << " is out of bounds: bus ids must be in [0, " << n + << ") (n = size of Ybus)."; + throw std::out_of_range(exc_.str()); + } + if (marks[static_cast(id)] != 0) { + const char * first = array_names[marks[static_cast(id)] - 1]; + std::ostringstream exc_; + exc_ << caller << ": bus id " << id << " appears both in " << first + << " and in " << name + << " (or twice in the same one): slack_ids, pv and pq must be disjoint."; + throw std::runtime_error(exc_.str()); + } + marks[static_cast(id)] = static_cast(array_code); + } +} + +} // anonymous namespace + +void BaseAlgo::check_pf_inputs(const std::string & caller, + Eigen::Index ybus_rows, Eigen::Index ybus_cols, + Eigen::Index v_size, Eigen::Index sbus_size, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq) +{ + if (ybus_rows != ybus_cols) { + std::ostringstream exc_; + exc_ << caller << ": the Ybus matrix must be square. Currently: (" + << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + const Eigen::Index n = ybus_rows; + if (sbus_size != n) { + std::ostringstream exc_; + exc_ << caller << ": Size of the Sbus (or Pbus) vector should be the same as the size of Ybus. Currently: " + << "Sbus (" << sbus_size << ") and Ybus (" << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + if (v_size != n) { + std::ostringstream exc_; + exc_ << caller << ": Size of V (init voltages) should be the same as the size of Ybus. Currently: " + << "V (" << v_size << ") and Ybus (" << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + if (slack_weights.size() != n) { + std::ostringstream exc_; + exc_ << caller << ": Size of slack_weights should be the same as the size of Ybus " + << "(one weight per bus, indexed by bus id). Currently: slack_weights (" + << slack_weights.size() << ") and Ybus (" << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + if (slack_ids.size() == 0) { + std::ostringstream exc_; + exc_ << caller << ": at least one slack bus is required (slack_ids is empty)."; + throw std::runtime_error(exc_.str()); + } + static const char * const array_names[] = {"slack_ids", "pv", "pq"}; + std::vector marks(static_cast(n), 0); + check_bus_id_array(caller, slack_ids, n, marks, 1, array_names); + check_bus_id_array(caller, pv, n, marks, 2, array_names); + check_bus_id_array(caller, pq, n, marks, 3, array_names); +} + +void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_type tol) +{ + if (max_iter <= 0) { + std::ostringstream exc_; + exc_ << caller << ": max_iter must be a strictly positive integer, got " << max_iter << "."; + throw std::runtime_error(exc_.str()); + } + if (!std::isfinite(tol) || !(tol > 0.)) { + std::ostringstream exc_; + exc_ << caller << ": tol must be a finite, strictly positive number, got " << tol << "."; + throw std::runtime_error(exc_.str()); + } +} void BaseAlgo::reset(){ // reset timers diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 7a09b988..2a1e7891 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -86,6 +86,34 @@ class LS2G_API BaseAlgo : public BaseConstants lsgrid_ptr_ = gridmodel; } + // ---- input validation shared by every solver entry point ---- + // Called at the top of each compute_pf / compute_pf_dc override so + // ill-formed inputs (from python via Solver.solve / compute_pf, or + // from a buggy C++ caller) raise a clean exception instead of + // reaching raw Eigen indexing (whose bounds asserts are compiled out + // in release builds). One O(n) pass, negligible vs the solve itself. + // + // Throws std::runtime_error for structural problems: non-square + // Ybus, V / Sbus (or Pbus) / slack_weights not of size n (the + // slack weights are indexed *by bus id*, see eg SlackPolicies.tpp), + // no slack bus at all, or a bus listed twice / in more than one of + // slack_ids / pv / pq. + // Throws std::out_of_range (python IndexError) for any id of + // slack_ids / pv / pq outside [0, n). + // `caller` names the solver in the error message (eg "NRAlgo::compute_pf"). + static void check_pf_inputs(const std::string & caller, + Eigen::Index ybus_rows, Eigen::Index ybus_cols, + Eigen::Index v_size, Eigen::Index sbus_size, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq); + + // Throws std::runtime_error unless max_iter > 0 and tol is a finite + // strictly positive number (NaN and infinity are rejected). AC only: + // DC is a single linear solve without iterations / tolerance. + static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); + virtual Eigen::Ref > get_J() const { throw std::runtime_error("AlgorithmSelector::get_J: There is not Jacobian matrix for this solver type."); } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index a49fcc9b..1078d13f 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -22,6 +22,8 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, const CplxVect & Sbus, Eigen::Ref slack_ids, - const RealVect & /*slack_weights*/, // currently unused + const RealVect & slack_weights, // only validated, otherwise unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, real_type tol ) { + BaseAlgo::check_pf_inputs("GaussSeidelAlgo::compute_pf", Ybus.rows(), Ybus.cols(), + V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); + BaseAlgo::check_iter_tol("GaussSeidelAlgo::compute_pf", max_iter, tol); /** pv: id of the pv buses pq: id of the pq buses diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index 85174781..0400d4f9 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -18,18 +18,9 @@ bool NRAlgo::compute_pf( int max_iter, real_type tol) { - if (Sbus.size() != Ybus.rows() || Sbus.size() != Ybus.cols()) { - std::ostringstream exc_; - exc_ << "NRAlgo::compute_pf: Size of the Sbus should be the same as the size of Ybus. Currently: "; - exc_ << "Sbus (" << Sbus.size() << ") and Ybus (" << Ybus.rows() << ", " << Ybus.cols() << ")."; - throw std::runtime_error(exc_.str()); - } - if (V.size() != Ybus.rows() || V.size() != Ybus.cols()) { - std::ostringstream exc_; - exc_ << "NRAlgo::compute_pf: Size of V (init voltages) should be the same as the size of Ybus. Currently: "; - exc_ << "V (" << V.size() << ") and Ybus (" << Ybus.rows() << ", " << Ybus.cols() << ")."; - throw std::runtime_error(exc_.str()); - } + BaseAlgo::check_pf_inputs("NRAlgo::compute_pf", Ybus.rows(), Ybus.cols(), + V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); + BaseAlgo::check_iter_tol("NRAlgo::compute_pf", max_iter, tol); if (!is_linear_solver_valid()) return false; reset_timer(); From 71f00ecf1471d4d18c19a81bf79baa3776f22a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 12:17:32 +0000 Subject: [PATCH 078/166] Validate powerflow inputs (ac_pf / dc_pf / every solver's compute_pf) Third robustness surface after the binary files (#144) and the element-mutator bindings (#143): the powerflow entry points now reject ill-formed inputs with a clean python exception instead of reaching raw Eigen indexing (out-of-bounds reads/writes in Release builds) or silently computing garbage. - new shared BaseAlgo::check_pf_inputs / check_iter_tol, called at the top of every solver entry (Newton-Raphson, single-slack variants, fast-decoupled, Gauss-Seidel -- which had no validation at all -- and the DC compute_pf_dc): non-square Ybus, V / Sbus / slack_weights not matching the size of Ybus, empty slack_ids, non-positive max_iter and non-positive or non-finite tol raise RuntimeError; out-of-range or negative bus ids in pv / pq / slack_ids raise IndexError; a bus listed in more than one of slack/pv/pq (or twice in the same one) raises RuntimeError. This implements the long-standing "TODO DEBUG MODE" checks of BaseFDPFAlgo, unconditionally (one O(n) pass per call, negligible vs the solve). - LSGrid.ac_pf / dc_pf additionally validate max_iter and tol on top of the existing Vinit size check. - fixed a segfault found by the new tests: a standalone fast-decoupled solver (eg FDPF_XB_SparseLU() built from python, never attached to an LSGrid) dereferenced a null pointer in fillBp_Bpp on any compute_pf call; it now raises a RuntimeError explaining FDPF cannot run standalone (B'/B'' are built from the grid, not from Ybus). - new lightsim2grid/tests/test_pf_input_robustness.py: corrupted-input matrix (sizes, bounds, overlaps, max_iter/tol values, python types) for LSGrid.ac_pf / dc_pf and for every AC solver class available in the build, plus valid-input sanity checks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 15 ++ .../tests/test_pf_input_robustness.py | 247 ++++++++++++++++++ src/core/LSGrid.cpp | 8 +- src/core/SolverInstantiations.cpp | 13 + src/core/powerflow_algorithm/BaseAlgo.cpp | 98 +++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 28 ++ src/core/powerflow_algorithm/BaseDCAlgo.tpp | 2 + src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 21 +- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 5 +- src/core/powerflow_algorithm/NRAlgo.tpp | 15 +- 10 files changed, 419 insertions(+), 33 deletions(-) create mode 100644 lightsim2grid/tests/test_pf_input_robustness.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f202f5f2..debe08ec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -364,6 +364,21 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. documentation page and `benchmarks/benchmark_binary_serialization.py` for speed comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). +- [IMPROVED] robustness of the powerflow entry points against ill-formed input: + `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (> 0) and `tol` + (finite and > 0) in addition to the size of `Vinit`, and every solver's + `compute_pf` / `solve` (Newton-Raphson, single-slack, fast-decoupled, + Gauss-Seidel, DC -- shared `BaseAlgo::check_pf_inputs`) now validates its + inputs before touching them: non-square `Ybus`, `V` / `Sbus` / + `slack_weights` not matching the size of `Ybus`, empty `slack_ids` and + non-positive / non-finite `max_iter` / `tol` raise `RuntimeError`; + out-of-range or negative bus ids in `pv` / `pq` / `slack_ids` raise + `IndexError` (previously they reached raw Eigen indexing: out-of-bounds + reads/writes in Release builds); a bus listed in more than one of + slack/pv/pq (or twice in the same one) raises `RuntimeError` instead of + silently producing a wrong system. Tested by the new + `lightsim2grid/tests/test_pf_input_robustness.py` for every solver class + available in the build. - [IMPROVED] robustness of `save_binary` / `load_binary` against ill-formed input: - version compatibility is now decided by a dedicated **binary format version** diff --git a/lightsim2grid/tests/test_pf_input_robustness.py b/lightsim2grid/tests/test_pf_input_robustness.py new file mode 100644 index 00000000..f8a4cccd --- /dev/null +++ b/lightsim2grid/tests/test_pf_input_robustness.py @@ -0,0 +1,247 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Robustness tests for the powerflow entry points: `LSGrid.ac_pf` / `LSGrid.dc_pf` +and the per-solver `compute_pf` / `solve` methods, in the same spirit as the +binary-file corruption tests (test_binary_serialization.py) and the +out-of-bounds binding tests (test_LSGrid_out_of_bounds.py): ill-formed inputs +must raise a clean Python exception, never crash, read out of bounds, or +silently compute garbage. + +Conventions (mirroring test_LSGrid_out_of_bounds.py): + * ``RuntimeError`` for structural problems: mis-sized V / Sbus / + slack_weights, non-square Ybus, empty slack_ids, a bus listed in more + than one of slack/pv/pq, non-positive max_iter, non-positive or + non-finite tol (``BaseAlgo::check_pf_inputs`` / ``check_iter_tol`` throw + ``std::runtime_error``); + * ``IndexError`` for out-of-range / negative bus ids in slack_ids / pv / pq + (``std::out_of_range``); + * ``TypeError`` for python arguments of the wrong type (pybind11). +""" + +import unittest +import warnings +import numpy as np + +import pandapower.networks as pn +from lightsim2grid.network import init_from_pandapower +from lightsim2grid import lightsim2grid_cpp + +MAX_IT = 30 +TOL = 1e-8 + +# every AC solver class importable in this build (DC solver objects only +# expose the throwing complex entry point: the DC validation is exercised +# through LSGrid.dc_pf and the C++ compute_pf_dc path instead) +AC_SOLVER_CLASSES = [ + nm for nm in ( + "NR_SparseLU", "NRSing_SparseLU", + "FDPF_XB_SparseLU", "FDPF_BX_SparseLU", + "NR_KLU", "NRSing_KLU", "FDPF_XB_KLU", "FDPF_BX_KLU", + "NR_NICSLU", "NRSing_NICSLU", "FDPF_XB_NICSLU", "FDPF_BX_NICSLU", + "NR_CKTSO", "NRSing_CKTSO", "FDPF_XB_CKTSO", "FDPF_BX_CKTSO", + "GaussSeidelAlgo", "GaussSeidelSynchAlgo", + ) if hasattr(lightsim2grid_cpp, nm) +] + + +def _make_grid(): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + net = pn.case14() + return init_from_pandapower(net) + + +class TestAcDcPfInputs(unittest.TestCase): + """LSGrid.ac_pf / LSGrid.dc_pf with corrupted arguments.""" + + def setUp(self): + self.grid = _make_grid() + self.n_bus = self.grid.total_bus() + self.v_init = np.ones(self.n_bus, dtype=complex) + + def _pf_methods(self): + yield "ac_pf", self.grid.ac_pf + yield "dc_pf", self.grid.dc_pf + + def test_valid_calls_converge(self): + for name, pf in self._pf_methods(): + with self.subTest(name): + V = pf(1.0 * self.v_init, MAX_IT, TOL) + assert V.shape[0] == self.n_bus, f"{name} diverged on a valid call" + + def test_wrong_v_init_size(self): + for name, pf in self._pf_methods(): + for descr, v in [("too short", self.v_init[:-1]), + ("too long", np.ones(self.n_bus + 3, dtype=complex)), + ("empty", np.zeros(0, dtype=complex))]: + with self.subTest(f"{name}, Vinit {descr}"): + with self.assertRaises(RuntimeError): + pf(v, MAX_IT, TOL) + + def test_bad_max_iter(self): + for name, pf in self._pf_methods(): + for bad_iter in (0, -1, -100): + with self.subTest(f"{name}, max_iter={bad_iter}"): + with self.assertRaises(RuntimeError): + pf(1.0 * self.v_init, bad_iter, TOL) + + def test_bad_tol(self): + for name, pf in self._pf_methods(): + for bad_tol in (0., -1e-8, float("nan"), float("inf"), -float("inf")): + with self.subTest(f"{name}, tol={bad_tol}"): + with self.assertRaises(RuntimeError): + pf(1.0 * self.v_init, MAX_IT, bad_tol) + + def test_wrong_types(self): + for name, pf in self._pf_methods(): + calls = [ + ("V is a string", lambda pf=pf: pf("not a voltage vector", MAX_IT, TOL)), + ("V is a list of strings", lambda pf=pf: pf(["a"] * self.n_bus, MAX_IT, TOL)), + ("max_iter is a string", lambda pf=pf: pf(1.0 * self.v_init, "many", TOL)), + ("max_iter is a float", lambda pf=pf: pf(1.0 * self.v_init, 3.5, TOL)), + ("tol is a string", lambda pf=pf: pf(1.0 * self.v_init, MAX_IT, "tiny")), + ("tol is complex", lambda pf=pf: pf(1.0 * self.v_init, MAX_IT, 1e-8 + 1j)), + ] + for descr, fun in calls: + with self.subTest(f"{name}, {descr}"): + with self.assertRaises(TypeError): + fun() + + +class TestSolverComputePfInputs(unittest.TestCase): + """Direct Solver.compute_pf / Solver.solve with corrupted inputs, for + every AC solver class available in this build. + + A single valid input set is built from the grid (one converged ac_pf, + then the *solver-side* accessors), then exactly one aspect is corrupted + per case. Before the validation in BaseAlgo::check_pf_inputs, several of + these reached raw Eigen indexing (out-of-bounds reads/writes, invisible + in Release builds where Eigen's asserts are compiled out). + """ + + @classmethod + def setUpClass(cls): + grid = _make_grid() + n_bus = grid.total_bus() + grid.ac_pf(np.ones(n_bus, dtype=complex), MAX_IT, TOL) + cls.Ybus = grid.get_Ybus_solver() # scipy sparse, n x n + cls.Sbus = 1.0 * grid.get_Sbus_solver() + cls.V0 = np.ones(cls.Ybus.shape[0], dtype=complex) + cls.slack_ids = np.array(grid.get_slack_ids_solver(), dtype=np.int32) + cls.slack_weights = 1.0 * grid.get_slack_weights_solver() + cls.pv = np.array(grid.get_pv_solver(), dtype=np.int32) + cls.pq = np.array(grid.get_pq_solver(), dtype=np.int32) + cls.n = cls.Ybus.shape[0] + + def _solve(self, solver_cls_name, Ybus=None, V=None, Sbus=None, + slack_ids=None, slack_weights=None, pv=None, pq=None, + max_iter=MAX_IT, tol=TOL): + solver = getattr(lightsim2grid_cpp, solver_cls_name)() + return solver.compute_pf( + self.Ybus if Ybus is None else Ybus, + 1.0 * (self.V0 if V is None else V), # compute_pf mutates V: pass a copy + self.Sbus if Sbus is None else Sbus, + self.slack_ids if slack_ids is None else slack_ids, + self.slack_weights if slack_weights is None else slack_weights, + self.pv if pv is None else pv, + self.pq if pq is None else pq, + max_iter, + tol, + ) + + def _assert_all_solvers_raise(self, exc, descr, **corrupted): + for solver_nm in AC_SOLVER_CLASSES: + with self.subTest(f"{solver_nm}, {descr}"): + with self.assertRaises(exc): + self._solve(solver_nm, **corrupted) + + def test_valid_inputs_converge(self): + # sanity: the validation must not reject a legitimate system. + # FDPF solvers cannot run standalone (their B'/B'' matrices are built + # from the grid, not from Ybus): they must raise a clean RuntimeError + # instead -- writing this very test found a null-pointer segfault there. + for solver_nm in AC_SOLVER_CLASSES: + with self.subTest(solver_nm): + solver = getattr(lightsim2grid_cpp, solver_nm)() + if solver_nm.startswith("FDPF"): + with self.assertRaises(RuntimeError) as cm: + self._solve(solver_nm, max_iter=1000) + assert "not attached to an LSGrid" in str(cm.exception) + continue + conv = solver.compute_pf(self.Ybus, 1.0 * self.V0, self.Sbus, + self.slack_ids, self.slack_weights, + self.pv, self.pq, 1000, TOL) + assert conv, f"{solver_nm} did not converge on a valid system" + + def test_non_square_ybus(self): + self._assert_all_solvers_raise( + RuntimeError, "non-square Ybus", Ybus=self.Ybus[:-1, :]) + + def test_sbus_size_mismatch(self): + self._assert_all_solvers_raise( + RuntimeError, "Sbus too short", Sbus=self.Sbus[:-1]) + self._assert_all_solvers_raise( + RuntimeError, "Sbus too long", + Sbus=np.concatenate([self.Sbus, [0. + 0j]])) + + def test_v_size_mismatch(self): + self._assert_all_solvers_raise( + RuntimeError, "V too short", V=self.V0[:-1]) + self._assert_all_solvers_raise( + RuntimeError, "V too long", + V=np.ones(self.n + 2, dtype=complex)) + + def test_slack_weights_size_mismatch(self): + self._assert_all_solvers_raise( + RuntimeError, "slack_weights too short", + slack_weights=self.slack_weights[:-1]) + + def test_out_of_bounds_ids(self): + for arr_name in ("pv", "pq", "slack_ids"): + valid = getattr(self, arr_name) + for descr, bad_id in (("id == n", self.n), ("huge id", 10**6), ("negative id", -1)): + corrupted = np.concatenate([valid, [bad_id]]).astype(np.int32) + self._assert_all_solvers_raise( + IndexError, f"{arr_name} with {descr}", **{arr_name: corrupted}) + + def test_empty_slack(self): + self._assert_all_solvers_raise( + RuntimeError, "empty slack_ids", + slack_ids=np.zeros(0, dtype=np.int32)) + + def test_overlapping_pv_pq(self): + # the same bus declared both PV and PQ silently breaks the system of + # equations: it must be rejected, not solved + overlap = np.concatenate([self.pq, [self.pv[0]]]).astype(np.int32) + self._assert_all_solvers_raise( + RuntimeError, "bus both pv and pq", pq=overlap) + # a bus listed twice in the same array is just as wrong + twice = np.concatenate([self.pq, [self.pq[0]]]).astype(np.int32) + self._assert_all_solvers_raise( + RuntimeError, "bus twice in pq", pq=twice) + + def test_slack_also_pv(self): + also_pv = np.concatenate([self.pv, [self.slack_ids[0]]]).astype(np.int32) + self._assert_all_solvers_raise( + RuntimeError, "slack bus also in pv", pv=also_pv) + + def test_bad_max_iter(self): + for bad_iter in (0, -1): + self._assert_all_solvers_raise( + RuntimeError, f"max_iter={bad_iter}", max_iter=bad_iter) + + def test_bad_tol(self): + for bad_tol in (0., -1e-8, float("nan"), float("inf")): + self._assert_all_solvers_raise( + RuntimeError, f"tol={bad_tol}", tol=bad_tol) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 4694d03e..337db1b1 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -369,6 +369,7 @@ CplxVect LSGrid::ac_pf(const CplxVect & Vinit, exc_ << "(fyi: Components of Vinit corresponding to deactivated bus will be ignored anyway, so you can put whatever you want there)."; throw std::runtime_error(exc_.str()); } + BaseAlgo::check_iter_tol("LSGrid::ac_pf", max_iter, tol); if(hvdc_lines_.has_droop_active() && !_algo.supports_hvdc_droop(_algo.get_type())){ std::ostringstream exc_; exc_ << "LSGrid::ac_pf: the grid counts hvdc lines with the angle-droop (AC emulation) enabled, "; @@ -1375,8 +1376,8 @@ void LSGrid::reset_results(){ } CplxVect LSGrid::dc_pf(const CplxVect & Vinit, - int /*max_iter*/, // not used for DC - real_type /*tol*/ // not used for DC + int max_iter, // only validated: not used for DC (single linear solve) + real_type tol // only validated: not used for DC (single linear solve) ) { //TODO SLACK: improve distributed slack for DC mode ! @@ -1394,6 +1395,9 @@ CplxVect LSGrid::dc_pf(const CplxVect & Vinit, exc_ << "(fyi: Components of Vinit corresponding to deactivated bus will be ignored anyway, so you can put whatever you want there)."; throw std::runtime_error(exc_.str()); } + // DC ignores max_iter / tol, but nonsensical values still indicate a bug + // at the call site: reject them the same way ac_pf does + BaseAlgo::check_iter_tol("LSGrid::dc_pf", max_iter, tol); bool conv = false; CplxVect res = CplxVect(); diff --git a/src/core/SolverInstantiations.cpp b/src/core/SolverInstantiations.cpp index 7f6fe6d2..b25f48c8 100644 --- a/src/core/SolverInstantiations.cpp +++ b/src/core/SolverInstantiations.cpp @@ -7,6 +7,8 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "Solvers.hpp" +#include + #include "LSGrid.hpp" // required: fillBp_Bpp body references lsgrid_ptr_ (a GridModel*) namespace ls2g { @@ -18,6 +20,17 @@ void BaseFDPFAlgo::fillBp_Bpp( Eigen::SparseMatrix & Bp, Eigen::SparseMatrix & Bpp) const { + if (lsgrid_ptr_ == nullptr) { + // standalone use (eg a bare FDPF_XB_SparseLU() built from python): + // unlike NR / Gauss-Seidel, FDPF cannot work from Ybus alone -- the + // B' / B'' matrices are built from the grid data. Without this check + // the call below is a null-pointer dereference (segfault). + throw std::runtime_error( + "BaseFDPFAlgo::compute_pf: this fast-decoupled solver is not attached to an LSGrid " + "(the B'/B'' matrices are built from the grid data, not from Ybus alone), so it " + "cannot be used standalone: use it through LSGrid instead " + "(change_algorithm(...) then ac_pf(...))."); + } lsgrid_ptr_->fillBp_Bpp(Bp, Bpp, XB_BX); } diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 0514b9aa..6099be9d 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -9,8 +9,106 @@ #include "BaseAlgo.hpp" #include "LSGrid.hpp" // needs to be included here because of the forward declaration +#include // std::isfinite +#include + namespace ls2g { +namespace { + +// bounds + duplicate / overlap check for one of slack_ids / pv / pq. +// `marks` holds, for each bus, 0 (unseen) or the 1-based index into +// `array_names` of the array that already claimed it. +void check_bus_id_array(const std::string & caller, + Eigen::Ref ids, + Eigen::Index n, + std::vector & marks, + int array_code, + const char * const array_names[]) +{ + const char * name = array_names[array_code - 1]; + for (Eigen::Index i = 0; i < ids.size(); ++i) { + const int id = ids(i); + if (id < 0 || id >= n) { + std::ostringstream exc_; + exc_ << caller << ": " << name << "[" << i << "] = " << id + << " is out of bounds: bus ids must be in [0, " << n + << ") (n = size of Ybus)."; + throw std::out_of_range(exc_.str()); + } + if (marks[static_cast(id)] != 0) { + const char * first = array_names[marks[static_cast(id)] - 1]; + std::ostringstream exc_; + exc_ << caller << ": bus id " << id << " appears both in " << first + << " and in " << name + << " (or twice in the same one): slack_ids, pv and pq must be disjoint."; + throw std::runtime_error(exc_.str()); + } + marks[static_cast(id)] = static_cast(array_code); + } +} + +} // anonymous namespace + +void BaseAlgo::check_pf_inputs(const std::string & caller, + Eigen::Index ybus_rows, Eigen::Index ybus_cols, + Eigen::Index v_size, Eigen::Index sbus_size, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq) +{ + if (ybus_rows != ybus_cols) { + std::ostringstream exc_; + exc_ << caller << ": the Ybus matrix must be square. Currently: (" + << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + const Eigen::Index n = ybus_rows; + if (sbus_size != n) { + std::ostringstream exc_; + exc_ << caller << ": Size of the Sbus (or Pbus) vector should be the same as the size of Ybus. Currently: " + << "Sbus (" << sbus_size << ") and Ybus (" << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + if (v_size != n) { + std::ostringstream exc_; + exc_ << caller << ": Size of V (init voltages) should be the same as the size of Ybus. Currently: " + << "V (" << v_size << ") and Ybus (" << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + if (slack_weights.size() != n) { + std::ostringstream exc_; + exc_ << caller << ": Size of slack_weights should be the same as the size of Ybus " + << "(one weight per bus, indexed by bus id). Currently: slack_weights (" + << slack_weights.size() << ") and Ybus (" << ybus_rows << ", " << ybus_cols << ")."; + throw std::runtime_error(exc_.str()); + } + if (slack_ids.size() == 0) { + std::ostringstream exc_; + exc_ << caller << ": at least one slack bus is required (slack_ids is empty)."; + throw std::runtime_error(exc_.str()); + } + static const char * const array_names[] = {"slack_ids", "pv", "pq"}; + std::vector marks(static_cast(n), 0); + check_bus_id_array(caller, slack_ids, n, marks, 1, array_names); + check_bus_id_array(caller, pv, n, marks, 2, array_names); + check_bus_id_array(caller, pq, n, marks, 3, array_names); +} + +void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_type tol) +{ + if (max_iter <= 0) { + std::ostringstream exc_; + exc_ << caller << ": max_iter must be a strictly positive integer, got " << max_iter << "."; + throw std::runtime_error(exc_.str()); + } + if (!std::isfinite(tol) || !(tol > 0.)) { + std::ostringstream exc_; + exc_ << caller << ": tol must be a finite, strictly positive number, got " << tol << "."; + throw std::runtime_error(exc_.str()); + } +} void BaseAlgo::reset(){ // reset timers diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 7a09b988..2a1e7891 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -86,6 +86,34 @@ class LS2G_API BaseAlgo : public BaseConstants lsgrid_ptr_ = gridmodel; } + // ---- input validation shared by every solver entry point ---- + // Called at the top of each compute_pf / compute_pf_dc override so + // ill-formed inputs (from python via Solver.solve / compute_pf, or + // from a buggy C++ caller) raise a clean exception instead of + // reaching raw Eigen indexing (whose bounds asserts are compiled out + // in release builds). One O(n) pass, negligible vs the solve itself. + // + // Throws std::runtime_error for structural problems: non-square + // Ybus, V / Sbus (or Pbus) / slack_weights not of size n (the + // slack weights are indexed *by bus id*, see eg SlackPolicies.tpp), + // no slack bus at all, or a bus listed twice / in more than one of + // slack_ids / pv / pq. + // Throws std::out_of_range (python IndexError) for any id of + // slack_ids / pv / pq outside [0, n). + // `caller` names the solver in the error message (eg "NRAlgo::compute_pf"). + static void check_pf_inputs(const std::string & caller, + Eigen::Index ybus_rows, Eigen::Index ybus_cols, + Eigen::Index v_size, Eigen::Index sbus_size, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq); + + // Throws std::runtime_error unless max_iter > 0 and tol is a finite + // strictly positive number (NaN and infinity are rejected). AC only: + // DC is a single linear solve without iterations / tolerance. + static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); + virtual Eigen::Ref > get_J() const { throw std::runtime_error("AlgorithmSelector::get_J: There is not Jacobian matrix for this solver type."); } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index a49fcc9b..1078d13f 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -22,6 +22,8 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, const CplxVect & Sbus, Eigen::Ref slack_ids, - const RealVect & /*slack_weights*/, // currently unused + const RealVect & slack_weights, // only validated, otherwise unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, real_type tol ) { + BaseAlgo::check_pf_inputs("GaussSeidelAlgo::compute_pf", Ybus.rows(), Ybus.cols(), + V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); + BaseAlgo::check_iter_tol("GaussSeidelAlgo::compute_pf", max_iter, tol); /** pv: id of the pv buses pq: id of the pq buses diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index 85174781..0400d4f9 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -18,18 +18,9 @@ bool NRAlgo::compute_pf( int max_iter, real_type tol) { - if (Sbus.size() != Ybus.rows() || Sbus.size() != Ybus.cols()) { - std::ostringstream exc_; - exc_ << "NRAlgo::compute_pf: Size of the Sbus should be the same as the size of Ybus. Currently: "; - exc_ << "Sbus (" << Sbus.size() << ") and Ybus (" << Ybus.rows() << ", " << Ybus.cols() << ")."; - throw std::runtime_error(exc_.str()); - } - if (V.size() != Ybus.rows() || V.size() != Ybus.cols()) { - std::ostringstream exc_; - exc_ << "NRAlgo::compute_pf: Size of V (init voltages) should be the same as the size of Ybus. Currently: "; - exc_ << "V (" << V.size() << ") and Ybus (" << Ybus.rows() << ", " << Ybus.cols() << ")."; - throw std::runtime_error(exc_.str()); - } + BaseAlgo::check_pf_inputs("NRAlgo::compute_pf", Ybus.rows(), Ybus.cols(), + V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); + BaseAlgo::check_iter_tol("NRAlgo::compute_pf", max_iter, tol); if (!is_linear_solver_valid()) return false; reset_timer(); From 705caf6238b1194586ec7aba7c941b2a48be2775 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:33:12 +0000 Subject: [PATCH 079/166] Move powerflow input validation off the internal hot path check_pf_inputs/check_iter_tol were called unconditionally at the top of every solver's compute_pf/compute_pf_dc override. Those are the same entry points LSGrid::ac_pf/dc_pf and BaseBatchSolverSynch (the engine behind ContingencyAnalysis, TimeSerie and the security-analysis classes) call internally, once per contingency / per timestep -- so the O(n) bounds/disjointness check (a vector(n) allocation) was repeated on every one of those calls, even though the inputs there are built by LSGrid itself and don't need re-validating. compute_pf / compute_pf_dc go back to being unchecked (their original, documented contract -- DocSolver::compute_pf already warned that bad input "might result in crash of the python virtual machine"). A new non-virtual BaseAlgo::compute_pf_with_input_validation (and its DC counterpart) validates then dispatches to the virtual compute_pf; the python-exposed names are unchanged (still "compute_pf" and "solve"), only the C++ function they're bound to changes, so this only affects callers going through pybind11 -- the C++-internal callers keep using the raw, unchecked entry point and see no behavior or performance change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 20 +++++--- src/bindings/python/binding_solvers.cpp | 10 +++- src/core/help_fun_msg.cpp | 18 ++++--- src/core/powerflow_algorithm/BaseAlgo.cpp | 31 ++++++++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 50 ++++++++++++++++--- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 3 -- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 3 -- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 5 +- src/core/powerflow_algorithm/NRAlgo.tpp | 3 -- 9 files changed, 109 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index debe08ec..508345a2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -366,19 +366,25 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. write and ~8x faster to read than pickle on grids with ~9000 buses). - [IMPROVED] robustness of the powerflow entry points against ill-formed input: `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (> 0) and `tol` - (finite and > 0) in addition to the size of `Vinit`, and every solver's - `compute_pf` / `solve` (Newton-Raphson, single-slack, fast-decoupled, - Gauss-Seidel, DC -- shared `BaseAlgo::check_pf_inputs`) now validates its - inputs before touching them: non-square `Ybus`, `V` / `Sbus` / + (finite and > 0) in addition to the size of `Vinit`, and the python-facing + `Solver.compute_pf` / `Solver.solve` (Newton-Raphson, single-slack, + fast-decoupled, Gauss-Seidel -- shared `BaseAlgo::check_pf_inputs`, bound + via the new `BaseAlgo::compute_pf_with_input_validation`) now validate + their inputs before touching them: non-square `Ybus`, `V` / `Sbus` / `slack_weights` not matching the size of `Ybus`, empty `slack_ids` and non-positive / non-finite `max_iter` / `tol` raise `RuntimeError`; out-of-range or negative bus ids in `pv` / `pq` / `slack_ids` raise `IndexError` (previously they reached raw Eigen indexing: out-of-bounds reads/writes in Release builds); a bus listed in more than one of slack/pv/pq (or twice in the same one) raises `RuntimeError` instead of - silently producing a wrong system. Tested by the new - `lightsim2grid/tests/test_pf_input_robustness.py` for every solver class - available in the build. + silently producing a wrong system. This validation is a python-boundary + concern only: the internal C++ solve path used by `LSGrid` and the batch + solvers (`ContingencyAnalysis`, `TimeSerie`, security analysis) still calls + the raw, unchecked `compute_pf` / `compute_pf_dc` in its hot loop, since + those callers build `Ybus` / `Sbus` / `pv` / `pq` themselves and paying an + O(n) validation pass on every contingency / timestep would be pure + overhead. Tested by the new `lightsim2grid/tests/test_pf_input_robustness.py` + for every solver class available in the build. - [IMPROVED] robustness of `save_binary` / `load_binary` against ill-formed input: - version compatibility is now decided by a dedicated **binary format version** diff --git a/src/bindings/python/binding_solvers.cpp b/src/bindings/python/binding_solvers.cpp index cff6c8bb..9715148e 100644 --- a/src/bindings/python/binding_solvers.cpp +++ b/src/bindings/python/binding_solvers.cpp @@ -28,9 +28,15 @@ void bind_algo_methods(py::class_& cls) { .def("get_nb_iter", &Solver::get_nb_iter, DocSolver::get_nb_iter.c_str()) .def("reset", &Solver::reset, DocSolver::reset.c_str()) .def("converged", &Solver::converged, DocSolver::converged.c_str()) - .def("compute_pf", &Solver::compute_pf, py::call_guard(), DocSolver::compute_pf.c_str()) + // python-facing compute_pf / solve are bound to the *checked* wrapper + // (BaseAlgo::compute_pf_with_input_validation), not the raw virtual + // compute_pf: the raw one is the hot, unchecked path used internally + // by LSGrid / BaseBatchSolverSynch (ContingencyAnalysis, TimeSeries, + // SecurityAnalysis) in tight loops, which never goes through pybind11 + // and so is unaffected by this binding. + .def("compute_pf", &Solver::compute_pf_with_input_validation, py::call_guard(), DocSolver::compute_pf.c_str()) .def("get_timers", &Solver::get_timers, DocSolver::get_timers.c_str()) - .def("solve", &Solver::compute_pf, py::call_guard(), DocSolver::compute_pf.c_str()) + .def("solve", &Solver::compute_pf_with_input_validation, py::call_guard(), DocSolver::compute_pf.c_str()) ; } diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index 9eb4f09b..6ad3c8d2 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -93,12 +93,18 @@ const std::string DocSolver::compute_pf = R"mydelimiter( see section :ref:`available-powerflow-solvers` for more information about these. - .. warning:: - There are strong assumption made on the validity of the parameters provided. Please have a look at the section :ref:`available-powerflow-solvers` - for more details about this. - - If a non compliant state is provided, it might result in crash of the python virtual machine (session terminates with error like - `segfault`) and there is absolutely no way to do anything and any data not saved on the hard drive will be lost. + .. note:: + This python-facing method (also available as ``solve``) validates its inputs before doing + anything else: a non-square ``Ybus``, a size mismatch between ``Ybus`` / ``V`` / ``Sbus`` / + ``slack_weights``, an out-of-range id in ``slack_ids`` / ``pv`` / ``pq``, a bus listed in more + than one of them, an empty ``slack_ids``, or a non-positive ``max_iter`` / non-finite or + non-positive ``tol`` all raise a clean ``RuntimeError`` (or ``IndexError`` for out-of-range + ids) instead of touching the underlying solver. This validation is skipped on the internal + C++ code path used by :class:`lightsim2grid.gridmodel.LSGrid` and the batch solvers + (:class:`~lightsim2grid.contingencyAnalysis.ContingencyAnalysis`, + :class:`~lightsim2grid.timeSerie.TimeSerie`, security analysis), which build these arrays + themselves and call the solver many times in a loop: paying this check on every call there + would be pure overhead, so it is only performed at this python entry point. Parameters ------------ diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 6099be9d..1ba01f79 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -110,6 +110,37 @@ void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_typ } } +bool BaseAlgo::compute_pf_with_input_validation( + const Eigen::SparseMatrix & Ybus, + CplxVect & V, + const CplxVect & Sbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq, + int max_iter, + real_type tol) +{ + check_pf_inputs("compute_pf", Ybus.rows(), Ybus.cols(), V.size(), Sbus.size(), + slack_ids, slack_weights, pv, pq); + check_iter_tol("compute_pf", max_iter, tol); + return compute_pf(Ybus, V, Sbus, slack_ids, slack_weights, pv, pq, max_iter, tol); +} + +bool BaseAlgo::compute_pf_dc_with_input_validation( + const Eigen::SparseMatrix & Bbus, + CplxVect & V, + const RealVect & Pbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq) +{ + check_pf_inputs("compute_pf_dc", Bbus.rows(), Bbus.cols(), V.size(), Pbus.size(), + slack_ids, slack_weights, pv, pq); + return compute_pf_dc(Bbus, V, Pbus, slack_ids, slack_weights, pv, pq); +} + void BaseAlgo::reset(){ // reset timers reset_timer(); diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 2a1e7891..5d82aba7 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -86,12 +86,18 @@ class LS2G_API BaseAlgo : public BaseConstants lsgrid_ptr_ = gridmodel; } - // ---- input validation shared by every solver entry point ---- - // Called at the top of each compute_pf / compute_pf_dc override so - // ill-formed inputs (from python via Solver.solve / compute_pf, or - // from a buggy C++ caller) raise a clean exception instead of - // reaching raw Eigen indexing (whose bounds asserts are compiled out - // in release builds). One O(n) pass, negligible vs the solve itself. + // ---- input validation, used ONLY by the *_with_input_validation + // wrappers below (never by compute_pf / compute_pf_dc themselves) ---- + // compute_pf / compute_pf_dc are called, unchecked, on every hot + // C++-internal path (LSGrid::ac_pf/dc_pf, and BaseBatchSolverSynch -- + // ie every contingency in ContingencyAnalysis, every timestep of + // TimeSeries / SecurityAnalysis): those callers build Ybus / Sbus / + // pv / pq / slack_ids themselves and can be assumed correct, so + // paying an O(n) validation pass on each of those calls would be + // pure overhead. The check is instead done once, at the actual + // trust boundary: python calling a solver directly (see + // compute_pf_with_input_validation below, bound as both + // Solver.compute_pf and Solver.solve in binding_solvers.cpp). // // Throws std::runtime_error for structural problems: non-square // Ybus, V / Sbus (or Pbus) / slack_weights not of size n (the @@ -114,6 +120,38 @@ class LS2G_API BaseAlgo : public BaseConstants // DC is a single linear solve without iterations / tolerance. static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); + // Checked wrapper around the (virtual) compute_pf: validates with + // check_pf_inputs + check_iter_tol, then dispatches to compute_pf on + // whichever concrete solver `this` is. Not virtual: one shared + // implementation (BaseAlgo.cpp) is enough since it only needs to + // validate and then call the already-virtual compute_pf. This is + // the method actually bound to python's Solver.compute_pf / .solve + // (see binding_solvers.cpp) -- the raw compute_pf keeps its name and + // stays reachable only from C++. + bool compute_pf_with_input_validation( + const Eigen::SparseMatrix & Ybus, + CplxVect & V, + const CplxVect & Sbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq, + int max_iter, + real_type tol); + + // Same idea for the DC entry point (validates, then calls the + // virtual compute_pf_dc). Not bound to python today -- compute_pf_dc + // itself isn't exposed under any name -- kept symmetric with the AC + // wrapper above and ready if that binding gap is ever closed. + bool compute_pf_dc_with_input_validation( + const Eigen::SparseMatrix & Bbus, + CplxVect & V, + const RealVect & Pbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq); + virtual Eigen::Ref > get_J() const { throw std::runtime_error("AlgorithmSelector::get_J: There is not Jacobian matrix for this solver type."); } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 1078d13f..96973060 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -22,9 +22,6 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, const CplxVect & Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, // only validated, otherwise unused + const RealVect & /*slack_weights*/, // currently unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, real_type tol ) { - BaseAlgo::check_pf_inputs("GaussSeidelAlgo::compute_pf", Ybus.rows(), Ybus.cols(), - V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); - BaseAlgo::check_iter_tol("GaussSeidelAlgo::compute_pf", max_iter, tol); /** pv: id of the pv buses pq: id of the pq buses diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index 0400d4f9..94468d75 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -18,9 +18,6 @@ bool NRAlgo::compute_pf( int max_iter, real_type tol) { - BaseAlgo::check_pf_inputs("NRAlgo::compute_pf", Ybus.rows(), Ybus.cols(), - V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); - BaseAlgo::check_iter_tol("NRAlgo::compute_pf", max_iter, tol); if (!is_linear_solver_valid()) return false; reset_timer(); From e6a0161cd5917b3583078bfc127de8b52b970c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:33:12 +0000 Subject: [PATCH 080/166] Move powerflow input validation off the internal hot path check_pf_inputs/check_iter_tol were called unconditionally at the top of every solver's compute_pf/compute_pf_dc override. Those are the same entry points LSGrid::ac_pf/dc_pf and BaseBatchSolverSynch (the engine behind ContingencyAnalysis, TimeSerie and the security-analysis classes) call internally, once per contingency / per timestep -- so the O(n) bounds/disjointness check (a vector(n) allocation) was repeated on every one of those calls, even though the inputs there are built by LSGrid itself and don't need re-validating. compute_pf / compute_pf_dc go back to being unchecked (their original, documented contract -- DocSolver::compute_pf already warned that bad input "might result in crash of the python virtual machine"). A new non-virtual BaseAlgo::compute_pf_with_input_validation (and its DC counterpart) validates then dispatches to the virtual compute_pf; the python-exposed names are unchanged (still "compute_pf" and "solve"), only the C++ function they're bound to changes, so this only affects callers going through pybind11 -- the C++-internal callers keep using the raw, unchecked entry point and see no behavior or performance change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 20 +++++--- src/bindings/python/binding_solvers.cpp | 10 +++- src/core/help_fun_msg.cpp | 18 ++++--- src/core/powerflow_algorithm/BaseAlgo.cpp | 31 ++++++++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 50 ++++++++++++++++--- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 3 -- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 3 -- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 5 +- src/core/powerflow_algorithm/NRAlgo.tpp | 3 -- 9 files changed, 109 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index debe08ec..508345a2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -366,19 +366,25 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. write and ~8x faster to read than pickle on grids with ~9000 buses). - [IMPROVED] robustness of the powerflow entry points against ill-formed input: `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (> 0) and `tol` - (finite and > 0) in addition to the size of `Vinit`, and every solver's - `compute_pf` / `solve` (Newton-Raphson, single-slack, fast-decoupled, - Gauss-Seidel, DC -- shared `BaseAlgo::check_pf_inputs`) now validates its - inputs before touching them: non-square `Ybus`, `V` / `Sbus` / + (finite and > 0) in addition to the size of `Vinit`, and the python-facing + `Solver.compute_pf` / `Solver.solve` (Newton-Raphson, single-slack, + fast-decoupled, Gauss-Seidel -- shared `BaseAlgo::check_pf_inputs`, bound + via the new `BaseAlgo::compute_pf_with_input_validation`) now validate + their inputs before touching them: non-square `Ybus`, `V` / `Sbus` / `slack_weights` not matching the size of `Ybus`, empty `slack_ids` and non-positive / non-finite `max_iter` / `tol` raise `RuntimeError`; out-of-range or negative bus ids in `pv` / `pq` / `slack_ids` raise `IndexError` (previously they reached raw Eigen indexing: out-of-bounds reads/writes in Release builds); a bus listed in more than one of slack/pv/pq (or twice in the same one) raises `RuntimeError` instead of - silently producing a wrong system. Tested by the new - `lightsim2grid/tests/test_pf_input_robustness.py` for every solver class - available in the build. + silently producing a wrong system. This validation is a python-boundary + concern only: the internal C++ solve path used by `LSGrid` and the batch + solvers (`ContingencyAnalysis`, `TimeSerie`, security analysis) still calls + the raw, unchecked `compute_pf` / `compute_pf_dc` in its hot loop, since + those callers build `Ybus` / `Sbus` / `pv` / `pq` themselves and paying an + O(n) validation pass on every contingency / timestep would be pure + overhead. Tested by the new `lightsim2grid/tests/test_pf_input_robustness.py` + for every solver class available in the build. - [IMPROVED] robustness of `save_binary` / `load_binary` against ill-formed input: - version compatibility is now decided by a dedicated **binary format version** diff --git a/src/bindings/python/binding_solvers.cpp b/src/bindings/python/binding_solvers.cpp index cff6c8bb..9715148e 100644 --- a/src/bindings/python/binding_solvers.cpp +++ b/src/bindings/python/binding_solvers.cpp @@ -28,9 +28,15 @@ void bind_algo_methods(py::class_& cls) { .def("get_nb_iter", &Solver::get_nb_iter, DocSolver::get_nb_iter.c_str()) .def("reset", &Solver::reset, DocSolver::reset.c_str()) .def("converged", &Solver::converged, DocSolver::converged.c_str()) - .def("compute_pf", &Solver::compute_pf, py::call_guard(), DocSolver::compute_pf.c_str()) + // python-facing compute_pf / solve are bound to the *checked* wrapper + // (BaseAlgo::compute_pf_with_input_validation), not the raw virtual + // compute_pf: the raw one is the hot, unchecked path used internally + // by LSGrid / BaseBatchSolverSynch (ContingencyAnalysis, TimeSeries, + // SecurityAnalysis) in tight loops, which never goes through pybind11 + // and so is unaffected by this binding. + .def("compute_pf", &Solver::compute_pf_with_input_validation, py::call_guard(), DocSolver::compute_pf.c_str()) .def("get_timers", &Solver::get_timers, DocSolver::get_timers.c_str()) - .def("solve", &Solver::compute_pf, py::call_guard(), DocSolver::compute_pf.c_str()) + .def("solve", &Solver::compute_pf_with_input_validation, py::call_guard(), DocSolver::compute_pf.c_str()) ; } diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index 9eb4f09b..6ad3c8d2 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -93,12 +93,18 @@ const std::string DocSolver::compute_pf = R"mydelimiter( see section :ref:`available-powerflow-solvers` for more information about these. - .. warning:: - There are strong assumption made on the validity of the parameters provided. Please have a look at the section :ref:`available-powerflow-solvers` - for more details about this. - - If a non compliant state is provided, it might result in crash of the python virtual machine (session terminates with error like - `segfault`) and there is absolutely no way to do anything and any data not saved on the hard drive will be lost. + .. note:: + This python-facing method (also available as ``solve``) validates its inputs before doing + anything else: a non-square ``Ybus``, a size mismatch between ``Ybus`` / ``V`` / ``Sbus`` / + ``slack_weights``, an out-of-range id in ``slack_ids`` / ``pv`` / ``pq``, a bus listed in more + than one of them, an empty ``slack_ids``, or a non-positive ``max_iter`` / non-finite or + non-positive ``tol`` all raise a clean ``RuntimeError`` (or ``IndexError`` for out-of-range + ids) instead of touching the underlying solver. This validation is skipped on the internal + C++ code path used by :class:`lightsim2grid.gridmodel.LSGrid` and the batch solvers + (:class:`~lightsim2grid.contingencyAnalysis.ContingencyAnalysis`, + :class:`~lightsim2grid.timeSerie.TimeSerie`, security analysis), which build these arrays + themselves and call the solver many times in a loop: paying this check on every call there + would be pure overhead, so it is only performed at this python entry point. Parameters ------------ diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 6099be9d..1ba01f79 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -110,6 +110,37 @@ void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_typ } } +bool BaseAlgo::compute_pf_with_input_validation( + const Eigen::SparseMatrix & Ybus, + CplxVect & V, + const CplxVect & Sbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq, + int max_iter, + real_type tol) +{ + check_pf_inputs("compute_pf", Ybus.rows(), Ybus.cols(), V.size(), Sbus.size(), + slack_ids, slack_weights, pv, pq); + check_iter_tol("compute_pf", max_iter, tol); + return compute_pf(Ybus, V, Sbus, slack_ids, slack_weights, pv, pq, max_iter, tol); +} + +bool BaseAlgo::compute_pf_dc_with_input_validation( + const Eigen::SparseMatrix & Bbus, + CplxVect & V, + const RealVect & Pbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq) +{ + check_pf_inputs("compute_pf_dc", Bbus.rows(), Bbus.cols(), V.size(), Pbus.size(), + slack_ids, slack_weights, pv, pq); + return compute_pf_dc(Bbus, V, Pbus, slack_ids, slack_weights, pv, pq); +} + void BaseAlgo::reset(){ // reset timers reset_timer(); diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 2a1e7891..5d82aba7 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -86,12 +86,18 @@ class LS2G_API BaseAlgo : public BaseConstants lsgrid_ptr_ = gridmodel; } - // ---- input validation shared by every solver entry point ---- - // Called at the top of each compute_pf / compute_pf_dc override so - // ill-formed inputs (from python via Solver.solve / compute_pf, or - // from a buggy C++ caller) raise a clean exception instead of - // reaching raw Eigen indexing (whose bounds asserts are compiled out - // in release builds). One O(n) pass, negligible vs the solve itself. + // ---- input validation, used ONLY by the *_with_input_validation + // wrappers below (never by compute_pf / compute_pf_dc themselves) ---- + // compute_pf / compute_pf_dc are called, unchecked, on every hot + // C++-internal path (LSGrid::ac_pf/dc_pf, and BaseBatchSolverSynch -- + // ie every contingency in ContingencyAnalysis, every timestep of + // TimeSeries / SecurityAnalysis): those callers build Ybus / Sbus / + // pv / pq / slack_ids themselves and can be assumed correct, so + // paying an O(n) validation pass on each of those calls would be + // pure overhead. The check is instead done once, at the actual + // trust boundary: python calling a solver directly (see + // compute_pf_with_input_validation below, bound as both + // Solver.compute_pf and Solver.solve in binding_solvers.cpp). // // Throws std::runtime_error for structural problems: non-square // Ybus, V / Sbus (or Pbus) / slack_weights not of size n (the @@ -114,6 +120,38 @@ class LS2G_API BaseAlgo : public BaseConstants // DC is a single linear solve without iterations / tolerance. static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); + // Checked wrapper around the (virtual) compute_pf: validates with + // check_pf_inputs + check_iter_tol, then dispatches to compute_pf on + // whichever concrete solver `this` is. Not virtual: one shared + // implementation (BaseAlgo.cpp) is enough since it only needs to + // validate and then call the already-virtual compute_pf. This is + // the method actually bound to python's Solver.compute_pf / .solve + // (see binding_solvers.cpp) -- the raw compute_pf keeps its name and + // stays reachable only from C++. + bool compute_pf_with_input_validation( + const Eigen::SparseMatrix & Ybus, + CplxVect & V, + const CplxVect & Sbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq, + int max_iter, + real_type tol); + + // Same idea for the DC entry point (validates, then calls the + // virtual compute_pf_dc). Not bound to python today -- compute_pf_dc + // itself isn't exposed under any name -- kept symmetric with the AC + // wrapper above and ready if that binding gap is ever closed. + bool compute_pf_dc_with_input_validation( + const Eigen::SparseMatrix & Bbus, + CplxVect & V, + const RealVect & Pbus, + Eigen::Ref slack_ids, + const RealVect & slack_weights, + Eigen::Ref pv, + Eigen::Ref pq); + virtual Eigen::Ref > get_J() const { throw std::runtime_error("AlgorithmSelector::get_J: There is not Jacobian matrix for this solver type."); } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 1078d13f..96973060 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -22,9 +22,6 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, const CplxVect & Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, // only validated, otherwise unused + const RealVect & /*slack_weights*/, // currently unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, real_type tol ) { - BaseAlgo::check_pf_inputs("GaussSeidelAlgo::compute_pf", Ybus.rows(), Ybus.cols(), - V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); - BaseAlgo::check_iter_tol("GaussSeidelAlgo::compute_pf", max_iter, tol); /** pv: id of the pv buses pq: id of the pq buses diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index 0400d4f9..94468d75 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -18,9 +18,6 @@ bool NRAlgo::compute_pf( int max_iter, real_type tol) { - BaseAlgo::check_pf_inputs("NRAlgo::compute_pf", Ybus.rows(), Ybus.cols(), - V.size(), Sbus.size(), slack_ids, slack_weights, pv, pq); - BaseAlgo::check_iter_tol("NRAlgo::compute_pf", max_iter, tol); if (!is_linear_solver_valid()) return false; reset_timer(); From 538f2f12865f98a65473ceaec3932a640fd59e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 18:04:21 +0000 Subject: [PATCH 081/166] Allow max_iter=0 in check_iter_tol (fixes test_jacobian.py CI failures) max_iter=0 is not ill-formed input: every solver builds its initial state / first Jacobian before the NR/GS iteration loop even starts, so max_iter=0 is the well-defined "return the pre-iteration state, take no steps" call. test_jacobian.py relies on exactly this (fixture-backed tests checking J after 0, 1, 2, ... NR iterations) and predates this PR; it broke once python's compute_pf started validating, since check_iter_tol rejected 0 outright. Only a negative max_iter is actually nonsensical, so that's now the only value rejected. Updates the two bad-max_iter test lists in test_pf_input_robustness.py accordingly (drop 0, keep negative values) and adds test_zero_max_iter_is_valid to pin the accepted case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 9 ++++++--- .../tests/test_pf_input_robustness.py | 19 +++++++++++++++---- src/core/help_fun_msg.cpp | 3 ++- src/core/powerflow_algorithm/BaseAlgo.cpp | 4 ++-- src/core/powerflow_algorithm/BaseAlgo.hpp | 7 ++++++- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 508345a2..156f9a91 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -365,14 +365,17 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). - [IMPROVED] robustness of the powerflow entry points against ill-formed input: - `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (> 0) and `tol` + `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (>= 0) and `tol` (finite and > 0) in addition to the size of `Vinit`, and the python-facing `Solver.compute_pf` / `Solver.solve` (Newton-Raphson, single-slack, fast-decoupled, Gauss-Seidel -- shared `BaseAlgo::check_pf_inputs`, bound via the new `BaseAlgo::compute_pf_with_input_validation`) now validate their inputs before touching them: non-square `Ybus`, `V` / `Sbus` / - `slack_weights` not matching the size of `Ybus`, empty `slack_ids` and - non-positive / non-finite `max_iter` / `tol` raise `RuntimeError`; + `slack_weights` not matching the size of `Ybus`, empty `slack_ids`, + a negative `max_iter` and a non-positive / non-finite `tol` raise + `RuntimeError` (`max_iter=0` is accepted: every solver builds its initial + state / first Jacobian before iterating, so it is a well-defined "return + the pre-iteration state" call, used by internal tests); out-of-range or negative bus ids in `pv` / `pq` / `slack_ids` raise `IndexError` (previously they reached raw Eigen indexing: out-of-bounds reads/writes in Release builds); a bus listed in more than one of diff --git a/lightsim2grid/tests/test_pf_input_robustness.py b/lightsim2grid/tests/test_pf_input_robustness.py index f8a4cccd..e9742ed5 100644 --- a/lightsim2grid/tests/test_pf_input_robustness.py +++ b/lightsim2grid/tests/test_pf_input_robustness.py @@ -17,8 +17,9 @@ Conventions (mirroring test_LSGrid_out_of_bounds.py): * ``RuntimeError`` for structural problems: mis-sized V / Sbus / slack_weights, non-square Ybus, empty slack_ids, a bus listed in more - than one of slack/pv/pq, non-positive max_iter, non-positive or - non-finite tol (``BaseAlgo::check_pf_inputs`` / ``check_iter_tol`` throw + than one of slack/pv/pq, negative max_iter (0 is valid: it returns the + pre-iteration state), non-positive or non-finite tol + (``BaseAlgo::check_pf_inputs`` / ``check_iter_tol`` throw ``std::runtime_error``); * ``IndexError`` for out-of-range / negative bus ids in slack_ids / pv / pq (``std::out_of_range``); @@ -86,12 +87,20 @@ def test_wrong_v_init_size(self): pf(v, MAX_IT, TOL) def test_bad_max_iter(self): + # max_iter=0 is legitimate (returns the pre-iteration state); only + # negative values are rejected for name, pf in self._pf_methods(): - for bad_iter in (0, -1, -100): + for bad_iter in (-1, -100): with self.subTest(f"{name}, max_iter={bad_iter}"): with self.assertRaises(RuntimeError): pf(1.0 * self.v_init, bad_iter, TOL) + def test_zero_max_iter_is_valid(self): + # 0 is the well-defined "no iterations, just the initial state" call + for name, pf in self._pf_methods(): + with self.subTest(name): + pf(1.0 * self.v_init, 0, TOL) # must not raise + def test_bad_tol(self): for name, pf in self._pf_methods(): for bad_tol in (0., -1e-8, float("nan"), float("inf"), -float("inf")): @@ -233,7 +242,9 @@ def test_slack_also_pv(self): RuntimeError, "slack bus also in pv", pv=also_pv) def test_bad_max_iter(self): - for bad_iter in (0, -1): + # max_iter=0 is legitimate (returns the pre-iteration state); only + # negative values are rejected + for bad_iter in (-1, -100): self._assert_all_solvers_raise( RuntimeError, f"max_iter={bad_iter}", max_iter=bad_iter) diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index 6ad3c8d2..ca8bd465 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -97,7 +97,8 @@ const std::string DocSolver::compute_pf = R"mydelimiter( This python-facing method (also available as ``solve``) validates its inputs before doing anything else: a non-square ``Ybus``, a size mismatch between ``Ybus`` / ``V`` / ``Sbus`` / ``slack_weights``, an out-of-range id in ``slack_ids`` / ``pv`` / ``pq``, a bus listed in more - than one of them, an empty ``slack_ids``, or a non-positive ``max_iter`` / non-finite or + than one of them, an empty ``slack_ids``, a negative ``max_iter`` (0 is accepted: it returns + the pre-iteration state, before any Newton-Raphson / Gauss-Seidel step), or a non-finite or non-positive ``tol`` all raise a clean ``RuntimeError`` (or ``IndexError`` for out-of-range ids) instead of touching the underlying solver. This validation is skipped on the internal C++ code path used by :class:`lightsim2grid.gridmodel.LSGrid` and the batch solvers diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 1ba01f79..9485821c 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -98,9 +98,9 @@ void BaseAlgo::check_pf_inputs(const std::string & caller, void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_type tol) { - if (max_iter <= 0) { + if (max_iter < 0) { std::ostringstream exc_; - exc_ << caller << ": max_iter must be a strictly positive integer, got " << max_iter << "."; + exc_ << caller << ": max_iter must be a non-negative integer, got " << max_iter << "."; throw std::runtime_error(exc_.str()); } if (!std::isfinite(tol) || !(tol > 0.)) { diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 5d82aba7..4222b32e 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -115,7 +115,12 @@ class LS2G_API BaseAlgo : public BaseConstants Eigen::Ref pv, Eigen::Ref pq); - // Throws std::runtime_error unless max_iter > 0 and tol is a finite + // Throws std::runtime_error unless max_iter >= 0 (0 is a legitimate, + // well-defined call: every solver builds its initial state / first + // Jacobian before the iteration loop, so max_iter=0 deliberately + // returns that pre-iteration state without taking any NR/GS step -- + // used eg by test_jacobian.py to check J iteration-by-iteration; only + // negative values are nonsensical) and tol is a finite // strictly positive number (NaN and infinity are rejected). AC only: // DC is a single linear solve without iterations / tolerance. static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); From b46eaf281a5316dbad01ab6a9acbadbae9e03411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 18:04:21 +0000 Subject: [PATCH 082/166] Allow max_iter=0 in check_iter_tol (fixes test_jacobian.py CI failures) max_iter=0 is not ill-formed input: every solver builds its initial state / first Jacobian before the NR/GS iteration loop even starts, so max_iter=0 is the well-defined "return the pre-iteration state, take no steps" call. test_jacobian.py relies on exactly this (fixture-backed tests checking J after 0, 1, 2, ... NR iterations) and predates this PR; it broke once python's compute_pf started validating, since check_iter_tol rejected 0 outright. Only a negative max_iter is actually nonsensical, so that's now the only value rejected. Updates the two bad-max_iter test lists in test_pf_input_robustness.py accordingly (drop 0, keep negative values) and adds test_zero_max_iter_is_valid to pin the accepted case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XjPTtnmQ7Yrf6jcEyfhqSN Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 9 ++++++--- .../tests/test_pf_input_robustness.py | 19 +++++++++++++++---- src/core/help_fun_msg.cpp | 3 ++- src/core/powerflow_algorithm/BaseAlgo.cpp | 4 ++-- src/core/powerflow_algorithm/BaseAlgo.hpp | 7 ++++++- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 508345a2..156f9a91 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -365,14 +365,17 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. comparisons against pickle (the speed up grows with grid size: up to ~17x faster to write and ~8x faster to read than pickle on grids with ~9000 buses). - [IMPROVED] robustness of the powerflow entry points against ill-formed input: - `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (> 0) and `tol` + `LSGrid.ac_pf` / `LSGrid.dc_pf` now validate `max_iter` (>= 0) and `tol` (finite and > 0) in addition to the size of `Vinit`, and the python-facing `Solver.compute_pf` / `Solver.solve` (Newton-Raphson, single-slack, fast-decoupled, Gauss-Seidel -- shared `BaseAlgo::check_pf_inputs`, bound via the new `BaseAlgo::compute_pf_with_input_validation`) now validate their inputs before touching them: non-square `Ybus`, `V` / `Sbus` / - `slack_weights` not matching the size of `Ybus`, empty `slack_ids` and - non-positive / non-finite `max_iter` / `tol` raise `RuntimeError`; + `slack_weights` not matching the size of `Ybus`, empty `slack_ids`, + a negative `max_iter` and a non-positive / non-finite `tol` raise + `RuntimeError` (`max_iter=0` is accepted: every solver builds its initial + state / first Jacobian before iterating, so it is a well-defined "return + the pre-iteration state" call, used by internal tests); out-of-range or negative bus ids in `pv` / `pq` / `slack_ids` raise `IndexError` (previously they reached raw Eigen indexing: out-of-bounds reads/writes in Release builds); a bus listed in more than one of diff --git a/lightsim2grid/tests/test_pf_input_robustness.py b/lightsim2grid/tests/test_pf_input_robustness.py index f8a4cccd..e9742ed5 100644 --- a/lightsim2grid/tests/test_pf_input_robustness.py +++ b/lightsim2grid/tests/test_pf_input_robustness.py @@ -17,8 +17,9 @@ Conventions (mirroring test_LSGrid_out_of_bounds.py): * ``RuntimeError`` for structural problems: mis-sized V / Sbus / slack_weights, non-square Ybus, empty slack_ids, a bus listed in more - than one of slack/pv/pq, non-positive max_iter, non-positive or - non-finite tol (``BaseAlgo::check_pf_inputs`` / ``check_iter_tol`` throw + than one of slack/pv/pq, negative max_iter (0 is valid: it returns the + pre-iteration state), non-positive or non-finite tol + (``BaseAlgo::check_pf_inputs`` / ``check_iter_tol`` throw ``std::runtime_error``); * ``IndexError`` for out-of-range / negative bus ids in slack_ids / pv / pq (``std::out_of_range``); @@ -86,12 +87,20 @@ def test_wrong_v_init_size(self): pf(v, MAX_IT, TOL) def test_bad_max_iter(self): + # max_iter=0 is legitimate (returns the pre-iteration state); only + # negative values are rejected for name, pf in self._pf_methods(): - for bad_iter in (0, -1, -100): + for bad_iter in (-1, -100): with self.subTest(f"{name}, max_iter={bad_iter}"): with self.assertRaises(RuntimeError): pf(1.0 * self.v_init, bad_iter, TOL) + def test_zero_max_iter_is_valid(self): + # 0 is the well-defined "no iterations, just the initial state" call + for name, pf in self._pf_methods(): + with self.subTest(name): + pf(1.0 * self.v_init, 0, TOL) # must not raise + def test_bad_tol(self): for name, pf in self._pf_methods(): for bad_tol in (0., -1e-8, float("nan"), float("inf"), -float("inf")): @@ -233,7 +242,9 @@ def test_slack_also_pv(self): RuntimeError, "slack bus also in pv", pv=also_pv) def test_bad_max_iter(self): - for bad_iter in (0, -1): + # max_iter=0 is legitimate (returns the pre-iteration state); only + # negative values are rejected + for bad_iter in (-1, -100): self._assert_all_solvers_raise( RuntimeError, f"max_iter={bad_iter}", max_iter=bad_iter) diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index 6ad3c8d2..ca8bd465 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -97,7 +97,8 @@ const std::string DocSolver::compute_pf = R"mydelimiter( This python-facing method (also available as ``solve``) validates its inputs before doing anything else: a non-square ``Ybus``, a size mismatch between ``Ybus`` / ``V`` / ``Sbus`` / ``slack_weights``, an out-of-range id in ``slack_ids`` / ``pv`` / ``pq``, a bus listed in more - than one of them, an empty ``slack_ids``, or a non-positive ``max_iter`` / non-finite or + than one of them, an empty ``slack_ids``, a negative ``max_iter`` (0 is accepted: it returns + the pre-iteration state, before any Newton-Raphson / Gauss-Seidel step), or a non-finite or non-positive ``tol`` all raise a clean ``RuntimeError`` (or ``IndexError`` for out-of-range ids) instead of touching the underlying solver. This validation is skipped on the internal C++ code path used by :class:`lightsim2grid.gridmodel.LSGrid` and the batch solvers diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 1ba01f79..9485821c 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -98,9 +98,9 @@ void BaseAlgo::check_pf_inputs(const std::string & caller, void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_type tol) { - if (max_iter <= 0) { + if (max_iter < 0) { std::ostringstream exc_; - exc_ << caller << ": max_iter must be a strictly positive integer, got " << max_iter << "."; + exc_ << caller << ": max_iter must be a non-negative integer, got " << max_iter << "."; throw std::runtime_error(exc_.str()); } if (!std::isfinite(tol) || !(tol > 0.)) { diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 5d82aba7..4222b32e 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -115,7 +115,12 @@ class LS2G_API BaseAlgo : public BaseConstants Eigen::Ref pv, Eigen::Ref pq); - // Throws std::runtime_error unless max_iter > 0 and tol is a finite + // Throws std::runtime_error unless max_iter >= 0 (0 is a legitimate, + // well-defined call: every solver builds its initial state / first + // Jacobian before the iteration loop, so max_iter=0 deliberately + // returns that pre-iteration state without taking any NR/GS step -- + // used eg by test_jacobian.py to check J iteration-by-iteration; only + // negative values are nonsensical) and tol is a finite // strictly positive number (NaN and infinity are rejected). AC only: // DC is a single linear solve without iterations / tolerance. static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); From 4b49e371095b6e86ddd1904d6c27adb11f88df11 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 10:14:33 +0200 Subject: [PATCH 083/166] fix reading from OLF with dist and remote voltage control at the same bus, add a class to allow comparing lightsim2grid results with pypowsybl-like API Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 20 ++ docs/benchmarks_dive.rst | 14 +- docs/comparison_with_pypowsybl.rst | 34 ++ lightsim2grid/network/__init__.py | 2 + .../network/from_pypowsybl/__init__.py | 4 +- .../network/from_pypowsybl/_from_pypowsybl.py | 4 + .../network/from_pypowsybl/_olf_bake.py | 65 ++++ .../network/from_pypowsybl/_result_network.py | 308 ++++++++++++++++++ .../case14_sandbox_format1.lsb | Bin 4731 -> 0 bytes .../tests/test_binary_serialization.py | 4 +- src/bindings/python/binding_lsgrid.cpp | 8 + src/core/BinaryArchive.hpp | 2 +- src/core/LSGrid.cpp | 46 ++- src/core/LSGrid.hpp | 26 +- 14 files changed, 516 insertions(+), 21 deletions(-) create mode 100644 lightsim2grid/network/from_pypowsybl/_result_network.py delete mode 100644 lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c41154b..59402933 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -561,6 +561,26 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. resolving/indexing, and raise a `RuntimeError` instead of silently indexing with `-1` if that invariant is ever violated. Existing HVDC test suite (`test_hvdc_*`, 35 tests) unaffected. +- [BREAKING] (binary) bumped `BINARY_FORMAT_VERSION` (`src/core/BinaryArchive.hpp`) from + ``1`` to ``2`` for the new `LSGrid._init_kwargs` field below: a grid ``save_binary``'d + with a previous lightsim2grid version can no longer be `load_binary`'d. Pickle files are + unaffected by this specific bump (pickle already requires an exact matching + MAJOR.medium.patch lightsim2grid version, regardless of any `StateRes` layout change). +- [ADDED] `LSGrid._init_kwargs`: a ``dict[str, str]`` property (get/set, persisted through + `copy()`, pickle and `save_binary`/`load_binary`) meant for a grid-building function (for + now only `init_from_pypowsybl`) to record the kwargs it was called with, so that code + operating on the returned `LSGrid` later does not have to separately remember them. Set + by `init_from_pypowsybl` to ``{"sort_index": str(sort_index), "buses_for_sub": str(buses_for_sub)}``. +- [ADDED] `lightsim2grid.network.LightsimResultNetwork`: casts a converged `LSGrid` built by + `init_from_pypowsybl` back into a pypowsybl-``Network``-shaped view, so analysis code + written against a solved ``pypowsybl.network.Network`` (``get_buses`` / ``get_lines`` / + ``get_generators`` / ... returning a pandas DataFrame indexed by the pypowsybl element id) + runs unmodified against a solved lightsim2grid grid. Covers buses, lines, 2-winding + transformers, generators, loads, shunt compensators, static var compensators, batteries, + HVDC lines and VSC/LCC converter stations. Relies on `LSGrid._init_kwargs` above (no + need to separately pass `sort_index` back in) and raises `NotImplementedError` for a grid + built with the legacy `buses_for_sub=True` mode. See the "Inspecting results in a + pypowsybl-like way" section of the documentation. [0.13.1] 2026-04-21 -------------------- diff --git a/docs/benchmarks_dive.rst b/docs/benchmarks_dive.rst index 1139aed6..936e395a 100644 --- a/docs/benchmarks_dive.rst +++ b/docs/benchmarks_dive.rst @@ -204,13 +204,13 @@ When we benchmark lightsim2grid in the page :ref:`benchmark-solvers` all 3 algor let us know, no problem at all). Pandapower -++++++++++++ +~~~~~~~~~~~~ When pandapower is benchmarked, only the Newton-Raphson algorithm is used, we will not detail the exact implementatoin of pandapower. Its implementation the python scipy package to perform the linear algebra operations needed. Pypowsybl -++++++++++++ +~~~~~~~~~~~~ When pypowsybl is benchmarked, only the Newton-Raphson algorithm is used. It internally uses some java implementation relying on powsybl framework and open-loadflow (for the default parameters) powerflow. @@ -218,19 +218,19 @@ open-loadflow (for the default parameters) powerflow. Let us know if you are interested with more detail and more algorithm (powsybl can do much more than what is exposed here). Lightsim2grid -++++++++++++++ +~~~~~~~~~~~~~~ -In the benchmarks, lightsim2grid counts the most reported algorithms. In this section we detail a "concisely" the bahviour all some of them. +In the benchmarks, lightsim2grid counts the most reported algorithms. In this section we detail a "concisely" the bahviour all some of them. Gauss Seidel -~~~~~~~~~~~~~ +^^^^^^^^^^^^^ -Lightsim2grid comes with two different Gauss-Seidel algorithms. They are not very efficient for the kind of problem at hand, so +Lightsim2grid comes with two different Gauss-Seidel algorithms. They are not very efficient for the kind of problem at hand, so we will not spend lot of time discussing them here. Fast decoupled and Newton Raphson -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ These two types of algorithms comes each with different variants (we will not enter into too their detail here): diff --git a/docs/comparison_with_pypowsybl.rst b/docs/comparison_with_pypowsybl.rst index 1f9e9dc2..270b85b5 100644 --- a/docs/comparison_with_pypowsybl.rst +++ b/docs/comparison_with_pypowsybl.rst @@ -155,6 +155,40 @@ largest voltage-magnitude and voltage-angle mismatches (plus a per-bus table): .. autofunction:: lightsim2grid.network.compare_baked :no-index: +Inspecting results in a pypowsybl-like way +******************************************** + +``compare_baked`` above only compares bus voltages. If you want to inspect (or write +generic analysis code against) the *full* result of a lightsim2grid powerflow -- lines, +transformers, generators, loads, shunts, HVDC lines, ... -- with the exact same API and +DataFrame shape as a solved pypowsybl :class:`pypowsybl.network.Network`, use +:class:`lightsim2grid.network.LightsimResultNetwork`. It wraps a converged ``LSGrid`` +(built by ``init_from_pypowsybl``) and the pypowsybl network it was built from, and +exposes ``get_buses`` / ``get_lines`` / ``get_2_windings_transformers`` / +``get_generators`` / ``get_loads`` / ``get_shunt_compensators`` / +``get_static_var_compensators`` / ``get_batteries`` / ``get_hvdc_lines`` / +``get_vsc_converter_stations`` / ``get_lcc_converter_stations``, each returning a pandas +DataFrame indexed by the pypowsybl element id, with pypowsybl's own column names and +sign conventions (post-solve ``p`` / ``q`` in the load convention): + +.. code-block:: python + + import pypowsybl as pp + from lightsim2grid.network import init_from_pypowsybl, LightsimResultNetwork + + net = pp.network.create_ieee14() + grid = init_from_pypowsybl(net, gen_slack_id="B1-G") + V = grid.ac_pf(..., 10, 1e-7) + assert len(V) > 0 # converged + + res_net = LightsimResultNetwork(grid, net) + res_net.get_generators() # same shape/columns as net.get_generators() after a solve + res_net.get_lines(attributes=["p1", "q1", "i1"]) + +.. autoclass:: lightsim2grid.network.LightsimResultNetwork + :members: + :no-index: + Results ----------------------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 4c5ea4f3..446ea0b0 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -24,12 +24,14 @@ from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_parameters # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_distributed_slack_parameters # noqa from lightsim2grid.network.from_pypowsybl import compare_baked, ComparisonResult # noqa + from lightsim2grid.network.from_pypowsybl import LightsimResultNetwork # noqa __all__.append("init_from_pypowsybl") __all__.append("bake_outer_loops") __all__.append("get_pypowsybl_loopfree_parameters") __all__.append("get_pypowsybl_loopfree_distributed_slack_parameters") __all__.append("compare_baked") __all__.append("ComparisonResult") + __all__.append("LightsimResultNetwork") except ImportError: # pypowsybl is not installed pass diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index 294e04f2..b2f8dd44 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -12,7 +12,8 @@ "get_pypowsybl_loopfree_parameters", "get_pypowsybl_loopfree_distributed_slack_parameters", "compare_baked", - "ComparisonResult"] + "ComparisonResult", + "LightsimResultNetwork"] from ._from_pypowsybl import init, dangling_line_boundary_bus from ._olf_bake import bake_outer_loops @@ -21,3 +22,4 @@ get_pypowsybl_loopfree_distributed_slack_parameters, ) from ._olf_compare import compare_baked, ComparisonResult +from ._result_network import LightsimResultNetwork diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 12678ad9..8f1118ac 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -813,6 +813,10 @@ def init(net : pypo.network.Network, ) model._ls_to_orig = ls_to_orig model._max_nb_bus_per_sub = n_busbar_per_sub_ls + # relevant kwargs downstream consumers (eg a pypowsybl-shaped result view) need to + # recover conversion-time settings that are otherwise plain Python arguments lost + # after this function returns -- see LSGrid._init_kwargs. + model._init_kwargs = {"sort_index": str(sort_index), "buses_for_sub": str(buses_for_sub)} model.set_substation_names(sub_names) model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 297e80d0..4088bec3 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -60,6 +60,12 @@ implausible ``target_v`` (outside 0.8-1.2 pu of nominal, on buses above 20 kV). Frozen to fixed-Q the same way as the "not started" case above, since OLF itself never actually voltage-controlled them either. +* A remotely-regulating generator whose own bus already hosts another + connected, locally-regulating generator: the shared bus's voltage is already + pinned by the local unit, so the remote unit's reactive injection cannot + independently move any voltage (including the one it targets). Frozen to + fixed-Q the same way, mirroring OLF's own handling of this configuration + (see :func:`_bake_remote_control_bus_conflicts`). * Active-power redistribution from distributed slack / area interchange: the realized P is written back into the target P of generators and batteries (and optionally loads, if the slack was distributed on load). @@ -454,6 +460,64 @@ def _bake_generator_voltage_control_discards(network, keep_only_main_comp=True): network.update_generators(upd) +def _bake_remote_control_bus_conflicts(network, keep_only_main_comp=True): + """Freeze a *remotely*-regulating generator to fixed-Q when its OWN bus + already hosts another connected, *locally* voltage-regulating generator. + + Physical reason: if a co-located generator locally pins this bus's voltage + (holding it at its own target, within its own reactive limits), that bus's + voltage is not a free network unknown from the remote controller's point of + view -- AC power flow couples buses purely through voltage phasors, so how + the *combined* reactive injection at that bus splits between the two + co-located generators has *zero* effect on any other bus's voltage, + including the one the second generator is trying to remotely control. Its + own voltage target is therefore unreachable regardless of its dispatched Q. + + Verified on a real grid carrying this exact configuration: solving the + baked (loop-free) network with OLF, with BOTH generators still nominally + regulating, reproduces almost exactly the voltage obtained by freezing the + remote one to its realized Q (much closer than OLF gets to the -- provably + unreachable -- raw remote target itself), showing OLF itself discards the + remote controller rather than actually achieving its target through free + reactive power. + + lightsim2grid's own C++ voltage-control extension has no equivalent + discard: a lone remote controller sharing a bus with a local one produces a + genuinely singular Jacobian (the remote controller's own reactive-injection + unknown ends up with no equation to pair with) which either crashes + (``ErrorType.SolverFactor``, if the shared bus is classified as a + distributed-slack participant) or raises ``LSGrid::fill_voltage_control_ + solver_data``'s own "not supported in v1" error (if it lands on an ordinary + PV bus) -- so this must be resolved before ``init()`` runs. + + Runs after :func:`_bake_generator_not_started` and + :func:`_bake_generator_voltage_control_discards`: a co-located generator + already frozen there (e.g. "not started", or discarded for too small a + reactive range) no longer counts as "locally regulating", so a remote + generator sharing its bus is correctly left alone once the true conflict is + resolved. + """ + df_bus = network.get_buses(attributes=["synchronous_component"]) + gen = network.get_generators( + attributes=["voltage_regulator_on", "regulated_element_id", "q", "connected", "bus_id"] + ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) + reg = gen["regulated_element_id"].fillna("") + is_remote = gen["voltage_regulator_on"] & (reg != "") & (reg != gen.index) + is_local = gen["voltage_regulator_on"] & ~is_remote + if not is_remote.any() or not is_local.any(): + return + local_buses = set(gen.loc[is_local, "bus_id"]) + conflict = is_remote & gen["bus_id"].isin(local_buses) + if not conflict.any(): + return + upd = pd.DataFrame(index=gen.index[conflict]) + upd["target_q"] = -gen["q"].to_numpy()[conflict] + upd["voltage_regulator_on"] = False + network.update_generators(upd) + + def _bake_reactive_limit_switches( network, keep_only_main_comp=True, @@ -461,6 +525,7 @@ def _bake_reactive_limit_switches( _bake_generator_not_started(network, keep_only_main_comp) if bake_generator_voltage_control_discards: _bake_generator_voltage_control_discards(network, keep_only_main_comp) + _bake_remote_control_bus_conflicts(network, keep_only_main_comp) df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ diff --git a/lightsim2grid/network/from_pypowsybl/_result_network.py b/lightsim2grid/network/from_pypowsybl/_result_network.py new file mode 100644 index 00000000..8a7ad234 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_result_network.py @@ -0,0 +1,308 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Cast a converged :class:`LSGrid` (built by ``init_from_pypowsybl``) back into a +pypowsybl-``Network``-shaped result view: :class:`LightsimResultNetwork` exposes +``get_buses`` / ``get_lines`` / ``get_generators`` / ... methods that return +pandas DataFrames with the same index (pypowsybl element ids) and the same +result column names/units as the real ``pypowsybl.network.Network``, so +analysis code written against a solved pypowsybl network runs unmodified +against a solved lightsim2grid one. + +Only supports grids built by :func:`_from_pypowsybl.init` (``init_from_pypowsybl``): +it relies on that function's invariants -- every non-bus element's ``.name`` set +verbatim to its pypowsybl id, and the grid's ``_init_kwargs``/``_orig_to_ls`` +properties -- which do not hold for pandapower- or powermodels-built grids. + +Sign conventions (lightsim2grid vs pypowsybl's post-solve ``p``/``q``, both in +the "load convention": positive = power flowing *into* the equipment): +loads, shunts and storage units already match (no flip needed: `_from_pypowsybl` +feeds storages a load-convention target_p/target_q, negating IIDM's own +generator-convention target_p on the way in). Generators and HVDC converter +stations use lightsim2grid's generation convention internally (positive = +production), the opposite of pypowsybl's post-solve columns, so those are +negated here -- confirmed against ``net.get_generators()["p"] == -prod_p`` +(see ``lightsim2grid/tests/test_LSGrid_pypowsybl.py``) and, for HVDC converter +stations, by direct comparison against an OLF-solved +``create_four_substations_node_breaker_network()``. SVC is assumed to follow +the same generation convention as generators (it shares the same internal +voltage-control machinery and is fed target_q/target_v un-negated, exactly +like a generator, unlike storages) but this could not be independently +double-checked against a converged real grid. +""" + +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd +import pypowsybl as pypo + +from ...lightsim2grid_cpp import LSGrid # type: ignore + + +class LightsimResultNetwork: + """pypowsybl-``Network``-shaped view of a solved lightsim2grid ``LSGrid``. + + :param ls_grid: a grid built by ``init_from_pypowsybl(net, ...)`` and + already solved (``ac_pf``/``dc_pf`` converged). + :param net: the same pypowsybl network passed to that ``init()`` call. + + Every ``get_*`` method mirrors its ``pypo.network.Network`` namesake: + it accepts an optional ``attributes`` list and returns a DataFrame + indexed by the pypowsybl element id, built lazily on first call and + cached afterwards. + """ + + def __init__(self, ls_grid: LSGrid, net: "pypo.network.Network"): + self._grid = ls_grid + self._net = net + + init_kwargs = ls_grid._init_kwargs + self._sort_index = init_kwargs.get("sort_index", "True") == "True" + if init_kwargs.get("buses_for_sub") == "True": + raise NotImplementedError( + "LightsimResultNetwork does not support a grid built with " + "buses_for_sub=True (legacy 'substation = pypowsybl bus' mode)." + ) + + self._cache: Dict[str, pd.DataFrame] = {} + self._bus_id_lookup: Optional[np.ndarray] = None # see _ls_bus_to_pypo + self._sub_names: Optional[List[str]] = None # see _ls_sub_to_vl + + def _bus_df(self) -> pd.DataFrame: + net = self._net + return net.get_buses().sort_index() if self._sort_index else net.get_buses() + + @staticmethod + def _maybe_select(df: pd.DataFrame, attributes: Optional[List[str]]) -> pd.DataFrame: + return df[attributes] if attributes is not None else df + + @staticmethod + def _records_to_frame(records: List[dict], columns: List[str]) -> pd.DataFrame: + # `columns=` must be passed explicitly: `pd.DataFrame(records)` alone + # infers columns from the (possibly empty) records list, so an element + # type with 0 instances (eg no battery in this grid) would otherwise + # come back with no "id" column at all instead of an empty frame. + return pd.DataFrame(records, columns=columns).set_index("id") + + def _ls_bus_to_pypo(self, ls_bus_id: int) -> Optional[str]: + """lightsim2grid internal bus id (eg `GenInfo.bus_id`) -> pypowsybl bus id. + + Every ``*Info.bus_id``/``bus1_id``/``bus2_id`` field is lightsim2grid's own + integer bus numbering, *not* the pypowsybl string id despite the shared + name -- this reverses it through ``_ls_to_orig`` (opposite of + ``_orig_to_ls``, used for :meth:`get_buses`) into ``_bus_df()``'s index. + Returns ``None`` for a disconnected element with no bus (``ls_bus_id < 0``) + or an ``_ls_to_orig`` slot with no counterpart in ``_bus_df()`` (eg a + dangling-line boundary bus, see :meth:`_build_buses`). + """ + if self._bus_id_lookup is None: + bus_df = self._bus_df() + n_bus = bus_df.shape[0] + ls_to_orig = np.asarray(self._grid._ls_to_orig) + lookup = np.full(ls_to_orig.shape[0], None, dtype=object) + valid = (ls_to_orig >= 0) & (ls_to_orig < n_bus) + lookup[valid] = bus_df.index.to_numpy()[ls_to_orig[valid]] + self._bus_id_lookup = lookup + if ls_bus_id < 0 or ls_bus_id >= self._bus_id_lookup.shape[0]: + return None + return self._bus_id_lookup[ls_bus_id] + + def _ls_sub_to_vl(self, sub_id: int) -> Optional[str]: + """lightsim2grid internal substation id -> pypowsybl voltage_level id. + + Every ``*Info.voltage_level_id``/``voltage_level1_id``/``voltage_level2_id`` + field is bound to the same integer as ``sub_id`` (see + ``src/bindings/python/binding_containers.cpp``), not the pypowsybl string + id. Since the grid was built with ``buses_for_sub`` not ``True`` (enforced + in ``__init__``), a lightsim2grid substation *is* a pypowsybl voltage + level, and ``LSGrid.get_substations()[k].name`` is exactly the string + `_from_pypowsybl.init()` set it to (``model.set_substation_names(...)``). + """ + if self._sub_names is None: + self._sub_names = [s.name for s in self._grid.get_substations()] + if sub_id < 0 or sub_id >= len(self._sub_names): + return None + return self._sub_names[sub_id] + + # ------------------------------------------------------------------ # + # buses + # ------------------------------------------------------------------ # + def get_buses(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "buses" not in self._cache: + self._cache["buses"] = self._build_buses() + return self._maybe_select(self._cache["buses"], attributes) + + def _build_buses(self) -> pd.DataFrame: + grid = self._grid + bus_df = self._bus_df() + n_bus = bus_df.shape[0] + + # `_orig_to_ls` may be longer than `bus_df` (eg extra fictitious buses + # appended for dangling-line boundaries when the grid was built with + # `convert_dangling_lines=True`): those extra slots have no row in + # `net.get_buses()`, so they are dropped here -- same guard as + # `_olf_compare.py::lightsim_bus_to_iidm`. + orig_to_ls = np.asarray(grid._orig_to_ls)[:n_bus] + v_cplx = np.asarray(grid.get_V())[orig_to_ls] + vn_kv = np.asarray(grid.get_bus_vn_kv())[orig_to_ls] + + res = pd.DataFrame(index=bus_df.index) + res["v_mag"] = np.abs(v_cplx) * vn_kv + res["v_angle"] = np.degrees(np.angle(v_cplx)) + res["voltage_level_id"] = bus_df["voltage_level_id"].values + + # a uniform angle offset across every bus is just a difference of + # reference-datum convention (lightsim2grid's slack bus needs not be + # at the same angle-datum as whatever `net` last held), not physical: + # remove it by aligning medians, mirroring the "offset-removed" angle + # metric already used by `_olf_compare.py::compare_baked`. + orig_angle = self._net.get_buses()["v_angle"] + mask_orig = np.isfinite(orig_angle.to_numpy()) & (orig_angle.abs() > 1e-6) + mask_new = np.isfinite(res["v_angle"].to_numpy()) & (res["v_angle"].abs() > 1e-6) + if mask_orig.any() and mask_new.any(): + offset = res.loc[mask_new, "v_angle"].median() - orig_angle.loc[mask_orig].median() + res["v_angle"] = res["v_angle"] - offset + + return res + + # ------------------------------------------------------------------ # + # lines / transformers + # ------------------------------------------------------------------ # + def get_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "lines" not in self._cache: + self._cache["lines"] = self._build_two_sided(self._grid.get_lines()) + return self._maybe_select(self._cache["lines"], attributes) + + def get_2_windings_transformers(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "trafos" not in self._cache: + self._cache["trafos"] = self._build_two_sided(self._grid.get_trafos()) + return self._maybe_select(self._cache["trafos"], attributes) + + _TWO_SIDED_COLUMNS = ["id", "p1", "q1", "i1", "p2", "q2", "i2", + "bus1_id", "bus2_id", "connected1", "connected2", + "voltage_level1_id", "voltage_level2_id"] + + def _build_two_sided(self, container) -> pd.DataFrame: + records = [] + for el in container: + records.append({ + "id": el.name, + "p1": el.res_p1_mw, "q1": el.res_q1_mvar, "i1": el.res_a1_ka * 1000., + "p2": el.res_p2_mw, "q2": el.res_q2_mvar, "i2": el.res_a2_ka * 1000., + "bus1_id": self._ls_bus_to_pypo(el.bus1_id), "bus2_id": self._ls_bus_to_pypo(el.bus2_id), + "connected1": el.connected1, "connected2": el.connected2, + "voltage_level1_id": self._ls_sub_to_vl(el.voltage_level1_id), + "voltage_level2_id": self._ls_sub_to_vl(el.voltage_level2_id), + }) + return self._records_to_frame(records, self._TWO_SIDED_COLUMNS) + + # ------------------------------------------------------------------ # + # generators / loads / shunts / svc / batteries: one-sided elements + # ------------------------------------------------------------------ # + def get_generators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "generators" not in self._cache: + self._cache["generators"] = self._build_one_sided(self._grid.get_generators(), flip_sign=True) + return self._maybe_select(self._cache["generators"], attributes) + + def get_loads(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "loads" not in self._cache: + self._cache["loads"] = self._build_one_sided(self._grid.get_loads(), flip_sign=False) + return self._maybe_select(self._cache["loads"], attributes) + + def get_shunt_compensators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "shunts" not in self._cache: + self._cache["shunts"] = self._build_one_sided(self._grid.get_shunts(), flip_sign=False) + return self._maybe_select(self._cache["shunts"], attributes) + + def get_static_var_compensators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "svcs" not in self._cache: + self._cache["svcs"] = self._build_one_sided(self._grid.get_svcs(), flip_sign=True) + return self._maybe_select(self._cache["svcs"], attributes) + + def get_batteries(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "batteries" not in self._cache: + self._cache["batteries"] = self._build_one_sided(self._grid.get_storages(), flip_sign=False) + return self._maybe_select(self._cache["batteries"], attributes) + + _ONE_SIDED_COLUMNS = ["id", "p", "q", "bus_id", "connected", "voltage_level_id"] + + def _build_one_sided(self, container, flip_sign: bool) -> pd.DataFrame: + sign = -1. if flip_sign else 1. + records = [] + for el in container: + records.append({ + "id": el.name, + "p": sign * el.res_p_mw, "q": sign * el.res_q_mvar, + "bus_id": self._ls_bus_to_pypo(el.bus_id), "connected": el.connected, + "voltage_level_id": self._ls_sub_to_vl(el.voltage_level_id), + }) + return self._records_to_frame(records, self._ONE_SIDED_COLUMNS) + + # ------------------------------------------------------------------ # + # hvdc lines / converter stations + # ------------------------------------------------------------------ # + def get_hvdc_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "hvdc_lines" not in self._cache: + self._build_hvdc() + return self._maybe_select(self._cache["hvdc_lines"], attributes) + + def get_vsc_converter_stations(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "vsc_stations" not in self._cache: + self._build_hvdc() + return self._maybe_select(self._cache["vsc_stations"], attributes) + + def get_lcc_converter_stations(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "lcc_stations" not in self._cache: + self._build_hvdc() + return self._maybe_select(self._cache["lcc_stations"], attributes) + + def _build_hvdc(self) -> None: + # `HvdcLineInfo.station1`/`.station2` never carry the pypowsybl + # `converter_station1_id`/`converter_station2_id` string (lightsim2grid + # only stores them as numeric bus references), so they are recovered + # here from the original network, keyed by the hvdc line's own `.name` + # (== its pypowsybl id). Side 1 pairs with `converter_station1_id` and + # side 2 with `converter_station2_id`: `_from_pypowsybl.init()` builds + # its per-side station data from those two columns in that same order. + orig_hvdc = self._net.get_hvdc_lines() + + line_records = [] + station_records = [] + for el in self._grid.get_dclines(): + st1_id, st2_id = orig_hvdc.loc[el.name, ["converter_station1_id", "converter_station2_id"]] + line_records.append({ + "id": el.name, + "p1": -el.station1.res_p_mw, "q1": -el.station1.res_q_mvar, + "p2": -el.station2.res_p_mw, "q2": -el.station2.res_q_mvar, + "connected1": el.connected1, "connected2": el.connected2, + "converter_station1_id": st1_id, "converter_station2_id": st2_id, + }) + for station_id, station in ((st1_id, el.station1), (st2_id, el.station2)): + station_records.append({ + "id": station_id, + "p": -station.res_p_mw, "q": -station.res_q_mvar, + "bus_id": self._ls_bus_to_pypo(station.bus_id), "connected": station.connected, + "voltage_level_id": self._ls_sub_to_vl(station.voltage_level_id), + # 0 = VSC, 1 = LCC (ConverterStationInfo::ConverterType) + "is_lcc": station.converter_type == 1, + }) + + hvdc_columns = ["id", "p1", "q1", "p2", "q2", "connected1", "connected2", + "converter_station1_id", "converter_station2_id"] + station_columns = ["id", "p", "q", "bus_id", "connected", "voltage_level_id", "is_lcc"] + self._cache["hvdc_lines"] = self._records_to_frame(line_records, hvdc_columns) + stations = self._records_to_frame(station_records, station_columns) + # `.astype(bool)`: an empty `station_records` (no hvdc line in this grid) + # leaves "is_lcc" as an empty object-dtype column, and a non-bool-dtype + # empty mask silently drops every column (not just rows) when used to + # index an empty DataFrame. + is_lcc = stations["is_lcc"].astype(bool) + self._cache["vsc_stations"] = stations[~is_lcc].drop(columns="is_lcc") + self._cache["lcc_stations"] = stations[is_lcc].drop(columns="is_lcc") diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb deleted file mode 100644 index eac26c82f11f96e4a0903bba601a91ed6976843f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4731 zcmeHKZBUd|6yAkIQUt><6iP@*G|_$c-CY(d@1`QdfE6;0IyGG=BAc-Y9Y&=uo1C$k zD2EhBG#eZ%Ga@y!BvW=$8Bu1!nj!s%OvOxPU>`J1KkPjBea~fg2Wlt#_>pJk+4G!p z?>*<-ckg@lZc)hsx6|Qp#1V>0@>f4v4$HuYOMS!;%Qiwk$)V)+C+-;a#h!j)St|l* zw%-{hw$oOLq!EiHnl(H}tqGI`qS6kJHP12I)>*b@f0X)P%!g+K89`W6{h)L)F_^fS zm`prOyi9yda+vs;xkw z8d9~n$)jpB$>T!`FQu0`HZ!E(W^Sp<^UGWsA4&84(lDiAfWjpWcRW$UAVMRGgGj@} z#f6B?sbg5#ZP;G-C3`u*osD4;uy7F(P)W{WRyV#@3rf zG2;kazo?h=9IJd7!ta+&BljNkg zL{<9q(c6Q~;>iBk-rB5Yk(Bq`{JI$j#n>8O)BI&Giqu(czwW%}h&cMgiq}T2Z53aY z?e6mQ9T0t~^2v&VMsa+}vjfxef}&)^o>%5M4~wzWHs+`1cZi(sA5X+zJS8}PeC(&k zHWj`r?%R4HeM@bJC|G&6W&E;sQF*lU{=Ic=qVoI)S%>ay6Riu28}I(|lvq>Plu@<$ z4RLJgwv3mn+r_-)S9)EtQ*3p8KCu6tpr}lKK6qyLVR3e&{HAxwIdNfaUVm%-X)*Bq z=3m~fZx@YkZfpKIuS@5rN1nqb~s+jx-{mvt3liAvNNXsv}&8SUp<+A zVD-v6ZI@5(Sw88*L3_@^v~PQSHF})${MVYFJJ<1l**EVuQlnWw(5;x$JF9$_*QSHR z?BGe3Z3Z1qbQ}n}jwyZ8@LG)euegFe`a_naFK z0-{zPv-%5LL(SAxjiqHb5GE4R2{Q;R93V72el}q9Sa_j_woI|44>Hxi~1uz&*)4R#bQ4JiFxF?7H*7eGf!V?#@EAY@(S z_#fK3AZr65VM-6KSLca!V?)DboU=%+#zWT8ltsbtoGG>)t~YEt5e#2f5U|!QvbBFm zP3yw9tM_27j`p)(aQ5N8Tzo+g^wd?AW#u?Wn$tnj+?49_tmb&6x;!iHl^#9EFTI-Q zN+0fJR!q9B4;pk~A2jIBj=Q8ajc(#DE{F@}#*Y(;;8+@Z`^0Tiud$CPl=jg$PF*x{ z^dcm}RW_M0iEtAki*PGp4#6OJ34Q`6B1+;5C>6XHy0qf!#g^?zvvn3U9rN3vODife zph*$}N;xJ%lYb^Oox|)vNPP0og+E{(x(k}(3(GdgK?gfJ(rs}Y_rh-~$*VT%1*-yLD|Z~tJ#-vNTC Bxv2mE diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 4cf2cbde..97921f27 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -538,13 +538,13 @@ def test_converter_station_type(self): {"VSC": 0, "LCC": 1}) -# reference file saved with binary format 1 (see BINARY_FORMAT_VERSION in +# reference file saved with binary format 2 (see BINARY_FORMAT_VERSION in # src/core/BinaryArchive.hpp) + a few values it is known to contain, used by # TestBinaryLayoutUnchanged below. Regenerate (only after a deliberate format # bump) with: python -m lightsim2grid.tests.test_binary_serialization regen FIXTURE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "binary_format_fixture", - "case14_sandbox_format1.lsb") + "case14_sandbox_format2.lsb") FIXTURE_ENV_NAME = "l2rpn_case14_sandbox" FIXTURE_N_SUB = 14 FIXTURE_N_LOAD = 11 diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 60127691..2049a0e8 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -44,6 +44,14 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` &LSGrid::get_max_nb_bus_per_sub, &LSGrid::set_max_nb_bus_per_sub, "do not modify it after loading !") + .def_property("_init_kwargs", + &LSGrid::get_init_kwargs, + &LSGrid::set_init_kwargs, + R"mydelimiter( +dict (str -> str) of the relevant kwargs this grid was built with (eg by +`init_from_pypowsybl`), for example {"sort_index": "True", "buses_for_sub": "False"}. +Empty for a grid not built that way, or a default-constructed one. +)mydelimiter") .def_property_readonly("timer_last_ac_pf", &LSGrid::timer_last_ac_pf, "TODO") .def_property_readonly("timer_last_dc_pf", &LSGrid::timer_last_dc_pf, "TODO"); add_pickle(lsgrid_cls, "LSGrid"); diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index e6e9cf38..34999bbb 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -73,7 +73,7 @@ namespace ls2g { // of being silently mis-read. If a future version wants to keep reading an // older format, add that format number to SUPPORTED_BINARY_FORMATS (in // BinaryArchive.cpp) together with the required migration code. -constexpr std::uint32_t BINARY_FORMAT_VERSION = 1; +constexpr std::uint32_t BINARY_FORMAT_VERSION = 2; class LS2G_API BinaryArchive { diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 337db1b1..601f62b5 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -20,6 +20,7 @@ LSGrid::LSGrid(const LSGrid & other) init_vm_pu_ = other.init_vm_pu_; sn_mva_ = other.sn_mva_; compute_results_ = other.compute_results_; + init_kwargs_ = other.init_kwargs_; // copy the powersystem representation // 1. bus @@ -92,6 +93,15 @@ LSGrid::StateRes LSGrid::get_state() const auto res_ac_algo_cfg = std::make_tuple(ac_algo_cfg.int_params, ac_algo_cfg.real_params); auto res_dc_algo_cfg = std::make_tuple(dc_algo_cfg.int_params, dc_algo_cfg.real_params); + std::vector init_kwargs_keys; + std::vector init_kwargs_values; + init_kwargs_keys.reserve(init_kwargs_.size()); + init_kwargs_values.reserve(init_kwargs_.size()); + for (const auto & kv : init_kwargs_) { + init_kwargs_keys.push_back(kv.first); + init_kwargs_values.push_back(kv.second); + } + LSGrid::StateRes res(version_major, version_medium, version_minor, @@ -112,7 +122,9 @@ LSGrid::StateRes LSGrid::get_state() const get_algo_type(), get_dc_algo_type(), res_ac_algo_cfg, - res_dc_algo_cfg + res_dc_algo_cfg, + init_kwargs_keys, + init_kwargs_values ); return res; }; @@ -168,6 +180,9 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // algo configs (scaling/refactor/line-search params) auto & state_ac_algo_cfg = std::get(my_state); auto & state_dc_algo_cfg = std::get(my_state); + // relevant kwargs the grid was built with (eg by init_from_pypowsybl) + const std::vector & init_kwargs_keys = std::get(my_state); + const std::vector & init_kwargs_values = std::get(my_state); // substations last_bus_status_saved_ = last_bus_status_saved; @@ -220,6 +235,12 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) dc_algo_cfg.int_params = std::get<0>(state_dc_algo_cfg); dc_algo_cfg.real_params = std::get<1>(state_dc_algo_cfg); set_dc_algo_config(dc_algo_cfg); + + // relevant kwargs the grid was built with (eg by init_from_pypowsybl) + init_kwargs_.clear(); + for (std::size_t i = 0; i < init_kwargs_keys.size(); ++i) { + init_kwargs_[init_kwargs_keys[i]] = init_kwargs_values[i]; + } }; void LSGrid::save_binary(const std::string & path, bool atomic) const { @@ -517,11 +538,18 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b // Slack buses are not PQ in the base block, but a slack bus that is not pinned // by a local PV generator is given a Q equation + free Vm by the MultiSlack // extension (see LSGrid::get_free_vm_slack_solver_buses), so a controller on - // such a slack bus is supported even though `is_pq` is false there. - std::vector is_slack(nb_bus_solver, false); - for(int k = 0; k < static_cast(slack_bus_id_ac_solver_.size()); ++k){ - const int b = slack_bus_id_ac_solver_(k).cast_int(); - if(b >= 0 && b < nb_bus_solver) is_slack[b] = true; + // such a slack bus is supported even though `is_pq` is false there. A slack + // bus that IS locally pinned (another generator regulates it directly) gets + // no such Q equation at all -- checking membership of the whole `slack_bus_ + // id_ac_solver_` list here (as opposed to just this "free" subset) would + // wrongly accept that case: its Q equation lookup then resolves to -1, the + // controller's own reactive-injection column ends up with no Jacobian entry + // anywhere, and the factorization fails with ErrorType::SolverFactor instead + // of this function's own clear error. + const std::set free_vm_slack = get_free_vm_slack_solver_buses(); + std::vector has_free_q(nb_bus_solver, false); + for(int b : free_vm_slack){ + if(b >= 0 && b < nb_bus_solver) has_free_q[b] = true; } // 1. collect the active voltage-mode controllers (remote-regulating gens for @@ -550,11 +578,13 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b << " regulates a disconnected bus."; throw std::runtime_error(exc_.str()); } - if(!is_pq[ctrl_solver] && !is_slack[ctrl_solver]){ + if(!is_pq[ctrl_solver] && !has_free_q[ctrl_solver]){ std::ostringstream exc_; exc_ << "LSGrid::fill_voltage_control_solver_data: generator " << gen_id << " regulates a remote bus but its OWN bus has no reactive (Q) equation" - " (it is a PV bus that is not a slack). This is not supported in v1."; + " (it is a PV bus that is not a slack, or a slack bus already locally" + " pinned by another voltage-regulating generator). This is not supported" + " in v1."; throw std::runtime_error(exc_.str()); } if(!is_pq[reg_solver]){ diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 7653bfeb..daa6a1c1 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include // for int32 @@ -88,7 +90,12 @@ class LS2G_API LSGrid final // algo config (scaling/refactor/line-search params, appended; // old pickles are version-gated): int_params, real_params std::tuple, std::vector >, // ac_algo_config - std::tuple, std::vector > // dc_algo_config + std::tuple, std::vector >, // dc_algo_config + // relevant kwargs the grid was built with (eg by init_from_pypowsybl), as + // a string->string map flattened into parallel key/value vectors (appended; + // old pickles are version-gated). See get_init_kwargs()/set_init_kwargs(). + std::vector, // init_kwargs keys + std::vector // init_kwargs values >; // named indices into the StateRes tuple above (get_state()/set_state() @@ -115,6 +122,8 @@ class LS2G_API LSGrid final static const std::size_t DC_ALGO_TYPE_ID = 18; static const std::size_t AC_ALGO_CONFIG_ID = 19; static const std::size_t DC_ALGO_CONFIG_ID = 20; + static const std::size_t INIT_KWARGS_KEYS_ID = 21; + static const std::size_t INIT_KWARGS_VALUES_ID = 22; LSGrid(): timer_last_ac_pf_(0.), @@ -1659,7 +1668,19 @@ class LS2G_API LSGrid final max_nb_bus_per_sub_ = max_nb_bus_per_sub; } int get_max_nb_bus_per_sub() const { return max_nb_bus_per_sub_;} - + + /** + * Relevant kwargs the grid was built with (eg by `init_from_pypowsybl`), as a + * string->string map (eg {"sort_index": "True", "buses_for_sub": "False"}). + * Empty for a grid not built that way (eg pandapower/powermodels), or a + * default-constructed one. Set by the Python-side converter, never read by any + * C++ logic itself -- purely a way for downstream Python consumers (eg a + * pypowsybl-shaped result view) to recover conversion-time settings that are + * otherwise plain Python arguments lost after `init()` returns. + */ + const std::map & get_init_kwargs() const { return init_kwargs_;} + void set_init_kwargs(const std::map & init_kwargs) { init_kwargs_ = init_kwargs;} + void fillSbus_other(CplxVect & res, bool ac, const SolverBusIdVect& id_me_to_solver){ fillSbus_me(res, ac, id_me_to_solver); } @@ -1986,6 +2007,7 @@ class LS2G_API LSGrid final int max_nb_bus_per_sub_; SubstationContainer substations_; std::vector last_bus_status_saved_; + std::map init_kwargs_; // see get_init_kwargs() // always have the length of the number of buses, // id_me_to_model_[id_me] gives -1 if the bus "id_me" is deactivated, or "id_model" if it is activated. From df6a629c735adab2cea5e75ab90c3660561bc5fc Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 10:14:33 +0200 Subject: [PATCH 084/166] fix reading from OLF with dist and remote voltage control at the same bus, add a class to allow comparing lightsim2grid results with pypowsybl-like API Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 20 ++ docs/benchmarks_dive.rst | 14 +- docs/comparison_with_pypowsybl.rst | 34 ++ lightsim2grid/network/__init__.py | 2 + .../network/from_pypowsybl/__init__.py | 4 +- .../network/from_pypowsybl/_from_pypowsybl.py | 4 + .../network/from_pypowsybl/_olf_bake.py | 65 ++++ .../network/from_pypowsybl/_result_network.py | 308 ++++++++++++++++++ .../case14_sandbox_format1.lsb | Bin 4731 -> 0 bytes .../tests/test_binary_serialization.py | 4 +- src/bindings/python/binding_lsgrid.cpp | 8 + src/core/BinaryArchive.hpp | 2 +- src/core/LSGrid.cpp | 46 ++- src/core/LSGrid.hpp | 26 +- 14 files changed, 516 insertions(+), 21 deletions(-) create mode 100644 lightsim2grid/network/from_pypowsybl/_result_network.py delete mode 100644 lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c41154b..59402933 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -561,6 +561,26 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. resolving/indexing, and raise a `RuntimeError` instead of silently indexing with `-1` if that invariant is ever violated. Existing HVDC test suite (`test_hvdc_*`, 35 tests) unaffected. +- [BREAKING] (binary) bumped `BINARY_FORMAT_VERSION` (`src/core/BinaryArchive.hpp`) from + ``1`` to ``2`` for the new `LSGrid._init_kwargs` field below: a grid ``save_binary``'d + with a previous lightsim2grid version can no longer be `load_binary`'d. Pickle files are + unaffected by this specific bump (pickle already requires an exact matching + MAJOR.medium.patch lightsim2grid version, regardless of any `StateRes` layout change). +- [ADDED] `LSGrid._init_kwargs`: a ``dict[str, str]`` property (get/set, persisted through + `copy()`, pickle and `save_binary`/`load_binary`) meant for a grid-building function (for + now only `init_from_pypowsybl`) to record the kwargs it was called with, so that code + operating on the returned `LSGrid` later does not have to separately remember them. Set + by `init_from_pypowsybl` to ``{"sort_index": str(sort_index), "buses_for_sub": str(buses_for_sub)}``. +- [ADDED] `lightsim2grid.network.LightsimResultNetwork`: casts a converged `LSGrid` built by + `init_from_pypowsybl` back into a pypowsybl-``Network``-shaped view, so analysis code + written against a solved ``pypowsybl.network.Network`` (``get_buses`` / ``get_lines`` / + ``get_generators`` / ... returning a pandas DataFrame indexed by the pypowsybl element id) + runs unmodified against a solved lightsim2grid grid. Covers buses, lines, 2-winding + transformers, generators, loads, shunt compensators, static var compensators, batteries, + HVDC lines and VSC/LCC converter stations. Relies on `LSGrid._init_kwargs` above (no + need to separately pass `sort_index` back in) and raises `NotImplementedError` for a grid + built with the legacy `buses_for_sub=True` mode. See the "Inspecting results in a + pypowsybl-like way" section of the documentation. [0.13.1] 2026-04-21 -------------------- diff --git a/docs/benchmarks_dive.rst b/docs/benchmarks_dive.rst index 1139aed6..936e395a 100644 --- a/docs/benchmarks_dive.rst +++ b/docs/benchmarks_dive.rst @@ -204,13 +204,13 @@ When we benchmark lightsim2grid in the page :ref:`benchmark-solvers` all 3 algor let us know, no problem at all). Pandapower -++++++++++++ +~~~~~~~~~~~~ When pandapower is benchmarked, only the Newton-Raphson algorithm is used, we will not detail the exact implementatoin of pandapower. Its implementation the python scipy package to perform the linear algebra operations needed. Pypowsybl -++++++++++++ +~~~~~~~~~~~~ When pypowsybl is benchmarked, only the Newton-Raphson algorithm is used. It internally uses some java implementation relying on powsybl framework and open-loadflow (for the default parameters) powerflow. @@ -218,19 +218,19 @@ open-loadflow (for the default parameters) powerflow. Let us know if you are interested with more detail and more algorithm (powsybl can do much more than what is exposed here). Lightsim2grid -++++++++++++++ +~~~~~~~~~~~~~~ -In the benchmarks, lightsim2grid counts the most reported algorithms. In this section we detail a "concisely" the bahviour all some of them. +In the benchmarks, lightsim2grid counts the most reported algorithms. In this section we detail a "concisely" the bahviour all some of them. Gauss Seidel -~~~~~~~~~~~~~ +^^^^^^^^^^^^^ -Lightsim2grid comes with two different Gauss-Seidel algorithms. They are not very efficient for the kind of problem at hand, so +Lightsim2grid comes with two different Gauss-Seidel algorithms. They are not very efficient for the kind of problem at hand, so we will not spend lot of time discussing them here. Fast decoupled and Newton Raphson -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ These two types of algorithms comes each with different variants (we will not enter into too their detail here): diff --git a/docs/comparison_with_pypowsybl.rst b/docs/comparison_with_pypowsybl.rst index 1f9e9dc2..270b85b5 100644 --- a/docs/comparison_with_pypowsybl.rst +++ b/docs/comparison_with_pypowsybl.rst @@ -155,6 +155,40 @@ largest voltage-magnitude and voltage-angle mismatches (plus a per-bus table): .. autofunction:: lightsim2grid.network.compare_baked :no-index: +Inspecting results in a pypowsybl-like way +******************************************** + +``compare_baked`` above only compares bus voltages. If you want to inspect (or write +generic analysis code against) the *full* result of a lightsim2grid powerflow -- lines, +transformers, generators, loads, shunts, HVDC lines, ... -- with the exact same API and +DataFrame shape as a solved pypowsybl :class:`pypowsybl.network.Network`, use +:class:`lightsim2grid.network.LightsimResultNetwork`. It wraps a converged ``LSGrid`` +(built by ``init_from_pypowsybl``) and the pypowsybl network it was built from, and +exposes ``get_buses`` / ``get_lines`` / ``get_2_windings_transformers`` / +``get_generators`` / ``get_loads`` / ``get_shunt_compensators`` / +``get_static_var_compensators`` / ``get_batteries`` / ``get_hvdc_lines`` / +``get_vsc_converter_stations`` / ``get_lcc_converter_stations``, each returning a pandas +DataFrame indexed by the pypowsybl element id, with pypowsybl's own column names and +sign conventions (post-solve ``p`` / ``q`` in the load convention): + +.. code-block:: python + + import pypowsybl as pp + from lightsim2grid.network import init_from_pypowsybl, LightsimResultNetwork + + net = pp.network.create_ieee14() + grid = init_from_pypowsybl(net, gen_slack_id="B1-G") + V = grid.ac_pf(..., 10, 1e-7) + assert len(V) > 0 # converged + + res_net = LightsimResultNetwork(grid, net) + res_net.get_generators() # same shape/columns as net.get_generators() after a solve + res_net.get_lines(attributes=["p1", "q1", "i1"]) + +.. autoclass:: lightsim2grid.network.LightsimResultNetwork + :members: + :no-index: + Results ----------------------------------- diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 4c5ea4f3..446ea0b0 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -24,12 +24,14 @@ from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_parameters # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_distributed_slack_parameters # noqa from lightsim2grid.network.from_pypowsybl import compare_baked, ComparisonResult # noqa + from lightsim2grid.network.from_pypowsybl import LightsimResultNetwork # noqa __all__.append("init_from_pypowsybl") __all__.append("bake_outer_loops") __all__.append("get_pypowsybl_loopfree_parameters") __all__.append("get_pypowsybl_loopfree_distributed_slack_parameters") __all__.append("compare_baked") __all__.append("ComparisonResult") + __all__.append("LightsimResultNetwork") except ImportError: # pypowsybl is not installed pass diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index 294e04f2..b2f8dd44 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -12,7 +12,8 @@ "get_pypowsybl_loopfree_parameters", "get_pypowsybl_loopfree_distributed_slack_parameters", "compare_baked", - "ComparisonResult"] + "ComparisonResult", + "LightsimResultNetwork"] from ._from_pypowsybl import init, dangling_line_boundary_bus from ._olf_bake import bake_outer_loops @@ -21,3 +22,4 @@ get_pypowsybl_loopfree_distributed_slack_parameters, ) from ._olf_compare import compare_baked, ComparisonResult +from ._result_network import LightsimResultNetwork diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 12678ad9..8f1118ac 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -813,6 +813,10 @@ def init(net : pypo.network.Network, ) model._ls_to_orig = ls_to_orig model._max_nb_bus_per_sub = n_busbar_per_sub_ls + # relevant kwargs downstream consumers (eg a pypowsybl-shaped result view) need to + # recover conversion-time settings that are otherwise plain Python arguments lost + # after this function returns -- see LSGrid._init_kwargs. + model._init_kwargs = {"sort_index": str(sort_index), "buses_for_sub": str(buses_for_sub)} model.set_substation_names(sub_names) model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 297e80d0..4088bec3 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -60,6 +60,12 @@ implausible ``target_v`` (outside 0.8-1.2 pu of nominal, on buses above 20 kV). Frozen to fixed-Q the same way as the "not started" case above, since OLF itself never actually voltage-controlled them either. +* A remotely-regulating generator whose own bus already hosts another + connected, locally-regulating generator: the shared bus's voltage is already + pinned by the local unit, so the remote unit's reactive injection cannot + independently move any voltage (including the one it targets). Frozen to + fixed-Q the same way, mirroring OLF's own handling of this configuration + (see :func:`_bake_remote_control_bus_conflicts`). * Active-power redistribution from distributed slack / area interchange: the realized P is written back into the target P of generators and batteries (and optionally loads, if the slack was distributed on load). @@ -454,6 +460,64 @@ def _bake_generator_voltage_control_discards(network, keep_only_main_comp=True): network.update_generators(upd) +def _bake_remote_control_bus_conflicts(network, keep_only_main_comp=True): + """Freeze a *remotely*-regulating generator to fixed-Q when its OWN bus + already hosts another connected, *locally* voltage-regulating generator. + + Physical reason: if a co-located generator locally pins this bus's voltage + (holding it at its own target, within its own reactive limits), that bus's + voltage is not a free network unknown from the remote controller's point of + view -- AC power flow couples buses purely through voltage phasors, so how + the *combined* reactive injection at that bus splits between the two + co-located generators has *zero* effect on any other bus's voltage, + including the one the second generator is trying to remotely control. Its + own voltage target is therefore unreachable regardless of its dispatched Q. + + Verified on a real grid carrying this exact configuration: solving the + baked (loop-free) network with OLF, with BOTH generators still nominally + regulating, reproduces almost exactly the voltage obtained by freezing the + remote one to its realized Q (much closer than OLF gets to the -- provably + unreachable -- raw remote target itself), showing OLF itself discards the + remote controller rather than actually achieving its target through free + reactive power. + + lightsim2grid's own C++ voltage-control extension has no equivalent + discard: a lone remote controller sharing a bus with a local one produces a + genuinely singular Jacobian (the remote controller's own reactive-injection + unknown ends up with no equation to pair with) which either crashes + (``ErrorType.SolverFactor``, if the shared bus is classified as a + distributed-slack participant) or raises ``LSGrid::fill_voltage_control_ + solver_data``'s own "not supported in v1" error (if it lands on an ordinary + PV bus) -- so this must be resolved before ``init()`` runs. + + Runs after :func:`_bake_generator_not_started` and + :func:`_bake_generator_voltage_control_discards`: a co-located generator + already frozen there (e.g. "not started", or discarded for too small a + reactive range) no longer counts as "locally regulating", so a remote + generator sharing its bus is correctly left alone once the true conflict is + resolved. + """ + df_bus = network.get_buses(attributes=["synchronous_component"]) + gen = network.get_generators( + attributes=["voltage_regulator_on", "regulated_element_id", "q", "connected", "bus_id"] + ) + if keep_only_main_comp: + gen = _keep_only_main_comp(gen, df_bus) + reg = gen["regulated_element_id"].fillna("") + is_remote = gen["voltage_regulator_on"] & (reg != "") & (reg != gen.index) + is_local = gen["voltage_regulator_on"] & ~is_remote + if not is_remote.any() or not is_local.any(): + return + local_buses = set(gen.loc[is_local, "bus_id"]) + conflict = is_remote & gen["bus_id"].isin(local_buses) + if not conflict.any(): + return + upd = pd.DataFrame(index=gen.index[conflict]) + upd["target_q"] = -gen["q"].to_numpy()[conflict] + upd["voltage_regulator_on"] = False + network.update_generators(upd) + + def _bake_reactive_limit_switches( network, keep_only_main_comp=True, @@ -461,6 +525,7 @@ def _bake_reactive_limit_switches( _bake_generator_not_started(network, keep_only_main_comp) if bake_generator_voltage_control_discards: _bake_generator_voltage_control_discards(network, keep_only_main_comp) + _bake_remote_control_bus_conflicts(network, keep_only_main_comp) df_bus = network.get_buses(attributes=["synchronous_component"]) gen = network.get_generators( attributes=[ diff --git a/lightsim2grid/network/from_pypowsybl/_result_network.py b/lightsim2grid/network/from_pypowsybl/_result_network.py new file mode 100644 index 00000000..8a7ad234 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_result_network.py @@ -0,0 +1,308 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Cast a converged :class:`LSGrid` (built by ``init_from_pypowsybl``) back into a +pypowsybl-``Network``-shaped result view: :class:`LightsimResultNetwork` exposes +``get_buses`` / ``get_lines`` / ``get_generators`` / ... methods that return +pandas DataFrames with the same index (pypowsybl element ids) and the same +result column names/units as the real ``pypowsybl.network.Network``, so +analysis code written against a solved pypowsybl network runs unmodified +against a solved lightsim2grid one. + +Only supports grids built by :func:`_from_pypowsybl.init` (``init_from_pypowsybl``): +it relies on that function's invariants -- every non-bus element's ``.name`` set +verbatim to its pypowsybl id, and the grid's ``_init_kwargs``/``_orig_to_ls`` +properties -- which do not hold for pandapower- or powermodels-built grids. + +Sign conventions (lightsim2grid vs pypowsybl's post-solve ``p``/``q``, both in +the "load convention": positive = power flowing *into* the equipment): +loads, shunts and storage units already match (no flip needed: `_from_pypowsybl` +feeds storages a load-convention target_p/target_q, negating IIDM's own +generator-convention target_p on the way in). Generators and HVDC converter +stations use lightsim2grid's generation convention internally (positive = +production), the opposite of pypowsybl's post-solve columns, so those are +negated here -- confirmed against ``net.get_generators()["p"] == -prod_p`` +(see ``lightsim2grid/tests/test_LSGrid_pypowsybl.py``) and, for HVDC converter +stations, by direct comparison against an OLF-solved +``create_four_substations_node_breaker_network()``. SVC is assumed to follow +the same generation convention as generators (it shares the same internal +voltage-control machinery and is fed target_q/target_v un-negated, exactly +like a generator, unlike storages) but this could not be independently +double-checked against a converged real grid. +""" + +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd +import pypowsybl as pypo + +from ...lightsim2grid_cpp import LSGrid # type: ignore + + +class LightsimResultNetwork: + """pypowsybl-``Network``-shaped view of a solved lightsim2grid ``LSGrid``. + + :param ls_grid: a grid built by ``init_from_pypowsybl(net, ...)`` and + already solved (``ac_pf``/``dc_pf`` converged). + :param net: the same pypowsybl network passed to that ``init()`` call. + + Every ``get_*`` method mirrors its ``pypo.network.Network`` namesake: + it accepts an optional ``attributes`` list and returns a DataFrame + indexed by the pypowsybl element id, built lazily on first call and + cached afterwards. + """ + + def __init__(self, ls_grid: LSGrid, net: "pypo.network.Network"): + self._grid = ls_grid + self._net = net + + init_kwargs = ls_grid._init_kwargs + self._sort_index = init_kwargs.get("sort_index", "True") == "True" + if init_kwargs.get("buses_for_sub") == "True": + raise NotImplementedError( + "LightsimResultNetwork does not support a grid built with " + "buses_for_sub=True (legacy 'substation = pypowsybl bus' mode)." + ) + + self._cache: Dict[str, pd.DataFrame] = {} + self._bus_id_lookup: Optional[np.ndarray] = None # see _ls_bus_to_pypo + self._sub_names: Optional[List[str]] = None # see _ls_sub_to_vl + + def _bus_df(self) -> pd.DataFrame: + net = self._net + return net.get_buses().sort_index() if self._sort_index else net.get_buses() + + @staticmethod + def _maybe_select(df: pd.DataFrame, attributes: Optional[List[str]]) -> pd.DataFrame: + return df[attributes] if attributes is not None else df + + @staticmethod + def _records_to_frame(records: List[dict], columns: List[str]) -> pd.DataFrame: + # `columns=` must be passed explicitly: `pd.DataFrame(records)` alone + # infers columns from the (possibly empty) records list, so an element + # type with 0 instances (eg no battery in this grid) would otherwise + # come back with no "id" column at all instead of an empty frame. + return pd.DataFrame(records, columns=columns).set_index("id") + + def _ls_bus_to_pypo(self, ls_bus_id: int) -> Optional[str]: + """lightsim2grid internal bus id (eg `GenInfo.bus_id`) -> pypowsybl bus id. + + Every ``*Info.bus_id``/``bus1_id``/``bus2_id`` field is lightsim2grid's own + integer bus numbering, *not* the pypowsybl string id despite the shared + name -- this reverses it through ``_ls_to_orig`` (opposite of + ``_orig_to_ls``, used for :meth:`get_buses`) into ``_bus_df()``'s index. + Returns ``None`` for a disconnected element with no bus (``ls_bus_id < 0``) + or an ``_ls_to_orig`` slot with no counterpart in ``_bus_df()`` (eg a + dangling-line boundary bus, see :meth:`_build_buses`). + """ + if self._bus_id_lookup is None: + bus_df = self._bus_df() + n_bus = bus_df.shape[0] + ls_to_orig = np.asarray(self._grid._ls_to_orig) + lookup = np.full(ls_to_orig.shape[0], None, dtype=object) + valid = (ls_to_orig >= 0) & (ls_to_orig < n_bus) + lookup[valid] = bus_df.index.to_numpy()[ls_to_orig[valid]] + self._bus_id_lookup = lookup + if ls_bus_id < 0 or ls_bus_id >= self._bus_id_lookup.shape[0]: + return None + return self._bus_id_lookup[ls_bus_id] + + def _ls_sub_to_vl(self, sub_id: int) -> Optional[str]: + """lightsim2grid internal substation id -> pypowsybl voltage_level id. + + Every ``*Info.voltage_level_id``/``voltage_level1_id``/``voltage_level2_id`` + field is bound to the same integer as ``sub_id`` (see + ``src/bindings/python/binding_containers.cpp``), not the pypowsybl string + id. Since the grid was built with ``buses_for_sub`` not ``True`` (enforced + in ``__init__``), a lightsim2grid substation *is* a pypowsybl voltage + level, and ``LSGrid.get_substations()[k].name`` is exactly the string + `_from_pypowsybl.init()` set it to (``model.set_substation_names(...)``). + """ + if self._sub_names is None: + self._sub_names = [s.name for s in self._grid.get_substations()] + if sub_id < 0 or sub_id >= len(self._sub_names): + return None + return self._sub_names[sub_id] + + # ------------------------------------------------------------------ # + # buses + # ------------------------------------------------------------------ # + def get_buses(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "buses" not in self._cache: + self._cache["buses"] = self._build_buses() + return self._maybe_select(self._cache["buses"], attributes) + + def _build_buses(self) -> pd.DataFrame: + grid = self._grid + bus_df = self._bus_df() + n_bus = bus_df.shape[0] + + # `_orig_to_ls` may be longer than `bus_df` (eg extra fictitious buses + # appended for dangling-line boundaries when the grid was built with + # `convert_dangling_lines=True`): those extra slots have no row in + # `net.get_buses()`, so they are dropped here -- same guard as + # `_olf_compare.py::lightsim_bus_to_iidm`. + orig_to_ls = np.asarray(grid._orig_to_ls)[:n_bus] + v_cplx = np.asarray(grid.get_V())[orig_to_ls] + vn_kv = np.asarray(grid.get_bus_vn_kv())[orig_to_ls] + + res = pd.DataFrame(index=bus_df.index) + res["v_mag"] = np.abs(v_cplx) * vn_kv + res["v_angle"] = np.degrees(np.angle(v_cplx)) + res["voltage_level_id"] = bus_df["voltage_level_id"].values + + # a uniform angle offset across every bus is just a difference of + # reference-datum convention (lightsim2grid's slack bus needs not be + # at the same angle-datum as whatever `net` last held), not physical: + # remove it by aligning medians, mirroring the "offset-removed" angle + # metric already used by `_olf_compare.py::compare_baked`. + orig_angle = self._net.get_buses()["v_angle"] + mask_orig = np.isfinite(orig_angle.to_numpy()) & (orig_angle.abs() > 1e-6) + mask_new = np.isfinite(res["v_angle"].to_numpy()) & (res["v_angle"].abs() > 1e-6) + if mask_orig.any() and mask_new.any(): + offset = res.loc[mask_new, "v_angle"].median() - orig_angle.loc[mask_orig].median() + res["v_angle"] = res["v_angle"] - offset + + return res + + # ------------------------------------------------------------------ # + # lines / transformers + # ------------------------------------------------------------------ # + def get_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "lines" not in self._cache: + self._cache["lines"] = self._build_two_sided(self._grid.get_lines()) + return self._maybe_select(self._cache["lines"], attributes) + + def get_2_windings_transformers(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "trafos" not in self._cache: + self._cache["trafos"] = self._build_two_sided(self._grid.get_trafos()) + return self._maybe_select(self._cache["trafos"], attributes) + + _TWO_SIDED_COLUMNS = ["id", "p1", "q1", "i1", "p2", "q2", "i2", + "bus1_id", "bus2_id", "connected1", "connected2", + "voltage_level1_id", "voltage_level2_id"] + + def _build_two_sided(self, container) -> pd.DataFrame: + records = [] + for el in container: + records.append({ + "id": el.name, + "p1": el.res_p1_mw, "q1": el.res_q1_mvar, "i1": el.res_a1_ka * 1000., + "p2": el.res_p2_mw, "q2": el.res_q2_mvar, "i2": el.res_a2_ka * 1000., + "bus1_id": self._ls_bus_to_pypo(el.bus1_id), "bus2_id": self._ls_bus_to_pypo(el.bus2_id), + "connected1": el.connected1, "connected2": el.connected2, + "voltage_level1_id": self._ls_sub_to_vl(el.voltage_level1_id), + "voltage_level2_id": self._ls_sub_to_vl(el.voltage_level2_id), + }) + return self._records_to_frame(records, self._TWO_SIDED_COLUMNS) + + # ------------------------------------------------------------------ # + # generators / loads / shunts / svc / batteries: one-sided elements + # ------------------------------------------------------------------ # + def get_generators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "generators" not in self._cache: + self._cache["generators"] = self._build_one_sided(self._grid.get_generators(), flip_sign=True) + return self._maybe_select(self._cache["generators"], attributes) + + def get_loads(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "loads" not in self._cache: + self._cache["loads"] = self._build_one_sided(self._grid.get_loads(), flip_sign=False) + return self._maybe_select(self._cache["loads"], attributes) + + def get_shunt_compensators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "shunts" not in self._cache: + self._cache["shunts"] = self._build_one_sided(self._grid.get_shunts(), flip_sign=False) + return self._maybe_select(self._cache["shunts"], attributes) + + def get_static_var_compensators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "svcs" not in self._cache: + self._cache["svcs"] = self._build_one_sided(self._grid.get_svcs(), flip_sign=True) + return self._maybe_select(self._cache["svcs"], attributes) + + def get_batteries(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "batteries" not in self._cache: + self._cache["batteries"] = self._build_one_sided(self._grid.get_storages(), flip_sign=False) + return self._maybe_select(self._cache["batteries"], attributes) + + _ONE_SIDED_COLUMNS = ["id", "p", "q", "bus_id", "connected", "voltage_level_id"] + + def _build_one_sided(self, container, flip_sign: bool) -> pd.DataFrame: + sign = -1. if flip_sign else 1. + records = [] + for el in container: + records.append({ + "id": el.name, + "p": sign * el.res_p_mw, "q": sign * el.res_q_mvar, + "bus_id": self._ls_bus_to_pypo(el.bus_id), "connected": el.connected, + "voltage_level_id": self._ls_sub_to_vl(el.voltage_level_id), + }) + return self._records_to_frame(records, self._ONE_SIDED_COLUMNS) + + # ------------------------------------------------------------------ # + # hvdc lines / converter stations + # ------------------------------------------------------------------ # + def get_hvdc_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "hvdc_lines" not in self._cache: + self._build_hvdc() + return self._maybe_select(self._cache["hvdc_lines"], attributes) + + def get_vsc_converter_stations(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "vsc_stations" not in self._cache: + self._build_hvdc() + return self._maybe_select(self._cache["vsc_stations"], attributes) + + def get_lcc_converter_stations(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + if "lcc_stations" not in self._cache: + self._build_hvdc() + return self._maybe_select(self._cache["lcc_stations"], attributes) + + def _build_hvdc(self) -> None: + # `HvdcLineInfo.station1`/`.station2` never carry the pypowsybl + # `converter_station1_id`/`converter_station2_id` string (lightsim2grid + # only stores them as numeric bus references), so they are recovered + # here from the original network, keyed by the hvdc line's own `.name` + # (== its pypowsybl id). Side 1 pairs with `converter_station1_id` and + # side 2 with `converter_station2_id`: `_from_pypowsybl.init()` builds + # its per-side station data from those two columns in that same order. + orig_hvdc = self._net.get_hvdc_lines() + + line_records = [] + station_records = [] + for el in self._grid.get_dclines(): + st1_id, st2_id = orig_hvdc.loc[el.name, ["converter_station1_id", "converter_station2_id"]] + line_records.append({ + "id": el.name, + "p1": -el.station1.res_p_mw, "q1": -el.station1.res_q_mvar, + "p2": -el.station2.res_p_mw, "q2": -el.station2.res_q_mvar, + "connected1": el.connected1, "connected2": el.connected2, + "converter_station1_id": st1_id, "converter_station2_id": st2_id, + }) + for station_id, station in ((st1_id, el.station1), (st2_id, el.station2)): + station_records.append({ + "id": station_id, + "p": -station.res_p_mw, "q": -station.res_q_mvar, + "bus_id": self._ls_bus_to_pypo(station.bus_id), "connected": station.connected, + "voltage_level_id": self._ls_sub_to_vl(station.voltage_level_id), + # 0 = VSC, 1 = LCC (ConverterStationInfo::ConverterType) + "is_lcc": station.converter_type == 1, + }) + + hvdc_columns = ["id", "p1", "q1", "p2", "q2", "connected1", "connected2", + "converter_station1_id", "converter_station2_id"] + station_columns = ["id", "p", "q", "bus_id", "connected", "voltage_level_id", "is_lcc"] + self._cache["hvdc_lines"] = self._records_to_frame(line_records, hvdc_columns) + stations = self._records_to_frame(station_records, station_columns) + # `.astype(bool)`: an empty `station_records` (no hvdc line in this grid) + # leaves "is_lcc" as an empty object-dtype column, and a non-bool-dtype + # empty mask silently drops every column (not just rows) when used to + # index an empty DataFrame. + is_lcc = stations["is_lcc"].astype(bool) + self._cache["vsc_stations"] = stations[~is_lcc].drop(columns="is_lcc") + self._cache["lcc_stations"] = stations[is_lcc].drop(columns="is_lcc") diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format1.lsb deleted file mode 100644 index eac26c82f11f96e4a0903bba601a91ed6976843f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4731 zcmeHKZBUd|6yAkIQUt><6iP@*G|_$c-CY(d@1`QdfE6;0IyGG=BAc-Y9Y&=uo1C$k zD2EhBG#eZ%Ga@y!BvW=$8Bu1!nj!s%OvOxPU>`J1KkPjBea~fg2Wlt#_>pJk+4G!p z?>*<-ckg@lZc)hsx6|Qp#1V>0@>f4v4$HuYOMS!;%Qiwk$)V)+C+-;a#h!j)St|l* zw%-{hw$oOLq!EiHnl(H}tqGI`qS6kJHP12I)>*b@f0X)P%!g+K89`W6{h)L)F_^fS zm`prOyi9yda+vs;xkw z8d9~n$)jpB$>T!`FQu0`HZ!E(W^Sp<^UGWsA4&84(lDiAfWjpWcRW$UAVMRGgGj@} z#f6B?sbg5#ZP;G-C3`u*osD4;uy7F(P)W{WRyV#@3rf zG2;kazo?h=9IJd7!ta+&BljNkg zL{<9q(c6Q~;>iBk-rB5Yk(Bq`{JI$j#n>8O)BI&Giqu(czwW%}h&cMgiq}T2Z53aY z?e6mQ9T0t~^2v&VMsa+}vjfxef}&)^o>%5M4~wzWHs+`1cZi(sA5X+zJS8}PeC(&k zHWj`r?%R4HeM@bJC|G&6W&E;sQF*lU{=Ic=qVoI)S%>ay6Riu28}I(|lvq>Plu@<$ z4RLJgwv3mn+r_-)S9)EtQ*3p8KCu6tpr}lKK6qyLVR3e&{HAxwIdNfaUVm%-X)*Bq z=3m~fZx@YkZfpKIuS@5rN1nqb~s+jx-{mvt3liAvNNXsv}&8SUp<+A zVD-v6ZI@5(Sw88*L3_@^v~PQSHF})${MVYFJJ<1l**EVuQlnWw(5;x$JF9$_*QSHR z?BGe3Z3Z1qbQ}n}jwyZ8@LG)euegFe`a_naFK z0-{zPv-%5LL(SAxjiqHb5GE4R2{Q;R93V72el}q9Sa_j_woI|44>Hxi~1uz&*)4R#bQ4JiFxF?7H*7eGf!V?#@EAY@(S z_#fK3AZr65VM-6KSLca!V?)DboU=%+#zWT8ltsbtoGG>)t~YEt5e#2f5U|!QvbBFm zP3yw9tM_27j`p)(aQ5N8Tzo+g^wd?AW#u?Wn$tnj+?49_tmb&6x;!iHl^#9EFTI-Q zN+0fJR!q9B4;pk~A2jIBj=Q8ajc(#DE{F@}#*Y(;;8+@Z`^0Tiud$CPl=jg$PF*x{ z^dcm}RW_M0iEtAki*PGp4#6OJ34Q`6B1+;5C>6XHy0qf!#g^?zvvn3U9rN3vODife zph*$}N;xJ%lYb^Oox|)vNPP0og+E{(x(k}(3(GdgK?gfJ(rs}Y_rh-~$*VT%1*-yLD|Z~tJ#-vNTC Bxv2mE diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 4cf2cbde..97921f27 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -538,13 +538,13 @@ def test_converter_station_type(self): {"VSC": 0, "LCC": 1}) -# reference file saved with binary format 1 (see BINARY_FORMAT_VERSION in +# reference file saved with binary format 2 (see BINARY_FORMAT_VERSION in # src/core/BinaryArchive.hpp) + a few values it is known to contain, used by # TestBinaryLayoutUnchanged below. Regenerate (only after a deliberate format # bump) with: python -m lightsim2grid.tests.test_binary_serialization regen FIXTURE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "binary_format_fixture", - "case14_sandbox_format1.lsb") + "case14_sandbox_format2.lsb") FIXTURE_ENV_NAME = "l2rpn_case14_sandbox" FIXTURE_N_SUB = 14 FIXTURE_N_LOAD = 11 diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 60127691..2049a0e8 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -44,6 +44,14 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` &LSGrid::get_max_nb_bus_per_sub, &LSGrid::set_max_nb_bus_per_sub, "do not modify it after loading !") + .def_property("_init_kwargs", + &LSGrid::get_init_kwargs, + &LSGrid::set_init_kwargs, + R"mydelimiter( +dict (str -> str) of the relevant kwargs this grid was built with (eg by +`init_from_pypowsybl`), for example {"sort_index": "True", "buses_for_sub": "False"}. +Empty for a grid not built that way, or a default-constructed one. +)mydelimiter") .def_property_readonly("timer_last_ac_pf", &LSGrid::timer_last_ac_pf, "TODO") .def_property_readonly("timer_last_dc_pf", &LSGrid::timer_last_dc_pf, "TODO"); add_pickle(lsgrid_cls, "LSGrid"); diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index e6e9cf38..34999bbb 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -73,7 +73,7 @@ namespace ls2g { // of being silently mis-read. If a future version wants to keep reading an // older format, add that format number to SUPPORTED_BINARY_FORMATS (in // BinaryArchive.cpp) together with the required migration code. -constexpr std::uint32_t BINARY_FORMAT_VERSION = 1; +constexpr std::uint32_t BINARY_FORMAT_VERSION = 2; class LS2G_API BinaryArchive { diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 337db1b1..601f62b5 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -20,6 +20,7 @@ LSGrid::LSGrid(const LSGrid & other) init_vm_pu_ = other.init_vm_pu_; sn_mva_ = other.sn_mva_; compute_results_ = other.compute_results_; + init_kwargs_ = other.init_kwargs_; // copy the powersystem representation // 1. bus @@ -92,6 +93,15 @@ LSGrid::StateRes LSGrid::get_state() const auto res_ac_algo_cfg = std::make_tuple(ac_algo_cfg.int_params, ac_algo_cfg.real_params); auto res_dc_algo_cfg = std::make_tuple(dc_algo_cfg.int_params, dc_algo_cfg.real_params); + std::vector init_kwargs_keys; + std::vector init_kwargs_values; + init_kwargs_keys.reserve(init_kwargs_.size()); + init_kwargs_values.reserve(init_kwargs_.size()); + for (const auto & kv : init_kwargs_) { + init_kwargs_keys.push_back(kv.first); + init_kwargs_values.push_back(kv.second); + } + LSGrid::StateRes res(version_major, version_medium, version_minor, @@ -112,7 +122,9 @@ LSGrid::StateRes LSGrid::get_state() const get_algo_type(), get_dc_algo_type(), res_ac_algo_cfg, - res_dc_algo_cfg + res_dc_algo_cfg, + init_kwargs_keys, + init_kwargs_values ); return res; }; @@ -168,6 +180,9 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // algo configs (scaling/refactor/line-search params) auto & state_ac_algo_cfg = std::get(my_state); auto & state_dc_algo_cfg = std::get(my_state); + // relevant kwargs the grid was built with (eg by init_from_pypowsybl) + const std::vector & init_kwargs_keys = std::get(my_state); + const std::vector & init_kwargs_values = std::get(my_state); // substations last_bus_status_saved_ = last_bus_status_saved; @@ -220,6 +235,12 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) dc_algo_cfg.int_params = std::get<0>(state_dc_algo_cfg); dc_algo_cfg.real_params = std::get<1>(state_dc_algo_cfg); set_dc_algo_config(dc_algo_cfg); + + // relevant kwargs the grid was built with (eg by init_from_pypowsybl) + init_kwargs_.clear(); + for (std::size_t i = 0; i < init_kwargs_keys.size(); ++i) { + init_kwargs_[init_kwargs_keys[i]] = init_kwargs_values[i]; + } }; void LSGrid::save_binary(const std::string & path, bool atomic) const { @@ -517,11 +538,18 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b // Slack buses are not PQ in the base block, but a slack bus that is not pinned // by a local PV generator is given a Q equation + free Vm by the MultiSlack // extension (see LSGrid::get_free_vm_slack_solver_buses), so a controller on - // such a slack bus is supported even though `is_pq` is false there. - std::vector is_slack(nb_bus_solver, false); - for(int k = 0; k < static_cast(slack_bus_id_ac_solver_.size()); ++k){ - const int b = slack_bus_id_ac_solver_(k).cast_int(); - if(b >= 0 && b < nb_bus_solver) is_slack[b] = true; + // such a slack bus is supported even though `is_pq` is false there. A slack + // bus that IS locally pinned (another generator regulates it directly) gets + // no such Q equation at all -- checking membership of the whole `slack_bus_ + // id_ac_solver_` list here (as opposed to just this "free" subset) would + // wrongly accept that case: its Q equation lookup then resolves to -1, the + // controller's own reactive-injection column ends up with no Jacobian entry + // anywhere, and the factorization fails with ErrorType::SolverFactor instead + // of this function's own clear error. + const std::set free_vm_slack = get_free_vm_slack_solver_buses(); + std::vector has_free_q(nb_bus_solver, false); + for(int b : free_vm_slack){ + if(b >= 0 && b < nb_bus_solver) has_free_q[b] = true; } // 1. collect the active voltage-mode controllers (remote-regulating gens for @@ -550,11 +578,13 @@ void LSGrid::fill_voltage_control_solver_data(VoltageControlSolverData & data, b << " regulates a disconnected bus."; throw std::runtime_error(exc_.str()); } - if(!is_pq[ctrl_solver] && !is_slack[ctrl_solver]){ + if(!is_pq[ctrl_solver] && !has_free_q[ctrl_solver]){ std::ostringstream exc_; exc_ << "LSGrid::fill_voltage_control_solver_data: generator " << gen_id << " regulates a remote bus but its OWN bus has no reactive (Q) equation" - " (it is a PV bus that is not a slack). This is not supported in v1."; + " (it is a PV bus that is not a slack, or a slack bus already locally" + " pinned by another voltage-regulating generator). This is not supported" + " in v1."; throw std::runtime_error(exc_.str()); } if(!is_pq[reg_solver]){ diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 7653bfeb..daa6a1c1 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include // for int32 @@ -88,7 +90,12 @@ class LS2G_API LSGrid final // algo config (scaling/refactor/line-search params, appended; // old pickles are version-gated): int_params, real_params std::tuple, std::vector >, // ac_algo_config - std::tuple, std::vector > // dc_algo_config + std::tuple, std::vector >, // dc_algo_config + // relevant kwargs the grid was built with (eg by init_from_pypowsybl), as + // a string->string map flattened into parallel key/value vectors (appended; + // old pickles are version-gated). See get_init_kwargs()/set_init_kwargs(). + std::vector, // init_kwargs keys + std::vector // init_kwargs values >; // named indices into the StateRes tuple above (get_state()/set_state() @@ -115,6 +122,8 @@ class LS2G_API LSGrid final static const std::size_t DC_ALGO_TYPE_ID = 18; static const std::size_t AC_ALGO_CONFIG_ID = 19; static const std::size_t DC_ALGO_CONFIG_ID = 20; + static const std::size_t INIT_KWARGS_KEYS_ID = 21; + static const std::size_t INIT_KWARGS_VALUES_ID = 22; LSGrid(): timer_last_ac_pf_(0.), @@ -1659,7 +1668,19 @@ class LS2G_API LSGrid final max_nb_bus_per_sub_ = max_nb_bus_per_sub; } int get_max_nb_bus_per_sub() const { return max_nb_bus_per_sub_;} - + + /** + * Relevant kwargs the grid was built with (eg by `init_from_pypowsybl`), as a + * string->string map (eg {"sort_index": "True", "buses_for_sub": "False"}). + * Empty for a grid not built that way (eg pandapower/powermodels), or a + * default-constructed one. Set by the Python-side converter, never read by any + * C++ logic itself -- purely a way for downstream Python consumers (eg a + * pypowsybl-shaped result view) to recover conversion-time settings that are + * otherwise plain Python arguments lost after `init()` returns. + */ + const std::map & get_init_kwargs() const { return init_kwargs_;} + void set_init_kwargs(const std::map & init_kwargs) { init_kwargs_ = init_kwargs;} + void fillSbus_other(CplxVect & res, bool ac, const SolverBusIdVect& id_me_to_solver){ fillSbus_me(res, ac, id_me_to_solver); } @@ -1986,6 +2007,7 @@ class LS2G_API LSGrid final int max_nb_bus_per_sub_; SubstationContainer substations_; std::vector last_bus_status_saved_; + std::map init_kwargs_; // see get_init_kwargs() // always have the length of the number of buses, // id_me_to_model_[id_me] gives -1 if the bus "id_me" is deactivated, or "id_model" if it is activated. From 2c6c463ebc947681da9f4c5b4bfc602c50f77d92 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 10:33:12 +0200 Subject: [PATCH 085/166] adding some missing tests data Signed-off-by: DONNOT Benjamin --- .../case14_sandbox_format2.lsb | Bin 0 -> 4747 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb new file mode 100644 index 0000000000000000000000000000000000000000..1fd0aa645a98a747897cf42298217338c93805ce GIT binary patch literal 4747 zcmeHKZBUd|6yB9oQWU~36iWz6FwuSY-CY(d?}nnofE6;0GBsT&BAc-Y8Ahcqo1C$k zEXPzvG#eZ$Ga@y!BvW=$8Bu1!nj!s%OvOxPU>`J1KkPjBea~fg2Wlt#_>pJk+4G!p z?>*<-ckg@lZeek*dxXQ`7)2;7Ub6bpawjnZA1?KQ!7L;6lN?H3f8vfYUo`d$%PI*> zxBbpAv7NRWPa3gUqFKXp)S5t9AS&(fSo0inY@K0i_D8Az#e8@+kdcJyng^weiNVCp z#AM=O;$`AvlFh`=BnMwIA{#Ev49(n{nW!R6obk4aP%dd$wYj86s@e?cmZ~;Gno`wf z(2%OlO&(R7Ngf|kcqzTiwwWRQHgii=o?qtB_-LBvmxd_~0~9W4xMPVL1`!%j97Gx( zE-vicIdOWG@G#pLVBjpq5U?)>LIc+m#uDNP93vuyel9dc3CD$wHg;GLEWD0@V(?_f z5Rg2HFqJTkFq4o@m`!jIJOmb9C^-;^ivY()p%IfehE0yMeb^|tjuEk2?WZ|^HMZU? zikU#*0wxm28!(A5hcJ)eCS($Pge(FMW;THf8SidCU2=Y_czT_4;M~3@QT*DV|N6b#gY9ny_+(cMSR|K3%1NUC=zOYO$(O4D3WKl{krR(BjV@}C9jQM(<;6y z+tcOgJ0SX!<&zcp4dVFHX9v>qf}(h2<16!>hebl#h9${MIz)E&k0)X;o)VltHs;e~ z8w=hQ_iek7vUO93$X|K3W#aO7QF*lU{=Hk;MCJJpG7jC@CR!I3HQfE>DN$X}lv=g< z4RLH)UFyp~Or4acSIfSG~5^W~I*fX;q!JUp<*} zVD-u^+Ag2kvtr7JgZA8o)4%QU)#`E1^IvOz?p(+JW#7EtNR4IzLAPQ~@2v7!UYiaM zvx6sDwi$Fd(QzQ?I;QkV!)r0>!>Vt4Bz11pw|z2O8me#mB#q|qO4Tk@4EkUv-g9m| z2#8vF%<3;}4K-6&l|ajGAWSBt5M~irI6!E4{A|GHvG7zw&D5!8(6UhF%kvX*O)akVA zPY7qY%CBSCEF6E>2@wN0e-NC~M;?A07b>Q7r(x=q?m=oY&6XafE~(y8M8f-w-+EL6 zUhi3(S9SJ_0u6v9n}48pC1xdemYCHM)Ph$x9Kpj7Z)=+cU>7hAStx~(&y>6qURU0P9* z3QZChP|7hGn*5p2bPlruA@RvS5B`98=q_lAFD%=f03Gb;NU^nP>m+E(7h~&qXo{=y zl0O~xHM#Z#{?;de){hqzfAK;EFD`uCm8`LDv9li1BC^!KhH3V1eRqV-zx|;R!};$p CbGfPj literal 0 HcmV?d00001 From 6121402e887e9d7ad1b69b022435ecf2d8d2f328 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 10:33:12 +0200 Subject: [PATCH 086/166] adding some missing tests data Signed-off-by: DONNOT Benjamin --- .../case14_sandbox_format2.lsb | Bin 0 -> 4747 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb new file mode 100644 index 0000000000000000000000000000000000000000..1fd0aa645a98a747897cf42298217338c93805ce GIT binary patch literal 4747 zcmeHKZBUd|6yB9oQWU~36iWz6FwuSY-CY(d?}nnofE6;0GBsT&BAc-Y8Ahcqo1C$k zEXPzvG#eZ$Ga@y!BvW=$8Bu1!nj!s%OvOxPU>`J1KkPjBea~fg2Wlt#_>pJk+4G!p z?>*<-ckg@lZeek*dxXQ`7)2;7Ub6bpawjnZA1?KQ!7L;6lN?H3f8vfYUo`d$%PI*> zxBbpAv7NRWPa3gUqFKXp)S5t9AS&(fSo0inY@K0i_D8Az#e8@+kdcJyng^weiNVCp z#AM=O;$`AvlFh`=BnMwIA{#Ev49(n{nW!R6obk4aP%dd$wYj86s@e?cmZ~;Gno`wf z(2%OlO&(R7Ngf|kcqzTiwwWRQHgii=o?qtB_-LBvmxd_~0~9W4xMPVL1`!%j97Gx( zE-vicIdOWG@G#pLVBjpq5U?)>LIc+m#uDNP93vuyel9dc3CD$wHg;GLEWD0@V(?_f z5Rg2HFqJTkFq4o@m`!jIJOmb9C^-;^ivY()p%IfehE0yMeb^|tjuEk2?WZ|^HMZU? zikU#*0wxm28!(A5hcJ)eCS($Pge(FMW;THf8SidCU2=Y_czT_4;M~3@QT*DV|N6b#gY9ny_+(cMSR|K3%1NUC=zOYO$(O4D3WKl{krR(BjV@}C9jQM(<;6y z+tcOgJ0SX!<&zcp4dVFHX9v>qf}(h2<16!>hebl#h9${MIz)E&k0)X;o)VltHs;e~ z8w=hQ_iek7vUO93$X|K3W#aO7QF*lU{=Hk;MCJJpG7jC@CR!I3HQfE>DN$X}lv=g< z4RLH)UFyp~Or4acSIfSG~5^W~I*fX;q!JUp<*} zVD-u^+Ag2kvtr7JgZA8o)4%QU)#`E1^IvOz?p(+JW#7EtNR4IzLAPQ~@2v7!UYiaM zvx6sDwi$Fd(QzQ?I;QkV!)r0>!>Vt4Bz11pw|z2O8me#mB#q|qO4Tk@4EkUv-g9m| z2#8vF%<3;}4K-6&l|ajGAWSBt5M~irI6!E4{A|GHvG7zw&D5!8(6UhF%kvX*O)akVA zPY7qY%CBSCEF6E>2@wN0e-NC~M;?A07b>Q7r(x=q?m=oY&6XafE~(y8M8f-w-+EL6 zUhi3(S9SJ_0u6v9n}48pC1xdemYCHM)Ph$x9Kpj7Z)=+cU>7hAStx~(&y>6qURU0P9* z3QZChP|7hGn*5p2bPlruA@RvS5B`98=q_lAFD%=f03Gb;NU^nP>m+E(7h~&qXo{=y zl0O~xHM#Z#{?;de){hqzfAK;EFD`uCm8`LDv9li1BC^!KhH3V1eRqV-zx|;R!};$p CbGfPj literal 0 HcmV?d00001 From 46b44d16989adeb7f1af723201a3c0c2d0e7968d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 14:18:19 +0200 Subject: [PATCH 087/166] fix the 'remove outer loops' from OLF parameters Signed-off-by: DONNOT Benjamin --- lightsim2grid/network/__init__.py | 2 + .../network/from_pypowsybl/__init__.py | 2 + .../network/from_pypowsybl/_olf_compare.py | 14 +- .../network/from_pypowsybl/_olf_params.py | 403 ++++++++++-------- lightsim2grid/tests/test_olf_bake.py | 23 +- .../powerflow_algorithm/ScalingPolicies.hpp | 35 +- 6 files changed, 284 insertions(+), 195 deletions(-) diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 446ea0b0..83f39c5a 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -21,12 +21,14 @@ try: from lightsim2grid.network.from_pypowsybl import init as init_from_pypowsybl # noqa from lightsim2grid.network.from_pypowsybl import bake_outer_loops # noqa + from lightsim2grid.network.from_pypowsybl import remove_outer_loops # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_parameters # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_distributed_slack_parameters # noqa from lightsim2grid.network.from_pypowsybl import compare_baked, ComparisonResult # noqa from lightsim2grid.network.from_pypowsybl import LightsimResultNetwork # noqa __all__.append("init_from_pypowsybl") __all__.append("bake_outer_loops") + __all__.append("remove_outer_loops") __all__.append("get_pypowsybl_loopfree_parameters") __all__.append("get_pypowsybl_loopfree_distributed_slack_parameters") __all__.append("compare_baked") diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index b2f8dd44..8ea891d8 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -9,6 +9,7 @@ __all__ = ["init", "dangling_line_boundary_bus", "bake_outer_loops", + "remove_outer_loops", "get_pypowsybl_loopfree_parameters", "get_pypowsybl_loopfree_distributed_slack_parameters", "compare_baked", @@ -18,6 +19,7 @@ from ._from_pypowsybl import init, dangling_line_boundary_bus from ._olf_bake import bake_outer_loops from ._olf_params import ( + remove_outer_loops, get_pypowsybl_loopfree_parameters, get_pypowsybl_loopfree_distributed_slack_parameters, ) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_compare.py b/lightsim2grid/network/from_pypowsybl/_olf_compare.py index 71c135af..1e2febd9 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_compare.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_compare.py @@ -17,8 +17,9 @@ network (see ``_olf_bake``). 3. Optionally apply the same topology change (e.g. a line outage) to both engines. -4. Solve the baked network loop-free in OLF (see - :func:`get_pypowsybl_loopfree_parameters`) and in lightsim2grid. +4. Solve the baked network loop-free in OLF (see :func:`remove_outer_loops`, + applied to the same ``olf_loop_params`` used for step 1) and in + lightsim2grid. 5. Compare bus voltage magnitude (pu) and angle (deg). Bus mapping @@ -42,7 +43,7 @@ from ._from_pypowsybl import init as init_from_pypowsybl from ._olf_bake import bake_outer_loops -from ._olf_params import get_pypowsybl_loopfree_parameters +from ._olf_params import remove_outer_loops def iidm_bus_voltages(network) -> pd.DataFrame: @@ -153,7 +154,12 @@ def compare_baked( balance_type=pp.loadflow.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, twt_split_shunt_admittance=True, ) - loop_free_params = get_pypowsybl_loopfree_parameters() + # Derived from ``olf_loop_params`` (not built independently): this guarantees + # every field the with-loops and loop-free solves do not differ on by design + # (twt_split_shunt_admittance, component_mode, voltage_init_mode, ...) is + # actually identical between them, so the comparison isolates the outer-loop + # effect that baking neutralizes rather than an unrelated model difference. + loop_free_params = remove_outer_loops(olf_loop_params) # ---- OLF side: with-loops solve, bake, outage, loop-free solve ---- n_olf = network_factory() diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 3b10a108..3d6fc836 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -7,34 +7,48 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """ -Canonical PowSyBl Open Load Flow (OLF) parameters that disable *every* outer -loop, so that OLF solves the same single-shot power-flow problem lightsim2grid -solves. - -This is the single source of truth used by the lightsim2grid benchmarks, the -documentation, the test suite and the OLF-vs-lightsim2grid comparison helper. -Disabling the outer loops is what makes a *raw* (un-baked) network give -consistent results between the two engines on grids where no outer loop would -trigger anyway; on grids where the loops do act, combine it with -:func:`lightsim2grid.network.bake_outer_loops`. - -The factory is written to be consistent across **every** pypowsybl version from -1.0.0 to 1.15.0. The pypowsybl ``Parameters`` constructor and the OLF provider -parameters changed over time (renamed kwargs, new enums, parameters that did not -exist yet, and values rejected by older releases). Rather than hard-code one -version, the factory introspects the installed pypowsybl and only passes what it -accepts: - -* top-level kwargs are filtered against ``Parameters.__init__`` and renamed - parameters fall back to their old spelling (``use_reactive_limits`` -> - ``no_generator_reactive_limits``, ``shunt_compensator_voltage_control_on`` -> - ``simul_shunt``, ``component_mode`` -> ``connected_component_mode``); -* enums are resolved from wherever the installed version exposes them; -* provider parameters are filtered against the OLF metadata (valid name *and*, - for enum-valued ones, valid value); -* the empty ``outerLoopNames`` allow-list is only used from pypowsybl 1.4.0 on, - because older OLF rejects the empty value ("Unknown outer loop ''"). Below - 1.4.0 the explicit trigger flags already disable every outer loop that exists. +Remove PowSyBl Open Load Flow (OLF) *outer loops* from a set of pypowsybl +loadflow parameters, without touching anything else. + +:func:`remove_outer_loops` is the single source of truth for "make OLF solve +the same single-shot power-flow problem lightsim2grid solves". It takes a +caller-supplied :class:`pypowsybl.loadflow.Parameters` and strips out the +outer-loop *mechanism* only: it does not change reactive limits, remote +voltage control, voltage initialization, balance type, or any other modeling +choice, because none of those are outer loops -- forcing them changes the +solved answer for reasons unrelated to outer loops (and, for +``voltage_init_mode`` in particular, can push OLF into a different +Newton-Raphson root on a multi-root grid, contaminating a comparison meant to +isolate the outer-loop effect). + +Background: OLF's outer loops fall into two families. + +* Some have an inline alternative: ``transformerVoltageControlMode``, + ``shuntVoltageControlMode`` and ``phaseShifterControlMode`` can each be + switched from an outer-loop-based mode to a mode that folds the same + control into the inner Newton-Raphson equations. Switching the mode keeps + the modeled control (e.g. transformer voltage regulation) *active*, it just + removes the separate outer-loop pass -- this is real outer-loop removal + that changes nothing else, and it works on every pypowsybl version. +* Others (``DistributedSlack``, ``ReactiveLimits``, ``VoltageMonitoring``, + ``SecondaryVoltageControl``, ``AreaInterchangeControl``, + ``AutomationSystem``) have no inline equivalent. From pypowsybl 1.4.0 on, + the ``outerLoopNames`` provider parameter is an *allow-list*: only loops + named there are ever created, regardless of any other trigger flag + (verified empirically: ``distributed_slack=True`` with an empty + ``outerLoopNames`` does not distribute). So on 1.4.0+, this allow-list is + the only lever this module needs to touch for that family. Below 1.4.0, + where OLF rejects an empty ``outerLoopNames`` (and the parameter does not + exist at all in 1.0.0), the individual creation-trigger flags are the only + way to stop a loop, so they are forced off there instead -- and only there. + +The factory (both this module's ``remove_outer_loops`` and the +``get_pypowsybl_loopfree_*`` convenience wrappers below) is written to be +consistent across every pypowsybl version from 1.0.0 to 1.15.0: it only +passes constructor keywords and provider parameters the installed version +accepts, resolving renamed kwargs (``use_reactive_limits`` -> +``no_generator_reactive_limits``, ``shunt_compensator_voltage_control_on`` -> +``simul_shunt``) and enums from wherever the installed version exposes them. """ import inspect @@ -60,8 +74,7 @@ def _ver_tuple(v: str): _PARAM_SIG = set(inspect.signature(_lf.Parameters.__init__).parameters) _PYPOWSYBL_VER = _ver_tuple(_pp.__version__) # OLF rejects an empty ``outerLoopNames`` value before 1.4.0 (and the parameter -# does not exist at all in 1.0.0); only use the empty-allow-list trick where it -# is accepted. +# does not exist at all in 1.0.0); only use the allow-list where it is accepted. _OUTERLOOP_EMPTY_OK = _PYPOWSYBL_VER >= (1, 4) # Lazily-computed cache of the OLF provider parameter metadata @@ -112,146 +125,201 @@ def _olf_provider_params(): return result -# The full "ZERO outer loops" OLF provider-parameter wish-list. Entries that the -# installed OLF does not know (or whose value it rejects) are filtered out. -_DESIRED_PROVIDER = { - # ---- empty allow-list: ZERO outer loops, future-proof (>= 1.4.0) ---- - "outerLoopNames": "", - # ---- every outer-loop creation trigger forced off (belt and suspenders) ---- - "areaInterchangeControl": "false", - "svcVoltageMonitoring": "false", - "secondaryVoltageControl": "false", - "transformerReactivePowerControl": "false", - "simulateAutomationSystems": "false", - "voltageRemoteControl": "false", - "generatorVoltageRemoteControl": "false", - # ---- control modes pushed to inner-loop / harmless ---- - "transformerVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", - "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", - "phaseShifterControlMode": "CONTINUOUS_WITH_DISCRETISATION", - # ---- NR inner-solver settings forced (the only thing left active) ---- - "maxNewtonRaphsonIterations": "15", - "newtonRaphsonConvEpsPerEq": "1.0E-4", - "stateVectorScalingMode": "NONE", - "lineSearchStateVectorScalingMaxIteration": "10", # inert with NONE; forced anyway - # ---- hard backstop (0 is rejected by OLF; 1 is moot with empty allow-list) ---- - "maxOuterLoopIterations": "1", - "slackDistributionFailureBehavior": "FAIL", +# Outer loops with an inline (inner-Newton-Raphson) alternative: switching the +# mode keeps the modeled control active while removing the separate outer-loop +# pass. Works on every pypowsybl version, so these are always applied unless +# the caller explicitly asks to ``keep`` the outer-loop-based mode. +_INLINE_MODE = { + "TransformerVoltageControl": ("transformerVoltageControlMode", "WITH_GENERATOR_VOLTAGE_CONTROL"), + "ShuntVoltageControl": ("shuntVoltageControlMode", "WITH_GENERATOR_VOLTAGE_CONTROL"), + "PhaseControl": ("phaseShifterControlMode", "CONTINUOUS_WITH_DISCRETISATION"), } +# Outer loops with no inline alternative. ``None`` marks a top-level +# Parameters kwarg (handled specially in ``remove_outer_loops`` because of the +# renamed-kwarg fallback); a tuple marks a provider parameter trigger. +_LEGACY_TRIGGER = { + "DistributedSlack": None, # top-level `distributed_slack` + "ReactiveLimits": None, # top-level `use_reactive_limits` / `no_generator_reactive_limits` + "VoltageMonitoring": ("svcVoltageMonitoring", "false"), + "SecondaryVoltageControl": ("secondaryVoltageControl", "false"), + "AreaInterchangeControl": ("areaInterchangeControl", "false"), + "AutomationSystem": ("simulateAutomationSystems", "false"), +} -def get_pypowsybl_loopfree_parameters( +_ALL_LOOP_NAMES = set(_INLINE_MODE) | set(_LEGACY_TRIGGER) + + +def _copy_parameters(parameters: _lf.Parameters) -> _lf.Parameters: + """Return an independent copy of ``parameters``. + + Built by copying each constructor-exposed attribute one at a time + (``getattr``/``setattr``) rather than via ``copy.deepcopy``: ``Parameters`` + is a pybind11-wrapped object backed by a native (Java) instance, and + whether the generic copy protocol produces a truly independent object -- + as opposed to a new Python wrapper aliasing the same underlying native + state -- is not guaranteed to hold the same way on every pypowsybl version + this module supports (1.0.0 to 1.15.0). Explicit getattr/setattr only + relies on the property interface already used everywhere else here. + """ + out = _lf.Parameters() + for name in _PARAM_SIG: + if name in ("self", "provider_parameters"): + continue + try: + value = getattr(parameters, name) + except AttributeError: + continue + setattr(out, name, value) + out.provider_parameters = dict(parameters.provider_parameters) + return out + + +def remove_outer_loops( + parameters: _lf.Parameters, + keep: Iterable[str] = (), slack_bus_ids: Optional[Union[str, Iterable[str]]] = None, - **overrides, + max_outer_loop_iterations: int = 20, ) -> _lf.Parameters: - """Build a fresh :class:`pypowsybl.loadflow.Parameters` with every OLF outer - loop disabled. + """Return a copy of ``parameters`` with OLF's outer loops removed. - The returned parameters disable distributed slack, reactive limits, - transformer / shunt / phase-shifter voltage control, area-interchange and - secondary-voltage control, automation systems, etc. From pypowsybl 1.4.0 on, - the empty ``outerLoopNames`` allow-list also registers *zero* outer loops - regardless of which loops a future OLF release adds; on older pypowsybl the - explicit trigger flags below disable every outer loop that exists. - - The factory is consistent across pypowsybl 1.0.0 .. 1.15.0: it only passes - constructor keywords and provider parameters the installed version accepts - (see the module docstring). + Only the outer-loop mechanism is touched: reactive limits, remote voltage + control, voltage initialization, balance type, connected-component mode, + and every other field of ``parameters`` are left exactly as given. Parameters ---------- + parameters : pypowsybl.loadflow.Parameters + The parameters to strip outer loops from. Not mutated; a modified + copy is returned. + keep : iterable of str, optional + Names of outer loops to leave active instead of removing. Valid + names: ``"TransformerVoltageControl"``, ``"ShuntVoltageControl"``, + ``"PhaseControl"`` (inline-mode family), ``"DistributedSlack"``, + ``"ReactiveLimits"``, ``"VoltageMonitoring"``, + ``"SecondaryVoltageControl"``, ``"AreaInterchangeControl"``, + ``"AutomationSystem"`` (no-inline-alternative family). Keeping a loop + only stops *this function* from removing it -- for a no-inline- + alternative loop this function does not force its own creation + trigger on, so it still runs only if ``parameters`` already enables + it (e.g. ``distributed_slack=True`` for ``"DistributedSlack"``). slack_bus_ids : str or iterable of str, optional If given, the slack is pinned by NAME on these bus(es) (``slackBusSelectionMode = "NAME"``) and ``read_slack_bus`` is turned - off. This is what the benchmarks need to use the same slack as - lightsim2grid. If ``None`` (default), the slack is read from the network - (``read_slack_bus = True``). - **overrides - Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to override - on the returned object (e.g. ``distributed_slack=True``). Only applied if - the installed pypowsybl accepts that keyword. + off. Unrelated to outer-loop removal; a convenience for matching + lightsim2grid's slack choice. + max_outer_loop_iterations : int + Outer-loop iteration budget, only set when ``keep`` is non-empty (so + a kept loop, e.g. distributed slack, has room to converge). Ignored + otherwise. Returns ------- pypowsybl.loadflow.Parameters - A new object on each call (no shared mutable state). - - Notes - ----- - ``maxOuterLoopIterations`` is set to ``"1"`` because OLF rejects ``"0"``; - with an empty ``outerLoopNames`` the iteration cap is moot. Verified to - converge on IEEE-14 against every pypowsybl from 1.0.0 to 1.15.0. + A new object; ``parameters`` is not modified. """ - kwargs = {} + keep = set(keep) + unknown = keep - _ALL_LOOP_NAMES + if unknown: + raise ValueError( + f"Unknown outer loop(s) in `keep`: {sorted(unknown)}. " + f"Valid names: {sorted(_ALL_LOOP_NAMES)}" + ) + + params = _copy_parameters(parameters) def put(name, value): if name in _PARAM_SIG: - kwargs[name] = value + setattr(params, name, value) return True return False - put("distributed_slack", False) - put("transformer_voltage_control_on", False) - put("phase_shifter_regulation_on", False) - put("write_slack_bus", True) - put("dc_use_transformer_ratio", True) - put("twt_split_shunt_admittance", True) - - vim = _enum("VoltageInitMode") - if vim is not None: - put("voltage_init_mode", vim.UNIFORM_VALUES) - bt = _enum("BalanceType") - if bt is not None: - # forced; inert with distributed slack off - put("balance_type", bt.PROPORTIONAL_TO_GENERATION_P_MAX) - - # reactive limits: new ``use_reactive_limits`` vs old ``no_generator_reactive_limits`` - if not put("use_reactive_limits", False): - put("no_generator_reactive_limits", True) - # shunt voltage control: new ``shunt_compensator_voltage_control_on`` vs old ``simul_shunt`` - if not put("shunt_compensator_voltage_control_on", False): - put("simul_shunt", False) - - read_slack_bus = slack_bus_ids is None - put("read_slack_bus", read_slack_bus) - - # connected component: ``component_mode`` (>= 1.15) vs ``connected_component_mode`` - if "component_mode" in _PARAM_SIG: - cm = _enum("ComponentMode") - if cm is not None and hasattr(cm, "MAIN_CONNECTED"): - kwargs["component_mode"] = cm.MAIN_CONNECTED - elif "connected_component_mode" in _PARAM_SIG: - ccm = _enum("ConnectedComponentMode") - if ccm is not None and hasattr(ccm, "MAIN"): - kwargs["connected_component_mode"] = ccm.MAIN - - desired = dict(_DESIRED_PROVIDER) - if not _OUTERLOOP_EMPTY_OK: - desired.pop("outerLoopNames", None) - if slack_bus_ids is not None: - if not isinstance(slack_bus_ids, str): - slack_bus_ids = ",".join(str(s) for s in slack_bus_ids) - desired["slackBusSelectionMode"] = "NAME" - desired["slackBusesIds"] = f"{slack_bus_ids}" - valid = _olf_provider_params() - if valid is not None: - provider_parameters = {} - for name, value in desired.items(): + prov = dict(params.provider_parameters) + + def put_provider(name, value): + if valid is not None: if name not in valid: - continue # parameter unknown in this pypowsybl version + return # parameter unknown in this pypowsybl/OLF version possible = valid[name] if possible is not None and value not in possible: - continue # value not accepted in this version - provider_parameters[name] = value + return # value not accepted in this version + prov[name] = value + + # 1. Inline-mode loops: fold into the inner NR loop unless kept as an + # outer loop. Version-independent. + for loop, (pname, value) in _INLINE_MODE.items(): + if loop not in keep: + put_provider(pname, value) + + # 2. Loops with no inline alternative. + if _OUTERLOOP_EMPTY_OK: + # The allow-list alone gates creation, regardless of any trigger flag, + # so nothing else needs touching here. + put_provider("outerLoopNames", ",".join(sorted(keep & set(_LEGACY_TRIGGER)))) + if keep: + put_provider("maxOuterLoopIterations", str(int(max_outer_loop_iterations))) else: - provider_parameters = desired - put("provider_parameters", provider_parameters) + # Pre-1.4 OLF: no allow-list: the only way to stop a loop is its own + # creation trigger, forced off unless the caller asked to keep it. + for loop, spec in _LEGACY_TRIGGER.items(): + if loop in keep: + continue + if spec is None: + if loop == "DistributedSlack": + put("distributed_slack", False) + elif loop == "ReactiveLimits": + if not put("use_reactive_limits", False): + put("no_generator_reactive_limits", True) + else: + pname, value = spec + put_provider(pname, value) + + if slack_bus_ids is not None: + if not isinstance(slack_bus_ids, str): + slack_bus_ids = ",".join(str(s) for s in slack_bus_ids) + put_provider("slackBusSelectionMode", "NAME") + put_provider("slackBusesIds", slack_bus_ids) + put("read_slack_bus", False) - # only forward overrides the installed Parameters constructor accepts + params.provider_parameters = prov + return params + + +def get_pypowsybl_loopfree_parameters( + slack_bus_ids: Optional[Union[str, Iterable[str]]] = None, + **overrides, +) -> _lf.Parameters: + """Build a fresh :class:`pypowsybl.loadflow.Parameters` with every OLF + outer loop removed (see :func:`remove_outer_loops`). + + Everything other than the outer-loop mechanism is left at the installed + pypowsybl's own defaults (or at ``**overrides``, if given) -- in + particular ``voltage_init_mode``, reactive limits and remote voltage + control are **not** forced, since none of those are outer loops. If a + test needs a specific value for one of those to be reproducible across + pypowsybl builds, pass it explicitly via ``**overrides`` (different + pypowsybl builds are known to ship different defaults for these). + + Parameters + ---------- + slack_bus_ids : str or iterable of str, optional + Forwarded to :func:`remove_outer_loops`. + **overrides + Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to set + before removing the outer loops (e.g. ``voltage_init_mode=...``). + Only applied if the installed pypowsybl accepts that keyword. + + Returns + ------- + pypowsybl.loadflow.Parameters + A new object on each call (no shared mutable state). + """ + kwargs = {} for name, value in overrides.items(): - put(name, value) - return _lf.Parameters(**kwargs) + if name in _PARAM_SIG: + kwargs[name] = value + return remove_outer_loops(_lf.Parameters(**kwargs), slack_bus_ids=slack_bus_ids) def get_pypowsybl_loopfree_distributed_slack_parameters( @@ -261,41 +329,36 @@ def get_pypowsybl_loopfree_distributed_slack_parameters( ) -> _lf.Parameters: """Loop-free OLF parameters EXCEPT the active-power slack distribution. - Identical to :func:`get_pypowsybl_loopfree_parameters` but with OLF's - *distributed slack* enabled: the active-power mismatch is shared over the - participating generators proportionally to their maximum active power - (``PROPORTIONAL_TO_GENERATION_P_MAX`` -- already the loop-free balance type), - which is what lightsim2grid's default distributed slack reproduces. Every - *other* outer loop stays disabled, so OLF (loop-free + distributed slack) and - lightsim2grid agree on a baked grid. - - Implementation note: in OpenLoadFlow the slack distribution is itself an AC - outer loop (``"DistributedSlack"``), so the empty ``outerLoopNames`` allow-list - used by the loop-free factory would suppress it. Here that allow-list is set to - exactly that one loop, and the outer-loop iteration cap (pinned to ``1`` by the - loop-free factory, where it is moot) is restored to ``max_outer_loop_iterations`` - so the distribution can iterate to convergence. On OpenLoadFlow older than - 1.4.0 the empty allow-list is not used, and ``distributed_slack=True`` alone - enables the distribution. + Identical to :func:`get_pypowsybl_loopfree_parameters` but keeps OLF's + ``DistributedSlack`` outer loop active, with ``balance_type`` set to + ``PROPORTIONAL_TO_GENERATION_P_MAX`` -- what lightsim2grid's default + distributed slack reproduces. Every *other* outer loop is removed. Parameters ---------- slack_bus_ids : str or iterable of str, optional - Forwarded to :func:`get_pypowsybl_loopfree_parameters`. + Forwarded to :func:`remove_outer_loops`. max_outer_loop_iterations : int - Outer-loop iteration cap for the distribution (default 20, the upstream - OLF default). + Outer-loop iteration cap for the distribution (default 20, the + upstream OLF default). **overrides - Forwarded to :func:`get_pypowsybl_loopfree_parameters`. + Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to set + before removing the outer loops. Only applied if the installed + pypowsybl accepts that keyword. """ overrides.setdefault("distributed_slack", True) - params = get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_bus_ids, **overrides) - prov = dict(params.provider_parameters) - # Allow ONLY the distributed-slack outer loop (kept out by the empty allow-list - # otherwise); restore a usable outer-loop budget so it can converge. - if "outerLoopNames" in prov: - prov["outerLoopNames"] = "DistributedSlack" - if "maxOuterLoopIterations" in prov: - prov["maxOuterLoopIterations"] = str(int(max_outer_loop_iterations)) - params.provider_parameters = prov - return params + bt = _enum("BalanceType") + if bt is not None: + overrides.setdefault("balance_type", bt.PROPORTIONAL_TO_GENERATION_P_MAX) + + kwargs = {} + for name, value in overrides.items(): + if name in _PARAM_SIG: + kwargs[name] = value + params = _lf.Parameters(**kwargs) + return remove_outer_loops( + params, + keep=("DistributedSlack",), + slack_bus_ids=slack_bus_ids, + max_outer_loop_iterations=max_outer_loop_iterations, + ) diff --git a/lightsim2grid/tests/test_olf_bake.py b/lightsim2grid/tests/test_olf_bake.py index 06339b0f..98428a95 100644 --- a/lightsim2grid/tests/test_olf_bake.py +++ b/lightsim2grid/tests/test_olf_bake.py @@ -31,7 +31,7 @@ init_from_pypowsybl, bake_outer_loops, compare_baked, - get_pypowsybl_loopfree_parameters, + remove_outer_loops, ) HAS_PYPOWSYBL = True except ImportError: @@ -98,11 +98,12 @@ def _with_loops_params(): # Every field is pinned to the ``venv_ls`` (pypowsybl 1.15.0) default so the # outer-loop solve is identical across pypowsybl builds (see - # ``_REF_PROVIDER_PARAMS``). The single intentional deviation from that default - # is ``twt_split_shunt_admittance=True`` (default is False): it matches the - # transformer model the loop-free / lightsim2grid solve uses (see - # get_pypowsybl_loopfree_parameters), which isolates the outer-loop effect that - # baking neutralizes. + # ``_REF_PROVIDER_PARAMS``). The corresponding loop-free reference used + # throughout this file is ``remove_outer_loops(_with_loops_params())``, not a + # separately-defaulted factory: deriving it from this same object guarantees + # every field it does not touch (twt_split_shunt_admittance, component_mode, + # use_reactive_limits, voltage_init_mode, ...) is identical between the two + # runs, isolating the outer-loop effect that baking neutralizes. return lf.Parameters( voltage_init_mode=lf.VoltageInitMode.UNIFORM_VALUES, transformer_voltage_control_on=False, @@ -206,7 +207,7 @@ def _olf_roundtrip_max_dev(network_factory): 'without outer loop' test: the baked grid solved with every loop disabled must reproduce the original with-loops operating point.""" with_loops = _with_loops_params() - loop_free = get_pypowsybl_loopfree_parameters() + loop_free = remove_outer_loops(with_loops) n_ref = network_factory() lf.run_ac(n_ref, with_loops) @@ -283,7 +284,7 @@ def _assert_baked_is_inert(self, network_factory, tol_kv=TOL_VM_KV, tol_q=1e-2): unchanged. """ with_loops = _with_loops_params() - loop_free = get_pypowsybl_loopfree_parameters() + loop_free = remove_outer_loops(with_loops) # (A) with-loops vs loop-free on the baked grid n_with = network_factory() @@ -353,7 +354,7 @@ def test_olf_pv_pq_switch_both_directions(self): self.assertTrue(g.loc[["B1-G", "B2-G", "B8-G"], "voltage_regulator_on"].all()) # Loop-free re-solve reproduces the with-limits reactive powers. - res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + res = lf.run_ac(n, remove_outer_loops(_with_loops_params())) self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) q_redo = n.get_generators(attributes=["q"])["q"] self.assertLess((q_redo - q_ref).abs().max(), 1e-2) @@ -376,7 +377,7 @@ def test_olf_unbaked_loops_do_act_control(self): lf.run_ac(n_with, _with_loops_params()) v_with = n_with.get_buses()[["v_mag"]].copy() n_free = ieee14_forced_pv_pq() - lf.run_ac(n_free, get_pypowsybl_loopfree_parameters()) + lf.run_ac(n_free, remove_outer_loops(_with_loops_params())) v_free = n_free.get_buses()[["v_mag"]] cmp = v_with.join(v_free, lsuffix="_w", rsuffix="_f") self.assertGreater((cmp["v_mag_w"] - cmp["v_mag_f"]).abs().max(), 1e-2) @@ -447,7 +448,7 @@ def test_olf_reactive_range_too_small_frozen(self): # other generators (plain MIN_MAX, ample range) stay untouched self.assertTrue(g.loc[["B1-G", "B3-G", "B6-G", "B8-G"], "voltage_regulator_on"].all()) - res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + res = lf.run_ac(n, remove_outer_loops(_with_loops_params())) self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) q_redo = n.get_generators(attributes=["q"]).loc["B2-G", "q"] self.assertLess(abs(q_redo - q_ref), 1e-2) diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index 85cd5515..d50b9b31 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -100,8 +100,16 @@ class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy virtual real_type scale(const NRSystem& system, const RealVect & F) final { - // TODO speed Save current merit (||mismatch||^2 before step) - const real_type F_norm_sq_0 = F.squaredNorm(); + // Current merit (||mismatch(x)||^2 before the step). By the time scale() + // runs, F has already been overwritten in place by the linear solve + // (J*dx=F -> F becomes dx): F.squaredNorm() would be ||dx||^2, NOT the + // current mismatch -- the wrong quantity for the Armijo condition below. + // mismatch_sq_norm_at(zero) evaluates the residual at the CURRENT state + // (zero step), which is exactly ||mismatch(x)||^2 -- bit-identical to + // system.mismatch().squaredNorm(), since _compute_trial_V(zero) + // reconstructs the current V unchanged. + const real_type F_norm_sq_0 = + system.mismatch_sq_norm_at(RealVect::Zero(F.size())); real_type alpha = static_cast(1.0); for (int k = 0; k < ls_max_iter_; ++k) { const real_type threshold = @@ -114,12 +122,12 @@ class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy } // virtual void update(const NRSystem& system, const RealVect & F) final { - // F_norm_sq_0_ = F.squaredNorm(); - // } + // F_norm_sq_0_ = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); + // } // should be called each iteration of NR // real_type F_norm_sq_0() const {return F_norm_sq_0_;} - // void F_norm_sq_0(real_type val) {F_norm_sq_0_ = val;} // F_norm_sq_0 = F.squaredNorm() + // void F_norm_sq_0(real_type val) {F_norm_sq_0_ = val;} // ||mismatch(x)||^2 before the step // getter / setter int ls_max_iter() const {return ls_max_iter_;} @@ -147,8 +155,15 @@ class IwamotoScalingPolicy final : public ScalingPolicy virtual real_type scale(const NRSystem& system, const RealVect & F) final { - // TODO speed this is already computed in NR (in previous step) - real_type g0 = F.squaredNorm(); + // g0 = ||mismatch(x)||^2 (current state, BEFORE the step) -- see the + // identical note in LineSearchScalingPolicy::scale: F is already dx by + // this point (overwritten in place by the linear solve), so + // F.squaredNorm() would be ||dx||^2, not the current mismatch. g1 below + // is ||mismatch(x + F)||^2 (the FULL, unscaled Newton step), so mixing + // ||dx||^2 into the same ratio as g0 would compare a state-step quantity + // against a mismatch quantity -- dimensionally wrong for the classic + // Iwamoto optimal-multiplier ratio mu = g0/(g0+g1). + real_type g0 = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); real_type g1 = system.mismatch_sq_norm_at(F); real_type mu = (g0 + g1 > static_cast(0.)) @@ -159,12 +174,12 @@ class IwamotoScalingPolicy final : public ScalingPolicy } // virtual void update(const NRSystem& system, const RealVect & F) final { - // g0_ = F.squaredNorm(); + // g0_ = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); // g1_ = system.mismatch_sq_norm_at(F); - // } + // } // should be called each iteration of NR - // real_type g0() const {return g0_;} // F.squaredNorm(); + // real_type g0() const {return g0_;} // ||mismatch(x)||^2 before the step // void g0(real_type val) {g0_ = val;} // real_type g1() const {return g1_;} // _system.mismatch_sq_norm_at(F); From a630497ac7276bd34d54acf533edd9105953ce88 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 14:18:19 +0200 Subject: [PATCH 088/166] fix the 'remove outer loops' from OLF parameters Signed-off-by: DONNOT Benjamin --- lightsim2grid/network/__init__.py | 2 + .../network/from_pypowsybl/__init__.py | 2 + .../network/from_pypowsybl/_olf_compare.py | 14 +- .../network/from_pypowsybl/_olf_params.py | 403 ++++++++++-------- lightsim2grid/tests/test_olf_bake.py | 23 +- .../powerflow_algorithm/ScalingPolicies.hpp | 35 +- 6 files changed, 284 insertions(+), 195 deletions(-) diff --git a/lightsim2grid/network/__init__.py b/lightsim2grid/network/__init__.py index 446ea0b0..83f39c5a 100644 --- a/lightsim2grid/network/__init__.py +++ b/lightsim2grid/network/__init__.py @@ -21,12 +21,14 @@ try: from lightsim2grid.network.from_pypowsybl import init as init_from_pypowsybl # noqa from lightsim2grid.network.from_pypowsybl import bake_outer_loops # noqa + from lightsim2grid.network.from_pypowsybl import remove_outer_loops # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_parameters # noqa from lightsim2grid.network.from_pypowsybl import get_pypowsybl_loopfree_distributed_slack_parameters # noqa from lightsim2grid.network.from_pypowsybl import compare_baked, ComparisonResult # noqa from lightsim2grid.network.from_pypowsybl import LightsimResultNetwork # noqa __all__.append("init_from_pypowsybl") __all__.append("bake_outer_loops") + __all__.append("remove_outer_loops") __all__.append("get_pypowsybl_loopfree_parameters") __all__.append("get_pypowsybl_loopfree_distributed_slack_parameters") __all__.append("compare_baked") diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index b2f8dd44..8ea891d8 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -9,6 +9,7 @@ __all__ = ["init", "dangling_line_boundary_bus", "bake_outer_loops", + "remove_outer_loops", "get_pypowsybl_loopfree_parameters", "get_pypowsybl_loopfree_distributed_slack_parameters", "compare_baked", @@ -18,6 +19,7 @@ from ._from_pypowsybl import init, dangling_line_boundary_bus from ._olf_bake import bake_outer_loops from ._olf_params import ( + remove_outer_loops, get_pypowsybl_loopfree_parameters, get_pypowsybl_loopfree_distributed_slack_parameters, ) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_compare.py b/lightsim2grid/network/from_pypowsybl/_olf_compare.py index 71c135af..1e2febd9 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_compare.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_compare.py @@ -17,8 +17,9 @@ network (see ``_olf_bake``). 3. Optionally apply the same topology change (e.g. a line outage) to both engines. -4. Solve the baked network loop-free in OLF (see - :func:`get_pypowsybl_loopfree_parameters`) and in lightsim2grid. +4. Solve the baked network loop-free in OLF (see :func:`remove_outer_loops`, + applied to the same ``olf_loop_params`` used for step 1) and in + lightsim2grid. 5. Compare bus voltage magnitude (pu) and angle (deg). Bus mapping @@ -42,7 +43,7 @@ from ._from_pypowsybl import init as init_from_pypowsybl from ._olf_bake import bake_outer_loops -from ._olf_params import get_pypowsybl_loopfree_parameters +from ._olf_params import remove_outer_loops def iidm_bus_voltages(network) -> pd.DataFrame: @@ -153,7 +154,12 @@ def compare_baked( balance_type=pp.loadflow.BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX, twt_split_shunt_admittance=True, ) - loop_free_params = get_pypowsybl_loopfree_parameters() + # Derived from ``olf_loop_params`` (not built independently): this guarantees + # every field the with-loops and loop-free solves do not differ on by design + # (twt_split_shunt_admittance, component_mode, voltage_init_mode, ...) is + # actually identical between them, so the comparison isolates the outer-loop + # effect that baking neutralizes rather than an unrelated model difference. + loop_free_params = remove_outer_loops(olf_loop_params) # ---- OLF side: with-loops solve, bake, outage, loop-free solve ---- n_olf = network_factory() diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 3b10a108..3d6fc836 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -7,34 +7,48 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """ -Canonical PowSyBl Open Load Flow (OLF) parameters that disable *every* outer -loop, so that OLF solves the same single-shot power-flow problem lightsim2grid -solves. - -This is the single source of truth used by the lightsim2grid benchmarks, the -documentation, the test suite and the OLF-vs-lightsim2grid comparison helper. -Disabling the outer loops is what makes a *raw* (un-baked) network give -consistent results between the two engines on grids where no outer loop would -trigger anyway; on grids where the loops do act, combine it with -:func:`lightsim2grid.network.bake_outer_loops`. - -The factory is written to be consistent across **every** pypowsybl version from -1.0.0 to 1.15.0. The pypowsybl ``Parameters`` constructor and the OLF provider -parameters changed over time (renamed kwargs, new enums, parameters that did not -exist yet, and values rejected by older releases). Rather than hard-code one -version, the factory introspects the installed pypowsybl and only passes what it -accepts: - -* top-level kwargs are filtered against ``Parameters.__init__`` and renamed - parameters fall back to their old spelling (``use_reactive_limits`` -> - ``no_generator_reactive_limits``, ``shunt_compensator_voltage_control_on`` -> - ``simul_shunt``, ``component_mode`` -> ``connected_component_mode``); -* enums are resolved from wherever the installed version exposes them; -* provider parameters are filtered against the OLF metadata (valid name *and*, - for enum-valued ones, valid value); -* the empty ``outerLoopNames`` allow-list is only used from pypowsybl 1.4.0 on, - because older OLF rejects the empty value ("Unknown outer loop ''"). Below - 1.4.0 the explicit trigger flags already disable every outer loop that exists. +Remove PowSyBl Open Load Flow (OLF) *outer loops* from a set of pypowsybl +loadflow parameters, without touching anything else. + +:func:`remove_outer_loops` is the single source of truth for "make OLF solve +the same single-shot power-flow problem lightsim2grid solves". It takes a +caller-supplied :class:`pypowsybl.loadflow.Parameters` and strips out the +outer-loop *mechanism* only: it does not change reactive limits, remote +voltage control, voltage initialization, balance type, or any other modeling +choice, because none of those are outer loops -- forcing them changes the +solved answer for reasons unrelated to outer loops (and, for +``voltage_init_mode`` in particular, can push OLF into a different +Newton-Raphson root on a multi-root grid, contaminating a comparison meant to +isolate the outer-loop effect). + +Background: OLF's outer loops fall into two families. + +* Some have an inline alternative: ``transformerVoltageControlMode``, + ``shuntVoltageControlMode`` and ``phaseShifterControlMode`` can each be + switched from an outer-loop-based mode to a mode that folds the same + control into the inner Newton-Raphson equations. Switching the mode keeps + the modeled control (e.g. transformer voltage regulation) *active*, it just + removes the separate outer-loop pass -- this is real outer-loop removal + that changes nothing else, and it works on every pypowsybl version. +* Others (``DistributedSlack``, ``ReactiveLimits``, ``VoltageMonitoring``, + ``SecondaryVoltageControl``, ``AreaInterchangeControl``, + ``AutomationSystem``) have no inline equivalent. From pypowsybl 1.4.0 on, + the ``outerLoopNames`` provider parameter is an *allow-list*: only loops + named there are ever created, regardless of any other trigger flag + (verified empirically: ``distributed_slack=True`` with an empty + ``outerLoopNames`` does not distribute). So on 1.4.0+, this allow-list is + the only lever this module needs to touch for that family. Below 1.4.0, + where OLF rejects an empty ``outerLoopNames`` (and the parameter does not + exist at all in 1.0.0), the individual creation-trigger flags are the only + way to stop a loop, so they are forced off there instead -- and only there. + +The factory (both this module's ``remove_outer_loops`` and the +``get_pypowsybl_loopfree_*`` convenience wrappers below) is written to be +consistent across every pypowsybl version from 1.0.0 to 1.15.0: it only +passes constructor keywords and provider parameters the installed version +accepts, resolving renamed kwargs (``use_reactive_limits`` -> +``no_generator_reactive_limits``, ``shunt_compensator_voltage_control_on`` -> +``simul_shunt``) and enums from wherever the installed version exposes them. """ import inspect @@ -60,8 +74,7 @@ def _ver_tuple(v: str): _PARAM_SIG = set(inspect.signature(_lf.Parameters.__init__).parameters) _PYPOWSYBL_VER = _ver_tuple(_pp.__version__) # OLF rejects an empty ``outerLoopNames`` value before 1.4.0 (and the parameter -# does not exist at all in 1.0.0); only use the empty-allow-list trick where it -# is accepted. +# does not exist at all in 1.0.0); only use the allow-list where it is accepted. _OUTERLOOP_EMPTY_OK = _PYPOWSYBL_VER >= (1, 4) # Lazily-computed cache of the OLF provider parameter metadata @@ -112,146 +125,201 @@ def _olf_provider_params(): return result -# The full "ZERO outer loops" OLF provider-parameter wish-list. Entries that the -# installed OLF does not know (or whose value it rejects) are filtered out. -_DESIRED_PROVIDER = { - # ---- empty allow-list: ZERO outer loops, future-proof (>= 1.4.0) ---- - "outerLoopNames": "", - # ---- every outer-loop creation trigger forced off (belt and suspenders) ---- - "areaInterchangeControl": "false", - "svcVoltageMonitoring": "false", - "secondaryVoltageControl": "false", - "transformerReactivePowerControl": "false", - "simulateAutomationSystems": "false", - "voltageRemoteControl": "false", - "generatorVoltageRemoteControl": "false", - # ---- control modes pushed to inner-loop / harmless ---- - "transformerVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", - "shuntVoltageControlMode": "WITH_GENERATOR_VOLTAGE_CONTROL", - "phaseShifterControlMode": "CONTINUOUS_WITH_DISCRETISATION", - # ---- NR inner-solver settings forced (the only thing left active) ---- - "maxNewtonRaphsonIterations": "15", - "newtonRaphsonConvEpsPerEq": "1.0E-4", - "stateVectorScalingMode": "NONE", - "lineSearchStateVectorScalingMaxIteration": "10", # inert with NONE; forced anyway - # ---- hard backstop (0 is rejected by OLF; 1 is moot with empty allow-list) ---- - "maxOuterLoopIterations": "1", - "slackDistributionFailureBehavior": "FAIL", +# Outer loops with an inline (inner-Newton-Raphson) alternative: switching the +# mode keeps the modeled control active while removing the separate outer-loop +# pass. Works on every pypowsybl version, so these are always applied unless +# the caller explicitly asks to ``keep`` the outer-loop-based mode. +_INLINE_MODE = { + "TransformerVoltageControl": ("transformerVoltageControlMode", "WITH_GENERATOR_VOLTAGE_CONTROL"), + "ShuntVoltageControl": ("shuntVoltageControlMode", "WITH_GENERATOR_VOLTAGE_CONTROL"), + "PhaseControl": ("phaseShifterControlMode", "CONTINUOUS_WITH_DISCRETISATION"), } +# Outer loops with no inline alternative. ``None`` marks a top-level +# Parameters kwarg (handled specially in ``remove_outer_loops`` because of the +# renamed-kwarg fallback); a tuple marks a provider parameter trigger. +_LEGACY_TRIGGER = { + "DistributedSlack": None, # top-level `distributed_slack` + "ReactiveLimits": None, # top-level `use_reactive_limits` / `no_generator_reactive_limits` + "VoltageMonitoring": ("svcVoltageMonitoring", "false"), + "SecondaryVoltageControl": ("secondaryVoltageControl", "false"), + "AreaInterchangeControl": ("areaInterchangeControl", "false"), + "AutomationSystem": ("simulateAutomationSystems", "false"), +} -def get_pypowsybl_loopfree_parameters( +_ALL_LOOP_NAMES = set(_INLINE_MODE) | set(_LEGACY_TRIGGER) + + +def _copy_parameters(parameters: _lf.Parameters) -> _lf.Parameters: + """Return an independent copy of ``parameters``. + + Built by copying each constructor-exposed attribute one at a time + (``getattr``/``setattr``) rather than via ``copy.deepcopy``: ``Parameters`` + is a pybind11-wrapped object backed by a native (Java) instance, and + whether the generic copy protocol produces a truly independent object -- + as opposed to a new Python wrapper aliasing the same underlying native + state -- is not guaranteed to hold the same way on every pypowsybl version + this module supports (1.0.0 to 1.15.0). Explicit getattr/setattr only + relies on the property interface already used everywhere else here. + """ + out = _lf.Parameters() + for name in _PARAM_SIG: + if name in ("self", "provider_parameters"): + continue + try: + value = getattr(parameters, name) + except AttributeError: + continue + setattr(out, name, value) + out.provider_parameters = dict(parameters.provider_parameters) + return out + + +def remove_outer_loops( + parameters: _lf.Parameters, + keep: Iterable[str] = (), slack_bus_ids: Optional[Union[str, Iterable[str]]] = None, - **overrides, + max_outer_loop_iterations: int = 20, ) -> _lf.Parameters: - """Build a fresh :class:`pypowsybl.loadflow.Parameters` with every OLF outer - loop disabled. + """Return a copy of ``parameters`` with OLF's outer loops removed. - The returned parameters disable distributed slack, reactive limits, - transformer / shunt / phase-shifter voltage control, area-interchange and - secondary-voltage control, automation systems, etc. From pypowsybl 1.4.0 on, - the empty ``outerLoopNames`` allow-list also registers *zero* outer loops - regardless of which loops a future OLF release adds; on older pypowsybl the - explicit trigger flags below disable every outer loop that exists. - - The factory is consistent across pypowsybl 1.0.0 .. 1.15.0: it only passes - constructor keywords and provider parameters the installed version accepts - (see the module docstring). + Only the outer-loop mechanism is touched: reactive limits, remote voltage + control, voltage initialization, balance type, connected-component mode, + and every other field of ``parameters`` are left exactly as given. Parameters ---------- + parameters : pypowsybl.loadflow.Parameters + The parameters to strip outer loops from. Not mutated; a modified + copy is returned. + keep : iterable of str, optional + Names of outer loops to leave active instead of removing. Valid + names: ``"TransformerVoltageControl"``, ``"ShuntVoltageControl"``, + ``"PhaseControl"`` (inline-mode family), ``"DistributedSlack"``, + ``"ReactiveLimits"``, ``"VoltageMonitoring"``, + ``"SecondaryVoltageControl"``, ``"AreaInterchangeControl"``, + ``"AutomationSystem"`` (no-inline-alternative family). Keeping a loop + only stops *this function* from removing it -- for a no-inline- + alternative loop this function does not force its own creation + trigger on, so it still runs only if ``parameters`` already enables + it (e.g. ``distributed_slack=True`` for ``"DistributedSlack"``). slack_bus_ids : str or iterable of str, optional If given, the slack is pinned by NAME on these bus(es) (``slackBusSelectionMode = "NAME"``) and ``read_slack_bus`` is turned - off. This is what the benchmarks need to use the same slack as - lightsim2grid. If ``None`` (default), the slack is read from the network - (``read_slack_bus = True``). - **overrides - Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to override - on the returned object (e.g. ``distributed_slack=True``). Only applied if - the installed pypowsybl accepts that keyword. + off. Unrelated to outer-loop removal; a convenience for matching + lightsim2grid's slack choice. + max_outer_loop_iterations : int + Outer-loop iteration budget, only set when ``keep`` is non-empty (so + a kept loop, e.g. distributed slack, has room to converge). Ignored + otherwise. Returns ------- pypowsybl.loadflow.Parameters - A new object on each call (no shared mutable state). - - Notes - ----- - ``maxOuterLoopIterations`` is set to ``"1"`` because OLF rejects ``"0"``; - with an empty ``outerLoopNames`` the iteration cap is moot. Verified to - converge on IEEE-14 against every pypowsybl from 1.0.0 to 1.15.0. + A new object; ``parameters`` is not modified. """ - kwargs = {} + keep = set(keep) + unknown = keep - _ALL_LOOP_NAMES + if unknown: + raise ValueError( + f"Unknown outer loop(s) in `keep`: {sorted(unknown)}. " + f"Valid names: {sorted(_ALL_LOOP_NAMES)}" + ) + + params = _copy_parameters(parameters) def put(name, value): if name in _PARAM_SIG: - kwargs[name] = value + setattr(params, name, value) return True return False - put("distributed_slack", False) - put("transformer_voltage_control_on", False) - put("phase_shifter_regulation_on", False) - put("write_slack_bus", True) - put("dc_use_transformer_ratio", True) - put("twt_split_shunt_admittance", True) - - vim = _enum("VoltageInitMode") - if vim is not None: - put("voltage_init_mode", vim.UNIFORM_VALUES) - bt = _enum("BalanceType") - if bt is not None: - # forced; inert with distributed slack off - put("balance_type", bt.PROPORTIONAL_TO_GENERATION_P_MAX) - - # reactive limits: new ``use_reactive_limits`` vs old ``no_generator_reactive_limits`` - if not put("use_reactive_limits", False): - put("no_generator_reactive_limits", True) - # shunt voltage control: new ``shunt_compensator_voltage_control_on`` vs old ``simul_shunt`` - if not put("shunt_compensator_voltage_control_on", False): - put("simul_shunt", False) - - read_slack_bus = slack_bus_ids is None - put("read_slack_bus", read_slack_bus) - - # connected component: ``component_mode`` (>= 1.15) vs ``connected_component_mode`` - if "component_mode" in _PARAM_SIG: - cm = _enum("ComponentMode") - if cm is not None and hasattr(cm, "MAIN_CONNECTED"): - kwargs["component_mode"] = cm.MAIN_CONNECTED - elif "connected_component_mode" in _PARAM_SIG: - ccm = _enum("ConnectedComponentMode") - if ccm is not None and hasattr(ccm, "MAIN"): - kwargs["connected_component_mode"] = ccm.MAIN - - desired = dict(_DESIRED_PROVIDER) - if not _OUTERLOOP_EMPTY_OK: - desired.pop("outerLoopNames", None) - if slack_bus_ids is not None: - if not isinstance(slack_bus_ids, str): - slack_bus_ids = ",".join(str(s) for s in slack_bus_ids) - desired["slackBusSelectionMode"] = "NAME" - desired["slackBusesIds"] = f"{slack_bus_ids}" - valid = _olf_provider_params() - if valid is not None: - provider_parameters = {} - for name, value in desired.items(): + prov = dict(params.provider_parameters) + + def put_provider(name, value): + if valid is not None: if name not in valid: - continue # parameter unknown in this pypowsybl version + return # parameter unknown in this pypowsybl/OLF version possible = valid[name] if possible is not None and value not in possible: - continue # value not accepted in this version - provider_parameters[name] = value + return # value not accepted in this version + prov[name] = value + + # 1. Inline-mode loops: fold into the inner NR loop unless kept as an + # outer loop. Version-independent. + for loop, (pname, value) in _INLINE_MODE.items(): + if loop not in keep: + put_provider(pname, value) + + # 2. Loops with no inline alternative. + if _OUTERLOOP_EMPTY_OK: + # The allow-list alone gates creation, regardless of any trigger flag, + # so nothing else needs touching here. + put_provider("outerLoopNames", ",".join(sorted(keep & set(_LEGACY_TRIGGER)))) + if keep: + put_provider("maxOuterLoopIterations", str(int(max_outer_loop_iterations))) else: - provider_parameters = desired - put("provider_parameters", provider_parameters) + # Pre-1.4 OLF: no allow-list: the only way to stop a loop is its own + # creation trigger, forced off unless the caller asked to keep it. + for loop, spec in _LEGACY_TRIGGER.items(): + if loop in keep: + continue + if spec is None: + if loop == "DistributedSlack": + put("distributed_slack", False) + elif loop == "ReactiveLimits": + if not put("use_reactive_limits", False): + put("no_generator_reactive_limits", True) + else: + pname, value = spec + put_provider(pname, value) + + if slack_bus_ids is not None: + if not isinstance(slack_bus_ids, str): + slack_bus_ids = ",".join(str(s) for s in slack_bus_ids) + put_provider("slackBusSelectionMode", "NAME") + put_provider("slackBusesIds", slack_bus_ids) + put("read_slack_bus", False) - # only forward overrides the installed Parameters constructor accepts + params.provider_parameters = prov + return params + + +def get_pypowsybl_loopfree_parameters( + slack_bus_ids: Optional[Union[str, Iterable[str]]] = None, + **overrides, +) -> _lf.Parameters: + """Build a fresh :class:`pypowsybl.loadflow.Parameters` with every OLF + outer loop removed (see :func:`remove_outer_loops`). + + Everything other than the outer-loop mechanism is left at the installed + pypowsybl's own defaults (or at ``**overrides``, if given) -- in + particular ``voltage_init_mode``, reactive limits and remote voltage + control are **not** forced, since none of those are outer loops. If a + test needs a specific value for one of those to be reproducible across + pypowsybl builds, pass it explicitly via ``**overrides`` (different + pypowsybl builds are known to ship different defaults for these). + + Parameters + ---------- + slack_bus_ids : str or iterable of str, optional + Forwarded to :func:`remove_outer_loops`. + **overrides + Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to set + before removing the outer loops (e.g. ``voltage_init_mode=...``). + Only applied if the installed pypowsybl accepts that keyword. + + Returns + ------- + pypowsybl.loadflow.Parameters + A new object on each call (no shared mutable state). + """ + kwargs = {} for name, value in overrides.items(): - put(name, value) - return _lf.Parameters(**kwargs) + if name in _PARAM_SIG: + kwargs[name] = value + return remove_outer_loops(_lf.Parameters(**kwargs), slack_bus_ids=slack_bus_ids) def get_pypowsybl_loopfree_distributed_slack_parameters( @@ -261,41 +329,36 @@ def get_pypowsybl_loopfree_distributed_slack_parameters( ) -> _lf.Parameters: """Loop-free OLF parameters EXCEPT the active-power slack distribution. - Identical to :func:`get_pypowsybl_loopfree_parameters` but with OLF's - *distributed slack* enabled: the active-power mismatch is shared over the - participating generators proportionally to their maximum active power - (``PROPORTIONAL_TO_GENERATION_P_MAX`` -- already the loop-free balance type), - which is what lightsim2grid's default distributed slack reproduces. Every - *other* outer loop stays disabled, so OLF (loop-free + distributed slack) and - lightsim2grid agree on a baked grid. - - Implementation note: in OpenLoadFlow the slack distribution is itself an AC - outer loop (``"DistributedSlack"``), so the empty ``outerLoopNames`` allow-list - used by the loop-free factory would suppress it. Here that allow-list is set to - exactly that one loop, and the outer-loop iteration cap (pinned to ``1`` by the - loop-free factory, where it is moot) is restored to ``max_outer_loop_iterations`` - so the distribution can iterate to convergence. On OpenLoadFlow older than - 1.4.0 the empty allow-list is not used, and ``distributed_slack=True`` alone - enables the distribution. + Identical to :func:`get_pypowsybl_loopfree_parameters` but keeps OLF's + ``DistributedSlack`` outer loop active, with ``balance_type`` set to + ``PROPORTIONAL_TO_GENERATION_P_MAX`` -- what lightsim2grid's default + distributed slack reproduces. Every *other* outer loop is removed. Parameters ---------- slack_bus_ids : str or iterable of str, optional - Forwarded to :func:`get_pypowsybl_loopfree_parameters`. + Forwarded to :func:`remove_outer_loops`. max_outer_loop_iterations : int - Outer-loop iteration cap for the distribution (default 20, the upstream - OLF default). + Outer-loop iteration cap for the distribution (default 20, the + upstream OLF default). **overrides - Forwarded to :func:`get_pypowsybl_loopfree_parameters`. + Any top-level :class:`pypowsybl.loadflow.Parameters` keyword to set + before removing the outer loops. Only applied if the installed + pypowsybl accepts that keyword. """ overrides.setdefault("distributed_slack", True) - params = get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_bus_ids, **overrides) - prov = dict(params.provider_parameters) - # Allow ONLY the distributed-slack outer loop (kept out by the empty allow-list - # otherwise); restore a usable outer-loop budget so it can converge. - if "outerLoopNames" in prov: - prov["outerLoopNames"] = "DistributedSlack" - if "maxOuterLoopIterations" in prov: - prov["maxOuterLoopIterations"] = str(int(max_outer_loop_iterations)) - params.provider_parameters = prov - return params + bt = _enum("BalanceType") + if bt is not None: + overrides.setdefault("balance_type", bt.PROPORTIONAL_TO_GENERATION_P_MAX) + + kwargs = {} + for name, value in overrides.items(): + if name in _PARAM_SIG: + kwargs[name] = value + params = _lf.Parameters(**kwargs) + return remove_outer_loops( + params, + keep=("DistributedSlack",), + slack_bus_ids=slack_bus_ids, + max_outer_loop_iterations=max_outer_loop_iterations, + ) diff --git a/lightsim2grid/tests/test_olf_bake.py b/lightsim2grid/tests/test_olf_bake.py index 06339b0f..98428a95 100644 --- a/lightsim2grid/tests/test_olf_bake.py +++ b/lightsim2grid/tests/test_olf_bake.py @@ -31,7 +31,7 @@ init_from_pypowsybl, bake_outer_loops, compare_baked, - get_pypowsybl_loopfree_parameters, + remove_outer_loops, ) HAS_PYPOWSYBL = True except ImportError: @@ -98,11 +98,12 @@ def _with_loops_params(): # Every field is pinned to the ``venv_ls`` (pypowsybl 1.15.0) default so the # outer-loop solve is identical across pypowsybl builds (see - # ``_REF_PROVIDER_PARAMS``). The single intentional deviation from that default - # is ``twt_split_shunt_admittance=True`` (default is False): it matches the - # transformer model the loop-free / lightsim2grid solve uses (see - # get_pypowsybl_loopfree_parameters), which isolates the outer-loop effect that - # baking neutralizes. + # ``_REF_PROVIDER_PARAMS``). The corresponding loop-free reference used + # throughout this file is ``remove_outer_loops(_with_loops_params())``, not a + # separately-defaulted factory: deriving it from this same object guarantees + # every field it does not touch (twt_split_shunt_admittance, component_mode, + # use_reactive_limits, voltage_init_mode, ...) is identical between the two + # runs, isolating the outer-loop effect that baking neutralizes. return lf.Parameters( voltage_init_mode=lf.VoltageInitMode.UNIFORM_VALUES, transformer_voltage_control_on=False, @@ -206,7 +207,7 @@ def _olf_roundtrip_max_dev(network_factory): 'without outer loop' test: the baked grid solved with every loop disabled must reproduce the original with-loops operating point.""" with_loops = _with_loops_params() - loop_free = get_pypowsybl_loopfree_parameters() + loop_free = remove_outer_loops(with_loops) n_ref = network_factory() lf.run_ac(n_ref, with_loops) @@ -283,7 +284,7 @@ def _assert_baked_is_inert(self, network_factory, tol_kv=TOL_VM_KV, tol_q=1e-2): unchanged. """ with_loops = _with_loops_params() - loop_free = get_pypowsybl_loopfree_parameters() + loop_free = remove_outer_loops(with_loops) # (A) with-loops vs loop-free on the baked grid n_with = network_factory() @@ -353,7 +354,7 @@ def test_olf_pv_pq_switch_both_directions(self): self.assertTrue(g.loc[["B1-G", "B2-G", "B8-G"], "voltage_regulator_on"].all()) # Loop-free re-solve reproduces the with-limits reactive powers. - res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + res = lf.run_ac(n, remove_outer_loops(_with_loops_params())) self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) q_redo = n.get_generators(attributes=["q"])["q"] self.assertLess((q_redo - q_ref).abs().max(), 1e-2) @@ -376,7 +377,7 @@ def test_olf_unbaked_loops_do_act_control(self): lf.run_ac(n_with, _with_loops_params()) v_with = n_with.get_buses()[["v_mag"]].copy() n_free = ieee14_forced_pv_pq() - lf.run_ac(n_free, get_pypowsybl_loopfree_parameters()) + lf.run_ac(n_free, remove_outer_loops(_with_loops_params())) v_free = n_free.get_buses()[["v_mag"]] cmp = v_with.join(v_free, lsuffix="_w", rsuffix="_f") self.assertGreater((cmp["v_mag_w"] - cmp["v_mag_f"]).abs().max(), 1e-2) @@ -447,7 +448,7 @@ def test_olf_reactive_range_too_small_frozen(self): # other generators (plain MIN_MAX, ample range) stay untouched self.assertTrue(g.loc[["B1-G", "B3-G", "B6-G", "B8-G"], "voltage_regulator_on"].all()) - res = lf.run_ac(n, get_pypowsybl_loopfree_parameters()) + res = lf.run_ac(n, remove_outer_loops(_with_loops_params())) self.assertEqual(res[0].status, pp.loadflow.ComponentStatus.CONVERGED) q_redo = n.get_generators(attributes=["q"]).loc["B2-G", "q"] self.assertLess(abs(q_redo - q_ref), 1e-2) diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index 85cd5515..d50b9b31 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -100,8 +100,16 @@ class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy virtual real_type scale(const NRSystem& system, const RealVect & F) final { - // TODO speed Save current merit (||mismatch||^2 before step) - const real_type F_norm_sq_0 = F.squaredNorm(); + // Current merit (||mismatch(x)||^2 before the step). By the time scale() + // runs, F has already been overwritten in place by the linear solve + // (J*dx=F -> F becomes dx): F.squaredNorm() would be ||dx||^2, NOT the + // current mismatch -- the wrong quantity for the Armijo condition below. + // mismatch_sq_norm_at(zero) evaluates the residual at the CURRENT state + // (zero step), which is exactly ||mismatch(x)||^2 -- bit-identical to + // system.mismatch().squaredNorm(), since _compute_trial_V(zero) + // reconstructs the current V unchanged. + const real_type F_norm_sq_0 = + system.mismatch_sq_norm_at(RealVect::Zero(F.size())); real_type alpha = static_cast(1.0); for (int k = 0; k < ls_max_iter_; ++k) { const real_type threshold = @@ -114,12 +122,12 @@ class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy } // virtual void update(const NRSystem& system, const RealVect & F) final { - // F_norm_sq_0_ = F.squaredNorm(); - // } + // F_norm_sq_0_ = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); + // } // should be called each iteration of NR // real_type F_norm_sq_0() const {return F_norm_sq_0_;} - // void F_norm_sq_0(real_type val) {F_norm_sq_0_ = val;} // F_norm_sq_0 = F.squaredNorm() + // void F_norm_sq_0(real_type val) {F_norm_sq_0_ = val;} // ||mismatch(x)||^2 before the step // getter / setter int ls_max_iter() const {return ls_max_iter_;} @@ -147,8 +155,15 @@ class IwamotoScalingPolicy final : public ScalingPolicy virtual real_type scale(const NRSystem& system, const RealVect & F) final { - // TODO speed this is already computed in NR (in previous step) - real_type g0 = F.squaredNorm(); + // g0 = ||mismatch(x)||^2 (current state, BEFORE the step) -- see the + // identical note in LineSearchScalingPolicy::scale: F is already dx by + // this point (overwritten in place by the linear solve), so + // F.squaredNorm() would be ||dx||^2, not the current mismatch. g1 below + // is ||mismatch(x + F)||^2 (the FULL, unscaled Newton step), so mixing + // ||dx||^2 into the same ratio as g0 would compare a state-step quantity + // against a mismatch quantity -- dimensionally wrong for the classic + // Iwamoto optimal-multiplier ratio mu = g0/(g0+g1). + real_type g0 = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); real_type g1 = system.mismatch_sq_norm_at(F); real_type mu = (g0 + g1 > static_cast(0.)) @@ -159,12 +174,12 @@ class IwamotoScalingPolicy final : public ScalingPolicy } // virtual void update(const NRSystem& system, const RealVect & F) final { - // g0_ = F.squaredNorm(); + // g0_ = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); // g1_ = system.mismatch_sq_norm_at(F); - // } + // } // should be called each iteration of NR - // real_type g0() const {return g0_;} // F.squaredNorm(); + // real_type g0() const {return g0_;} // ||mismatch(x)||^2 before the step // void g0(real_type val) {g0_ = val;} // real_type g1() const {return g1_;} // _system.mismatch_sq_norm_at(F); From 594a5fdad1e1aaa9c3fe638e40ade88f411d1912 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 17:13:48 +0200 Subject: [PATCH 089/166] fix CI Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_params.py | 58 ++++++++++++++----- .../utils_for_slack.py | 14 ++++- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 3d6fc836..4b709276 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -37,10 +37,30 @@ named there are ever created, regardless of any other trigger flag (verified empirically: ``distributed_slack=True`` with an empty ``outerLoopNames`` does not distribute). So on 1.4.0+, this allow-list is - the only lever this module needs to touch for that family. Below 1.4.0, - where OLF rejects an empty ``outerLoopNames`` (and the parameter does not - exist at all in 1.0.0), the individual creation-trigger flags are the only - way to stop a loop, so they are forced off there instead -- and only there. + the only lever this module needs to touch for that family for an AC solve. + Below 1.4.0, where OLF rejects an empty ``outerLoopNames`` (and the + parameter does not exist at all in 1.0.0), the individual creation-trigger + flags are the only way to stop a loop, so they are forced off there instead. + + ``DistributedSlack`` and ``ReactiveLimits`` are the two exceptions to + "the allow-list is the only lever": both also have a top-level + ``Parameters`` flag (``distributed_slack``, ``use_reactive_limits``) that + is not reliably gated by ``outerLoopNames``. ``run_dc`` always honors + ``distributed_slack`` directly, with no outer-loop pass and no + ``outerLoopNames`` gating at all -- it still distributes the slack across + generators with ``distributed_slack`` left at its default (``True``) even + with an empty allow-list. ``use_reactive_limits`` is not DC-specific but is + just as unreliable: on pypowsybl 1.9.0, ``run_ac`` still clips a generator + to its reactive limit with an empty ``outerLoopNames`` and + ``use_reactive_limits`` left at its default (``True``), producing a + generator ``Q`` a few 1e-3 MVAr off from lightsim2grid's (which never + clips) -- confirmed fixed by forcing ``use_reactive_limits=False``, and + confirmed absent on pypowsybl 1.15.0 for the same grid/scenario, so this + looks like an OLF-side version difference in how much the allow-list + actually covers, not a stable "AC is always allow-list-gated" rule. So + these two top-level flags are always forced off here (unless kept), on + every pypowsybl version, in addition to whatever the allow-list/trigger-flag + branch below does for the other, allow-list-only loops. The factory (both this module's ``remove_outer_loops`` and the ``get_pypowsybl_loopfree_*`` convenience wrappers below) is written to be @@ -253,27 +273,33 @@ def put_provider(name, value): put_provider(pname, value) # 2. Loops with no inline alternative. + + # DistributedSlack / ReactiveLimits: top-level flags OLF's DC solve honors + # directly (see the module docstring) -- forced regardless of pypowsybl + # version and regardless of the outerLoopNames allow-list below, which + # only gates the AC outer-loop mechanism. + if "DistributedSlack" not in keep: + put("distributed_slack", False) + if "ReactiveLimits" not in keep: + if not put("use_reactive_limits", False): + put("no_generator_reactive_limits", True) + if _OUTERLOOP_EMPTY_OK: - # The allow-list alone gates creation, regardless of any trigger flag, - # so nothing else needs touching here. + # The allow-list alone gates AC outer-loop creation, regardless of any + # trigger flag, so nothing else needs touching here. put_provider("outerLoopNames", ",".join(sorted(keep & set(_LEGACY_TRIGGER)))) if keep: put_provider("maxOuterLoopIterations", str(int(max_outer_loop_iterations))) else: # Pre-1.4 OLF: no allow-list: the only way to stop a loop is its own # creation trigger, forced off unless the caller asked to keep it. + # DistributedSlack/ReactiveLimits (spec is None) were already handled + # above, uniformly across versions. for loop, spec in _LEGACY_TRIGGER.items(): - if loop in keep: + if loop in keep or spec is None: continue - if spec is None: - if loop == "DistributedSlack": - put("distributed_slack", False) - elif loop == "ReactiveLimits": - if not put("use_reactive_limits", False): - put("no_generator_reactive_limits", True) - else: - pname, value = spec - put_provider(pname, value) + pname, value = spec + put_provider(pname, value) if slack_bus_ids is not None: if not isinstance(slack_bus_ids, str): diff --git a/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py b/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py index 98cd5e15..9ef874c1 100644 --- a/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py +++ b/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py @@ -27,5 +27,17 @@ def get_pypowsybl_parameters(slack_voltage_level=None): # lightsim2grid.network.get_pypowsybl_loopfree_parameters). When a slack # voltage level is given, the slack is pinned by name so lightsim2grid and # pypowsybl use the same slack bus. - return get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_voltage_level) + # + # twt_split_shunt_admittance=True is not an outer loop (it is a Ybus- + # construction convention: whether a transformer's shunt admittance is + # split half/half between its two sides), so get_pypowsybl_loopfree_parameters + # deliberately does not force it -- OLF's own default is False, but + # lightsim2grid's from_pypowsybl converter always splits it, so it must be + # forced here for the two engines to solve the same problem (verified on + # IEEE-300, which has transformers with non-negligible shunt admittance: + # leaving this at OLF's default desyncs bus voltages by up to ~0.4 pu). + return get_pypowsybl_loopfree_parameters( + slack_bus_ids=slack_voltage_level, + twt_split_shunt_admittance=True, + ) From c4c1cff432c90f7670dbe9cac056459a92de29ca Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 13 Jul 2026 17:13:48 +0200 Subject: [PATCH 090/166] fix CI Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_params.py | 58 ++++++++++++++----- .../utils_for_slack.py | 14 ++++- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 3d6fc836..4b709276 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -37,10 +37,30 @@ named there are ever created, regardless of any other trigger flag (verified empirically: ``distributed_slack=True`` with an empty ``outerLoopNames`` does not distribute). So on 1.4.0+, this allow-list is - the only lever this module needs to touch for that family. Below 1.4.0, - where OLF rejects an empty ``outerLoopNames`` (and the parameter does not - exist at all in 1.0.0), the individual creation-trigger flags are the only - way to stop a loop, so they are forced off there instead -- and only there. + the only lever this module needs to touch for that family for an AC solve. + Below 1.4.0, where OLF rejects an empty ``outerLoopNames`` (and the + parameter does not exist at all in 1.0.0), the individual creation-trigger + flags are the only way to stop a loop, so they are forced off there instead. + + ``DistributedSlack`` and ``ReactiveLimits`` are the two exceptions to + "the allow-list is the only lever": both also have a top-level + ``Parameters`` flag (``distributed_slack``, ``use_reactive_limits``) that + is not reliably gated by ``outerLoopNames``. ``run_dc`` always honors + ``distributed_slack`` directly, with no outer-loop pass and no + ``outerLoopNames`` gating at all -- it still distributes the slack across + generators with ``distributed_slack`` left at its default (``True``) even + with an empty allow-list. ``use_reactive_limits`` is not DC-specific but is + just as unreliable: on pypowsybl 1.9.0, ``run_ac`` still clips a generator + to its reactive limit with an empty ``outerLoopNames`` and + ``use_reactive_limits`` left at its default (``True``), producing a + generator ``Q`` a few 1e-3 MVAr off from lightsim2grid's (which never + clips) -- confirmed fixed by forcing ``use_reactive_limits=False``, and + confirmed absent on pypowsybl 1.15.0 for the same grid/scenario, so this + looks like an OLF-side version difference in how much the allow-list + actually covers, not a stable "AC is always allow-list-gated" rule. So + these two top-level flags are always forced off here (unless kept), on + every pypowsybl version, in addition to whatever the allow-list/trigger-flag + branch below does for the other, allow-list-only loops. The factory (both this module's ``remove_outer_loops`` and the ``get_pypowsybl_loopfree_*`` convenience wrappers below) is written to be @@ -253,27 +273,33 @@ def put_provider(name, value): put_provider(pname, value) # 2. Loops with no inline alternative. + + # DistributedSlack / ReactiveLimits: top-level flags OLF's DC solve honors + # directly (see the module docstring) -- forced regardless of pypowsybl + # version and regardless of the outerLoopNames allow-list below, which + # only gates the AC outer-loop mechanism. + if "DistributedSlack" not in keep: + put("distributed_slack", False) + if "ReactiveLimits" not in keep: + if not put("use_reactive_limits", False): + put("no_generator_reactive_limits", True) + if _OUTERLOOP_EMPTY_OK: - # The allow-list alone gates creation, regardless of any trigger flag, - # so nothing else needs touching here. + # The allow-list alone gates AC outer-loop creation, regardless of any + # trigger flag, so nothing else needs touching here. put_provider("outerLoopNames", ",".join(sorted(keep & set(_LEGACY_TRIGGER)))) if keep: put_provider("maxOuterLoopIterations", str(int(max_outer_loop_iterations))) else: # Pre-1.4 OLF: no allow-list: the only way to stop a loop is its own # creation trigger, forced off unless the caller asked to keep it. + # DistributedSlack/ReactiveLimits (spec is None) were already handled + # above, uniformly across versions. for loop, spec in _LEGACY_TRIGGER.items(): - if loop in keep: + if loop in keep or spec is None: continue - if spec is None: - if loop == "DistributedSlack": - put("distributed_slack", False) - elif loop == "ReactiveLimits": - if not put("use_reactive_limits", False): - put("no_generator_reactive_limits", True) - else: - pname, value = spec - put_provider(pname, value) + pname, value = spec + put_provider(pname, value) if slack_bus_ids is not None: if not isinstance(slack_bus_ids, str): diff --git a/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py b/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py index 98cd5e15..9ef874c1 100644 --- a/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py +++ b/lightsim2grid/tests/test_match_with_pypowsybl/utils_for_slack.py @@ -27,5 +27,17 @@ def get_pypowsybl_parameters(slack_voltage_level=None): # lightsim2grid.network.get_pypowsybl_loopfree_parameters). When a slack # voltage level is given, the slack is pinned by name so lightsim2grid and # pypowsybl use the same slack bus. - return get_pypowsybl_loopfree_parameters(slack_bus_ids=slack_voltage_level) + # + # twt_split_shunt_admittance=True is not an outer loop (it is a Ybus- + # construction convention: whether a transformer's shunt admittance is + # split half/half between its two sides), so get_pypowsybl_loopfree_parameters + # deliberately does not force it -- OLF's own default is False, but + # lightsim2grid's from_pypowsybl converter always splits it, so it must be + # forced here for the two engines to solve the same problem (verified on + # IEEE-300, which has transformers with non-negligible shunt admittance: + # leaving this at OLF's default desyncs bus voltages by up to ~0.4 pu). + return get_pypowsybl_loopfree_parameters( + slack_bus_ids=slack_voltage_level, + twt_split_shunt_admittance=True, + ) From 41537bdcbb9eee17bebb381d6a9aed01ed2be27c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 17:18:13 +0000 Subject: [PATCH 091/166] Add dedicated C++ unit tests (Catch2) for BinaryArchive, run under valgrind in CI Item 4 of the serialization-hardening plan (after the ASan+UBSan job, the python corruption sweep and the debug-assertions job): a small Catch2 suite under src/tests/ exercising the archive layer without python or a real grid. - Catch2 v3.15.2 as a new git submodule (same precedent as eigen/SuiteSparse). - src/tests/CMakeLists.txt follows the src/core standalone-guard idiom: configurable directly (cmake -S src/tests) with no python/pybind11/KLU involved, or from the root CMakeLists via -DBUILD_TESTING=ON (default OFF, so pip/wheel builds are untouched). BinaryArchive.cpp is compiled straight into the test binary (it is self-contained), keeping the build to seconds. - Tests cover: primitive + synthetic-StateRes round trips over every ValueArchiver specialization, every require_count bounds check (incl. overflow safety), magic/format/type-tag mismatches, printable() escaping, truncation and trailing-byte detection, the atomic temp-file commit/rollback lifecycle, the fixup_binary_state hook, on-disk bool normalization, and a C++ port of the python corruption sweep (byte flips, uint64 stamps, truncations at every offset). - New CI workflow (.github/workflows/cpp_unit_tests.yml): build, ctest, then valgrind --error-exitcode=1 --leak-check=full over the same binary -- practical only because the suite is a plain small binary (1018 assertions, ~0.2s; valgrind-clean). - sdist excludes Catch2/ and src/tests/ (never compiled by pip builds). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H5MjAoBz1WWcVpZGkJTM5K Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- .github/workflows/cpp_unit_tests.yml | 50 +++ .gitignore | 1 + .gitmodules | 4 + CHANGELOG.rst | 10 + CMakeLists.txt | 9 + Catch2 | 1 + pyproject.toml | 5 + src/tests/CMakeLists.txt | 55 +++ src/tests/test_binary_archive.cpp | 381 +++++++++++++++++++ src/tests/test_binary_archive_corruption.cpp | 90 +++++ src/tests/test_binary_archive_stateres.cpp | 159 ++++++++ src/tests/test_helpers.hpp | 166 ++++++++ 12 files changed, 931 insertions(+) create mode 100644 .github/workflows/cpp_unit_tests.yml create mode 160000 Catch2 create mode 100644 src/tests/CMakeLists.txt create mode 100644 src/tests/test_binary_archive.cpp create mode 100644 src/tests/test_binary_archive_corruption.cpp create mode 100644 src/tests/test_binary_archive_stateres.cpp create mode 100644 src/tests/test_helpers.hpp diff --git a/.github/workflows/cpp_unit_tests.yml b/.github/workflows/cpp_unit_tests.yml new file mode 100644 index 00000000..9fbc631d --- /dev/null +++ b/.github/workflows/cpp_unit_tests.yml @@ -0,0 +1,50 @@ +# C++ unit tests: builds the Catch2 suite under src/tests/ (the archive layer +# compiled straight into a small test binary -- no python, no pip, no solver +# deps) and runs it twice: once through ctest, once under valgrind. Being a +# plain binary makes valgrind practical here (--error-exitcode=1 turns any +# memory error into a CI failure), which it is not over the python test suite +# (10-50x slowdown plus CPython suppressions). Complements the Sanitizers +# workflow, which exercises the full python extension. + +name: C++ unit tests + +on: + push: + branches: + # '**' and not '*': a single '*' does not match '/' in GitHub Actions + # globs, so topic branches like 'foo/bar' would silently never run + - '**' + tags: + - 'v*.*.*' + +jobs: + cpp_unit_tests: + name: Catch2 + valgrind + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # eigen (transitive include of the core headers) and Catch2 + submodules: true + + - name: Install valgrind + run: | + sudo apt-get update + sudo apt-get install -y valgrind + + - name: Configure + # standalone test project: no python, pybind11 or KLU involved. + # Debug keeps assert() live and gives valgrind full line info. + run: cmake -S src/tests -B build_tests -DCMAKE_BUILD_TYPE=Debug + + - name: Build + run: cmake --build build_tests -j$(nproc) + + - name: Run tests + run: ctest --test-dir build_tests --output-on-failure + + - name: Run tests under valgrind + run: | + cd build_tests + valgrind --error-exitcode=1 --leak-check=full --track-origins=yes \ + ./lightsim2grid_unit_tests diff --git a/.gitignore b/.gitignore index d9f1b923..02ad6307 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Distribution / packaging .Python build/ +build_tests/ develop-eggs/ dist/ downloads/ diff --git a/.gitmodules b/.gitmodules index 035f23f6..e4f2c330 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,7 @@ path = eigen url = https://gitlab.com/libeigen/eigen.git ignore = dirty +[submodule "Catch2"] + path = Catch2 + url = https://github.com/catchorg/Catch2.git + ignore = dirty diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 59402933..dc25f208 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -427,6 +427,16 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. corruption-sweep test (`TestCorruptionSweep`) that corrupts a valid binary file at every byte offset and checks `load_binary` never does anything worse than raising a clean `RuntimeError`. +- [ADDED] a dedicated C++ unit test suite (Catch2, new git submodule, under + `src/tests/`) exercising the binary serialization layer (`BinaryArchive`) + without python or a real grid: synthetic `StateRes` round trips covering + every serialized field shape, every bounds-check / header-mismatch path, + the atomic temp-file commit/rollback, and a C++ port of the corruption + sweep. Built standalone (`cmake -S src/tests`) or via `BUILD_TESTING=ON`, + and run in CI both through ctest and under `valgrind --error-exitcode=1` + (`.github/workflows/cpp_unit_tests.yml`) -- practical only because the + suite is a small plain binary. This is also the first framework for C++ + unit tests of the core (eg future solver-level tests). - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/CMakeLists.txt b/CMakeLists.txt index 55486727..48cdcbc3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,15 @@ endif() # pybind11 extension. add_subdirectory("src/bindings/python") +# --- C++ unit tests (Catch2) --- +# OFF by default so pip / wheel builds are unaffected. The tests can also be +# configured standalone, without python or pybind11: cmake -S src/tests +option(BUILD_TESTING "Build the C++ unit tests (src/tests, Catch2)" OFF) +if(BUILD_TESTING) + enable_testing() + add_subdirectory("src/tests") +endif() + # --- Python-only compile definitions (applied after targets exist) --- # Read The Docs — only affects binding stubs diff --git a/Catch2 b/Catch2 new file mode 160000 index 00000000..191fa38c --- /dev/null +++ b/Catch2 @@ -0,0 +1 @@ +Subproject commit 191fa38c9b1596cd2576ab531d4ab4d5e8e05190 diff --git a/pyproject.toml b/pyproject.toml index c7c175d0..e25b1a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,11 @@ exclude = [ "old", "wheelhouse", + # C++ unit tests and the Catch2 submodule they build against — never + # compiled by a pip / wheel build (BUILD_TESTING defaults OFF) + "Catch2", + "src/tests", + # SuiteSparse submodule — only KLU and its direct dependencies are used # (SuiteSparse_config, AMD, BTF, COLAMD, CXSparse, KLU are kept). "SuiteSparse/CAMD", diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt new file mode 100644 index 00000000..b8f13f6b --- /dev/null +++ b/src/tests/CMakeLists.txt @@ -0,0 +1,55 @@ +# ---- Standalone guard ------------------------------------------------------- +# Mirrors src/core/CMakeLists.txt: when included via add_subdirectory() from +# the root CMakeLists (BUILD_TESTING=ON), the project() call is skipped. When +# invoked as a standalone "cmake -S src/tests", the full project is set up so +# the C++ unit tests build without python, pybind11 or the pip machinery -- +# this is how the cpp_unit_tests CI workflow builds them. +if(NOT PROJECT_NAME) + cmake_minimum_required(VERSION 3.16...4.99) + project(lightsim2grid_tests LANGUAGES CXX) + + if("cxx_std_23" IN_LIST CMAKE_CXX_COMPILE_FEATURES) + set(CMAKE_CXX_STANDARD 23) + elseif("cxx_std_17" IN_LIST CMAKE_CXX_COMPILE_FEATURES) + set(CMAKE_CXX_STANDARD 17) + elseif("cxx_std_14" IN_LIST CMAKE_CXX_COMPILE_FEATURES) + set(CMAKE_CXX_STANDARD 14) + else() + message(FATAL_ERROR "lightsim2grid requires at least C++14") + endif() + set(CMAKE_CXX_STANDARD_REQUIRED ON) + set(CMAKE_CXX_EXTENSIONS OFF) + + # Eigen is two levels up (repo root / eigen/) + include_directories("${CMAKE_CURRENT_SOURCE_DIR}/../../eigen") + + enable_testing() +endif() + +# Catch2 (repo root / Catch2/ submodule). The guard keeps a future second +# consumer (or a parent that already provides Catch2) from adding it twice. +if(NOT TARGET Catch2::Catch2WithMain) + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../Catch2" "${CMAKE_CURRENT_BINARY_DIR}/catch2") +endif() + +# The archive layer under test is deliberately compiled into the test binary +# instead of linking lightsim2grid_core: BinaryArchive.cpp is self-contained +# (see its header), so the tests need neither KLU/SuiteSparse detection nor a +# shared-library build, and compile in seconds. When solver-level unit tests +# arrive, link lightsim2grid_core here instead of growing this source list. +add_executable(lightsim2grid_unit_tests + "${CMAKE_CURRENT_SOURCE_DIR}/../core/BinaryArchive.cpp" + test_binary_archive.cpp + test_binary_archive_stateres.cpp + test_binary_archive_corruption.cpp +) +target_include_directories(lightsim2grid_unit_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../core") +# LS2G_API must resolve to dllexport (not dllimport) on MSVC, since the core +# translation unit is built into this binary rather than imported from a DLL +target_compile_definitions(lightsim2grid_unit_tests PRIVATE LS2G_BUILDING_CORE) +target_link_libraries(lightsim2grid_unit_tests PRIVATE Catch2::Catch2WithMain) + +# one ctest entry per TEST_CASE +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../Catch2/extras") +include(Catch) +catch_discover_tests(lightsim2grid_unit_tests) diff --git a/src/tests/test_binary_archive.cpp b/src/tests/test_binary_archive.cpp new file mode 100644 index 00000000..a0b558c0 --- /dev/null +++ b/src/tests/test_binary_archive.cpp @@ -0,0 +1,381 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Low-level BinaryArchive API: primitive round-trips, header / magic / format +// / tag validation, every require_count path, truncation and trailing-byte +// detection, and the atomic temp-file commit / rollback lifecycle. + +#include +#include +#include +#include + +#include +#include +#include + +#include "BinaryArchive.hpp" +#include "test_helpers.hpp" + +using ls2g::BinaryArchive; +using ls2g_test::TempFile; +using Catch::Matchers::ContainsSubstring; +using Catch::Matchers::MessageMatches; + +namespace { + +// most tests start from a freshly written file +void with_written(const std::string & path, void (*writer)(BinaryArchive &)) +{ + BinaryArchive ar(path, BinaryArchive::Mode::Write); + writer(ar); + ar.commit(); +} + +} // anonymous namespace + +TEST_CASE("scalars round-trip", "[BinaryArchive]") +{ + TempFile file; + with_written(file.str(), [](BinaryArchive & ar) { + ar.write_scalar(std::int32_t(-123)); + ar.write_scalar(std::uint64_t(1) << 60); + ar.write_scalar(2.75); + ar.write_scalar(ls2g::cplx_type(1., -1.)); + }); + + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + std::int32_t i = 0; + std::uint64_t u = 0; + double d = 0.; + ls2g::cplx_type c; + ar.read_scalar(i); + ar.read_scalar(u); + ar.read_scalar(d); + ar.read_scalar(c); + CHECK(i == -123); + CHECK(u == (std::uint64_t(1) << 60)); + CHECK(d == 2.75); + CHECK(c == ls2g::cplx_type(1., -1.)); + CHECK_NOTHROW(ar.check_fully_consumed()); +} + +TEST_CASE("strings round-trip, including empty and embedded NUL", "[BinaryArchive]") +{ + TempFile file; + const std::string with_nul("a\0b", 3); + { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_string(""); + ar.write_string("plain"); + ar.write_string(with_nul); + ar.commit(); + } + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + std::string a, b, c; + ar.read_string(a); + ar.read_string(b); + ar.read_string(c); + CHECK(a == ""); + CHECK(b == "plain"); + CHECK(c == with_nul); + CHECK_NOTHROW(ar.check_fully_consumed()); +} + +TEST_CASE("bool / string / raw vectors round-trip, including empty", "[BinaryArchive]") +{ + TempFile file; + const std::vector bools{true, false, true}; + const std::vector strings{"", "x", "yz"}; + const std::vector doubles{-1., 0., 1.5}; + const std::vector ints{7, -8}; + const std::vector cplxs{ls2g::cplx_type(0., 2.)}; + { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_bool_vector(bools); + ar.write_bool_vector({}); + ar.write_string_vector(strings); + ar.write_string_vector({}); + ar.write_vector_raw(doubles); + ar.write_vector_raw(std::vector{}); + ar.write_vector_raw(ints); + ar.write_vector_raw(cplxs); + ar.commit(); + } + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + std::vector rb, rb_empty; + std::vector rs, rs_empty; + std::vector rd, rd_empty; + std::vector ri; + std::vector rc; + ar.read_bool_vector(rb); + ar.read_bool_vector(rb_empty); + ar.read_string_vector(rs); + ar.read_string_vector(rs_empty); + ar.read_vector_raw(rd); + ar.read_vector_raw(rd_empty); + ar.read_vector_raw(ri); + ar.read_vector_raw(rc); + CHECK(rb == bools); + CHECK(rb_empty.empty()); + CHECK(rs == strings); + CHECK(rs_empty.empty()); + CHECK(rd == doubles); + CHECK(rd_empty.empty()); + CHECK(ri == ints); + CHECK(rc == cplxs); + CHECK_NOTHROW(ar.check_fully_consumed()); +} + +TEST_CASE("header round-trips; writer version strings are not compared", "[BinaryArchive]") +{ + TempFile file; + { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_header("SomeTag", "0", "14", "0"); + ar.commit(); + } + { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + CHECK_NOTHROW(ar.check_header("SomeTag", "0", "14", "0")); + CHECK_NOTHROW(ar.check_fully_consumed()); + } + // a build with a different version but the same binary format reads the file + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + CHECK_NOTHROW(ar.check_header("SomeTag", "99", "0", "1")); +} + +TEST_CASE("garbage magic number is rejected", "[BinaryArchive]") +{ + TempFile file; + ls2g_test::write_file(file.str(), {'G', 'A', 'R', 'B', 'A', 'G', 'E', '!'}); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + REQUIRE_THROWS_MATCHES( + ar.check_magic(), std::runtime_error, + MessageMatches(ContainsSubstring("magic number mismatch"))); +} + +TEST_CASE("unsupported binary format version is rejected with a precise message", "[BinaryArchive]") +{ + TempFile file; + { + // hand-crafted header claiming format 999: magic is valid, so the + // header must still parse and the error must cite both versions + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_magic(); + ar.write_scalar(std::uint32_t(999)); + ar.write_string("SomeTag"); + ar.write_string("12"); + ar.write_string("34"); + ar.write_string("56"); + ar.commit(); + } + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + REQUIRE_THROWS_MATCHES( + ar.check_header("SomeTag", "0", "14", "0"), std::runtime_error, + MessageMatches(ContainsSubstring("binary format 999") && + ContainsSubstring("12.34.56") && + ContainsSubstring("only supports binary format(s)"))); +} + +TEST_CASE("wrong type tag is rejected", "[BinaryArchive]") +{ + TempFile file; + with_written(file.str(), [](BinaryArchive & ar) { + ar.write_header("LoadContainer", "0", "14", "0"); + }); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + REQUIRE_THROWS_MATCHES( + ar.check_header("StorageContainer", "0", "14", "0"), std::runtime_error, + MessageMatches(ContainsSubstring("wrong object type") && + ContainsSubstring("'LoadContainer'") && + ContainsSubstring("'StorageContainer'"))); +} + +TEST_CASE("corrupt strings are escaped and truncated in error messages", "[BinaryArchive]") +{ + SECTION("non-printable bytes become \\x escapes") { + TempFile file; + with_written(file.str(), [](BinaryArchive & ar) { + ar.write_header(std::string("bad\x01tag\xff", 8), "0", "14", "0"); + }); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + REQUIRE_THROWS_MATCHES( + ar.check_header("SomeTag", "0", "14", "0"), std::runtime_error, + MessageMatches(ContainsSubstring("\\x01") && ContainsSubstring("\\xff"))); + } + SECTION("over-long strings are truncated with a length note") { + TempFile file; + const std::string long_tag(200, 'A'); + { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_header(long_tag, "0", "14", "0"); + ar.commit(); + } + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + REQUIRE_THROWS_MATCHES( + ar.check_header("SomeTag", "0", "14", "0"), std::runtime_error, + MessageMatches(ContainsSubstring("... (200 chars)") && + !ContainsSubstring(long_tag))); + } +} + +TEST_CASE("require_count", "[BinaryArchive]") +{ + TempFile file; + // 16 bytes of payload, nothing consumed yet + ls2g_test::write_file(file.str(), std::vector(16, '\0')); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + + SECTION("counts that fit do not throw") { + CHECK_NOTHROW(ar.require_count(16, 1)); + CHECK_NOTHROW(ar.require_count(2, 8)); + CHECK_NOTHROW(ar.require_count(0, 8)); + } + SECTION("one element too many throws before any allocation") { + REQUIRE_THROWS_MATCHES( + ar.require_count(17, 1), std::runtime_error, + MessageMatches(ContainsSubstring("truncated or corrupted"))); + REQUIRE_THROWS_AS(ar.require_count(3, 8), std::runtime_error); + } + SECTION("huge counts cannot overflow back into acceptance") { + // 2^61 * 8 == 2^64 wraps to 0 with a multiplication-based check + REQUIRE_THROWS_AS(ar.require_count(std::uint64_t(1) << 61, 8), std::runtime_error); + REQUIRE_THROWS_AS(ar.require_count(~std::uint64_t(0), 8), std::runtime_error); + } + SECTION("elem_size 0 never throws") { + CHECK_NOTHROW(ar.require_count(~std::uint64_t(0), 0)); + } + SECTION("the check is relative to the current read position") { + std::uint64_t dummy = 0; + ar.read_scalar(dummy); // consume 8 of the 16 bytes + CHECK_NOTHROW(ar.require_count(8, 1)); + REQUIRE_THROWS_AS(ar.require_count(9, 1), std::runtime_error); + } +} + +TEST_CASE("trailing bytes are treated as corruption", "[BinaryArchive]") +{ + TempFile file; + with_written(file.str(), [](BinaryArchive & ar) { + ar.write_scalar(std::int32_t(1)); + ar.write_scalar(std::uint8_t(0)); // the trailing byte + }); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + std::int32_t v = 0; + ar.read_scalar(v); + REQUIRE_THROWS_MATCHES( + ar.check_fully_consumed(), std::runtime_error, + MessageMatches(ContainsSubstring("1 unexpected byte(s)"))); +} + +TEST_CASE("reading past the end of a truncated file throws", "[BinaryArchive]") +{ + TempFile file; + ls2g_test::write_file(file.str(), {'\x01', '\x02', '\x03', '\x04'}); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + double d = 0.; // needs 8 bytes, only 4 available + REQUIRE_THROWS_MATCHES( + ar.read_scalar(d), std::runtime_error, + MessageMatches(ContainsSubstring("unexpected end of file"))); +} + +TEST_CASE("constructor failures name the file", "[BinaryArchive]") +{ + SECTION("reading a nonexistent file") { + REQUIRE_THROWS_MATCHES( + BinaryArchive("no_such_file.lsb", BinaryArchive::Mode::Read), + std::runtime_error, + MessageMatches(ContainsSubstring("cannot open file for reading") && + ContainsSubstring("no_such_file.lsb"))); + } + SECTION("atomic write into a nonexistent directory names the temp file") { + REQUIRE_THROWS_MATCHES( + BinaryArchive("no_such_dir_ls2g/x.lsb", BinaryArchive::Mode::Write), + std::runtime_error, + MessageMatches(ContainsSubstring("cannot open file for writing") && + ContainsSubstring(".lsb_tmp"))); + } + SECTION("non-atomic write into a nonexistent directory") { + REQUIRE_THROWS_MATCHES( + BinaryArchive("no_such_dir_ls2g/x.lsb", BinaryArchive::Mode::Write, false), + std::runtime_error, + MessageMatches(ContainsSubstring("cannot open file for writing"))); + } +} + +TEST_CASE("commit() on a read-mode archive throws", "[BinaryArchive]") +{ + TempFile file; + ls2g_test::write_file(file.str(), {'\0'}); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + REQUIRE_THROWS_MATCHES( + ar.commit(), std::runtime_error, + MessageMatches(ContainsSubstring("read-mode"))); +} + +TEST_CASE("atomic write lifecycle", "[BinaryArchive]") +{ + TempFile file; + const std::string tmp = file.str() + ".lsb_tmp"; + + SECTION("data goes to the temp file, the destination only appears on commit") { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_scalar(std::int32_t(1)); + CHECK(ls2g_test::file_exists(tmp)); + CHECK_FALSE(ls2g_test::file_exists(file.str())); + ar.commit(); + CHECK_FALSE(ls2g_test::file_exists(tmp)); + CHECK(ls2g_test::file_exists(file.str())); + } + + SECTION("an interrupted write leaves a previously good file untouched") { + // a good file first + with_written(file.str(), [](BinaryArchive & ar) { + ar.write_scalar(std::int32_t(42)); + }); + const std::vector before = ls2g_test::read_file(file.str()); + { + // then a write abandoned before commit() (as after an exception + // during serialization): rollback in the destructor + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write); + ar.write_scalar(std::int32_t(-1)); + ar.write_scalar(std::int32_t(-2)); + } + CHECK_FALSE(ls2g_test::file_exists(tmp)); + CHECK(ls2g_test::read_file(file.str()) == before); + } +} + +TEST_CASE("non-atomic write goes straight to the destination", "[BinaryArchive]") +{ + TempFile file; + const std::string tmp = file.str() + ".lsb_tmp"; + + SECTION("no temp file is involved") { + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write, false); + ar.write_scalar(std::int32_t(1)); + CHECK_FALSE(ls2g_test::file_exists(tmp)); + CHECK(ls2g_test::file_exists(file.str())); + ar.commit(); + CHECK(ls2g_test::file_exists(file.str())); + } + + SECTION("an abandoned partial file remains and is rejected as truncated at load time") { + { + // declares a 5-element block whose data is never written + BinaryArchive ar(file.str(), BinaryArchive::Mode::Write, false); + ar.write_scalar(std::uint64_t(5)); + // no commit(): simulates an interrupted save + } + CHECK(ls2g_test::file_exists(file.str())); + BinaryArchive ar(file.str(), BinaryArchive::Mode::Read); + std::vector v; + REQUIRE_THROWS_AS(ar.read_vector_raw(v), std::runtime_error); + } +} diff --git a/src/tests/test_binary_archive_corruption.cpp b/src/tests/test_binary_archive_corruption.cpp new file mode 100644 index 00000000..42ed160c --- /dev/null +++ b/src/tests/test_binary_archive_corruption.cpp @@ -0,0 +1,90 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Deterministic corruption sweep: the C++ twin of the python sweep in +// lightsim2grid/tests/test_binary_serialization.py, but running on the +// archive layer alone -- so it is cheap enough to run at every byte offset, +// and any memory error it provokes becomes a hard failure under the valgrind +// / ASan CI jobs. The contract under test: loading arbitrarily corrupted +// bytes either succeeds (the corruption kept all counts consistent) or +// throws std::runtime_error -- never anything else, never a crash or an +// attempted huge allocation. + +#include // std::memset +#include +#include +#include + +#include + +#include "BinaryArchive.hpp" +#include "test_helpers.hpp" + +using ls2g_test::FakeContainer; + +namespace { + +// writes the corrupted bytes and attempts a load; anything other than a +// clean success or a std::runtime_error fails the test at this offset +void check_load(const std::string & path, const std::vector & bytes, std::size_t offset) +{ + ls2g_test::write_file(path, bytes); + INFO("corruption at byte offset " << offset << " of " << bytes.size()); + try { + (void) ls2g::load_binary_generic(path, "0", "14", "0"); + } catch (const std::runtime_error &) { + // the expected rejection path + } + // any other exception type (std::bad_alloc, std::length_error, ...) + // propagates out of the loop and fails the surrounding TEST_CASE + SUCCEED(); +} + +std::vector reference_bytes(const std::string & path) +{ + ls2g::save_binary_generic(ls2g_test::make_reference_container(), path, "0", "14", "0"); + return ls2g_test::read_file(path); +} + +} // anonymous namespace + +TEST_CASE("every single-byte flip loads cleanly or throws runtime_error", "[corruption]") +{ + ls2g_test::TempFile file; + const std::vector ref = reference_bytes(file.str()); + REQUIRE(ref.size() > 0); + for (std::size_t i = 0; i < ref.size(); ++i) { + std::vector corrupted = ref; + corrupted[i] = static_cast(corrupted[i] ^ 0xFF); + check_load(file.str(), corrupted, i); + } +} + +TEST_CASE("every 8-byte 0xFF stamp loads cleanly or throws runtime_error", "[corruption]") +{ + // stamps a maximal uint64 over every alignment, the worst case for any + // count / length field it happens to cover + ls2g_test::TempFile file; + const std::vector ref = reference_bytes(file.str()); + REQUIRE(ref.size() >= 8); + for (std::size_t i = 0; i + 8 <= ref.size(); ++i) { + std::vector corrupted = ref; + std::memset(&corrupted[i], 0xFF, 8); + check_load(file.str(), corrupted, i); + } +} + +TEST_CASE("every truncation loads cleanly or throws runtime_error", "[corruption]") +{ + ls2g_test::TempFile file; + const std::vector ref = reference_bytes(file.str()); + for (std::size_t len = 0; len < ref.size(); ++len) { + const std::vector truncated(ref.begin(), ref.begin() + len); + check_load(file.str(), truncated, len); + } +} diff --git a/src/tests/test_binary_archive_stateres.cpp b/src/tests/test_binary_archive_stateres.cpp new file mode 100644 index 00000000..018477a6 --- /dev/null +++ b/src/tests/test_binary_archive_stateres.cpp @@ -0,0 +1,159 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// ValueArchiver dispatch + the generic save_binary_generic / load_binary_generic +// entry points, over synthetic StateRes types (test_helpers.hpp) covering every +// field shape the real containers use -- no grid, no Eigen data, no python. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "BinaryArchive.hpp" +#include "test_helpers.hpp" + +using ls2g_test::FakeContainer; +using ls2g_test::TempFile; +using Catch::Matchers::ContainsSubstring; +using Catch::Matchers::MessageMatches; + +namespace { + +// number of bytes the header occupies for a given tag, with the "0"/"14"/"0" +// version strings every test here writes: magic (4) + format (uint32) + four +// length-prefixed strings (uint32 length each) +std::size_t header_size(const std::string & tag) +{ + return 4 + 4 + (4 + tag.size()) + (4 + 1) + (4 + 2) + (4 + 1); +} + +template +void save(const T & obj, const std::string & path, bool atomic = true) +{ + ls2g::save_binary_generic(obj, path, "0", "14", "0", atomic); +} + +template +T load(const std::string & path) +{ + return ls2g::load_binary_generic(path, "0", "14", "0"); +} + +} // anonymous namespace + +TEST_CASE("full StateRes round-trip covering every ValueArchiver specialization", "[StateRes]") +{ + const FakeContainer ref = ls2g_test::make_reference_container(); + TempFile file; + save(ref, file.str()); + const FakeContainer loaded = load(file.str()); + // std::tuple operator== compares field by field, recursing into the + // nested tuple and the nested vectors + CHECK(loaded.get_state() == ref.get_state()); +} + +TEST_CASE("an all-default (empty) StateRes round-trips", "[StateRes]") +{ + const FakeContainer ref{}; // zero scalars, empty strings and vectors + TempFile file; + save(ref, file.str()); + CHECK(load(file.str()).get_state() == ref.get_state()); +} + +TEST_CASE("non-atomic save round-trips too", "[StateRes]") +{ + const FakeContainer ref = ls2g_test::make_reference_container(); + TempFile file; + save(ref, file.str(), false); + CHECK_FALSE(ls2g_test::file_exists(file.str() + ".lsb_tmp")); + CHECK(load(file.str()).get_state() == ref.get_state()); +} + +TEST_CASE("a corrupted on-disk bool byte is normalized, not undefined behavior", "[StateRes]") +{ + // reading a byte holding eg 255 straight into a bool object would be UB + // when that bool is later loaded; ValueArchiver normalizes instead + const FakeContainer ref = ls2g_test::make_reference_container(); + TempFile file; + save(ref, file.str()); + + // the bool is the third StateRes field: after the int and the real_type + std::vector bytes = ls2g_test::read_file(file.str()); + const std::size_t bool_offset = + header_size(FakeContainer::binary_type_tag()) + sizeof(int) + sizeof(ls2g::real_type); + REQUIRE(bytes.at(bool_offset) == 1); // written by the reference container as true + bytes.at(bool_offset) = static_cast(0xFF); + ls2g_test::write_file(file.str(), bytes); + + const FakeContainer loaded = load(file.str()); + CHECK(std::get<2>(loaded.get_state()) == true); + + bytes.at(bool_offset) = 0; + ls2g_test::write_file(file.str(), bytes); + CHECK(std::get<2>(load(file.str()).get_state()) == false); +} + +TEST_CASE("identical StateRes layouts are told apart by the type tag", "[StateRes]") +{ + // FakeLoad and FakeStorage have byte-identical payloads (like the real + // LoadContainer / StorageContainer): only the header tag differs + ls2g_test::FakeLoad load_obj; + load_obj.state = std::make_tuple(3, std::vector{1., 2.}); + TempFile file; + save(load_obj, file.str()); + + CHECK(load(file.str()).get_state() == load_obj.get_state()); + REQUIRE_THROWS_MATCHES( + load(file.str()), std::runtime_error, + MessageMatches(ContainsSubstring("wrong object type") && + ContainsSubstring("'FakeLoad'") && + ContainsSubstring("'FakeStorage'"))); +} + +TEST_CASE("fixup_binary_state is applied when declared, and only then", "[StateRes]") +{ + SECTION("a type declaring the hook gets it applied after the read") { + ls2g_test::FakeWithFixup obj; + obj.state = std::make_tuple(5, std::string("original")); + TempFile file; + save(obj, file.str()); + const ls2g_test::FakeWithFixup loaded = load(file.str()); + CHECK(std::get<0>(loaded.get_state()) == 5); + CHECK(std::get<1>(loaded.get_state()) == "fixed up"); + } + SECTION("a type without the hook is loaded untouched") { + ls2g_test::FakeLoad obj; + obj.state = std::make_tuple(5, std::vector{7.}); + TempFile file; + save(obj, file.str()); + CHECK(load(file.str()).get_state() == obj.get_state()); + } +} + +TEST_CASE("loading rejects a file whose payload is longer than the StateRes", "[StateRes]") +{ + // a FakeContainer payload after a FakeLoad-sized read leaves trailing + // bytes: check_fully_consumed must reject them (wrong-class protection + // beyond the tag, eg after a manual rename attack on the header) + ls2g_test::FakeLoad obj; + obj.state = std::make_tuple(1, std::vector{1.}); + TempFile file; + save(obj, file.str()); + std::vector bytes = ls2g_test::read_file(file.str()); + bytes.push_back('\0'); + ls2g_test::write_file(file.str(), bytes); + REQUIRE_THROWS_MATCHES( + load(file.str()), std::runtime_error, + MessageMatches(ContainsSubstring("unexpected byte(s)"))); +} diff --git a/src/tests/test_helpers.hpp b/src/tests/test_helpers.hpp new file mode 100644 index 00000000..0edfdcd6 --- /dev/null +++ b/src/tests/test_helpers.hpp @@ -0,0 +1,166 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef LS2G_TEST_HELPERS_H +#define LS2G_TEST_HELPERS_H + +// Shared helpers for the C++ unit tests (src/tests): a self-cleaning +// temporary file, raw file IO, and the synthetic serializable types used to +// exercise BinaryArchive without a real grid. C++14 only (project policy). + +#include // std::remove +#include +#include +#include +#include +#include + +#include "Utils.hpp" // ls2g::real_type, ls2g::cplx_type + +namespace ls2g_test { + +// A unique file path in the current working directory (ctest / CI run the +// binary from the build directory), removed on destruction together with the +// ".lsb_tmp" sibling a BinaryArchive atomic write may have left behind. +// mkdtemp/std::filesystem are avoided on purpose: C++14, no platform #ifdef. +class TempFile +{ + public: + explicit TempFile(const std::string & suffix = ".lsb") { + static int counter = 0; + std::ostringstream oss; + oss << "ls2g_unit_test_" << counter++ << suffix; + path_ = oss.str(); + // in case a previous crashed run left files behind + std::remove(path_.c_str()); + std::remove((path_ + ".lsb_tmp").c_str()); + } + ~TempFile() { + std::remove(path_.c_str()); + std::remove((path_ + ".lsb_tmp").c_str()); + } + TempFile(const TempFile &) = delete; + TempFile & operator=(const TempFile &) = delete; + + const std::string & str() const { return path_; } + + private: + std::string path_; +}; + +inline bool file_exists(const std::string & path) +{ + std::ifstream f(path, std::ios::binary); + return f.is_open(); +} + +inline std::vector read_file(const std::string & path) +{ + std::ifstream f(path, std::ios::binary); + return std::vector((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +inline void write_file(const std::string & path, const std::vector & bytes) +{ + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f.write(bytes.data(), static_cast(bytes.size())); +} + +// ---- synthetic serializable types ------------------------------------------ +// They implement the same contract as the real containers (StateRes typedef + +// get_state / set_state / binary_type_tag) with none of their dependencies. + +enum class FakeEnum : int { kOff = 0, kOn = 1, kAuto = 42 }; + +// One field per ValueArchiver specialization in BinaryArchive.hpp: arithmetic +// scalars, bool, cplx_type, enum, string, raw vectors (real / int / cplx), +// vector, vector, vector> and a nested tuple. +struct FakeContainer +{ + using SubState = std::tuple >; + using StateRes = std::tuple< + int, + ls2g::real_type, + bool, + ls2g::cplx_type, + FakeEnum, + std::string, + std::vector, + std::vector, + std::vector, + std::vector, + std::vector, + std::vector >, + SubState + >; + + StateRes state{}; + + StateRes get_state() const { return state; } + void set_state(StateRes & s) { state = s; } + static const char * binary_type_tag() { return "FakeContainer"; } +}; + +inline FakeContainer make_reference_container() +{ + FakeContainer res; + res.state = FakeContainer::StateRes( + -7, + 3.141592653589793, + true, + ls2g::cplx_type(1.5, -2.5), + FakeEnum::kAuto, + "hello archive", + {0., -1.5, 2.25e3}, + {1, -2, 3}, + {ls2g::cplx_type(0., 1.), ls2g::cplx_type(-1., 0.)}, + {true, false, true, true}, + {"", "one", "two words"}, + {{1., 2.}, {}, {3.}}, + FakeContainer::SubState(99, {4., 5., 6.}) + ); + return res; +} + +// Two types with identical StateRes layouts but different tags: the +// LoadContainer-vs-StorageContainer confusion the type tag exists to reject. +struct FakeLoad +{ + using StateRes = std::tuple >; + StateRes state{}; + StateRes get_state() const { return state; } + void set_state(StateRes & s) { state = s; } + static const char * binary_type_tag() { return "FakeLoad"; } +}; + +struct FakeStorage +{ + using StateRes = std::tuple >; + StateRes state{}; + StateRes get_state() const { return state; } + void set_state(StateRes & s) { state = s; } + static const char * binary_type_tag() { return "FakeStorage"; } +}; + +// Opts into the optional post-read hook (like LSGrid does): detected by +// BinaryLoadFixup via the C++14 detection idiom and applied after the state +// is read, before set_state(). +struct FakeWithFixup +{ + using StateRes = std::tuple; + StateRes state{}; + StateRes get_state() const { return state; } + void set_state(StateRes & s) { state = s; } + static const char * binary_type_tag() { return "FakeWithFixup"; } + static void fixup_binary_state(StateRes & s) { std::get<1>(s) = "fixed up"; } +}; + +} // namespace ls2g_test + +#endif // LS2G_TEST_HELPERS_H From 018515f15b862534e322f6ee51ea7e0cdb652800 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:52:48 +0000 Subject: [PATCH 092/166] Add C++ LSGrid API tests, standalone-library docs; fix uninitialized TrafoContainer bools - src/tests/test_lsgrid.cpp: basic tests of the LSGrid main API driven entirely from C++ (a 3-bus grid built through the init_* methods, solved with the default Eigen SparseLU algorithms): AC/DC powerflow contract (converged => per-bus V, diverged => empty vector), physically-checked results (power balance, analytic DC angles on a lossless feeder), voltage getters, copy independence, get_state/set_state and save_binary/load_binary round trips, setpoint changes, load deactivation, and the documented error paths (runtime_error / out_of_range). - src/tests/CMakeLists.txt now links lightsim2grid_core (same if(NOT TARGET) guard as the python bindings) instead of compiling BinaryArchive.cpp into the binary; the standalone project() gains a VERSION since src/core generates its package version file from it. - docs/cpp_library.rst (+ index toctree): how to build/install lightsim2grid_core from source (cmake -S src/core), consume the copy shipped in the python wheel (get_include()/get_cmake_dir()), link via find_package(lightsim2grid_core CONFIG) -> lightsim2grid::core, embed via add_subdirectory, a complete build-a-grid-and-solve example, and how to run the C++ unit tests. - Fix: TrafoContainer left ignore_tap_side_for_shift_ and shift_dependent_rx_ uninitialized when init_trafo was never called (any grid built without trafos), so copying or serializing such a grid read indeterminate bools -- undefined behavior, garbage bytes in binary files/pickles. Found by valgrind over the new LSGrid tests (the serialized bool at file offset 669 held 212). Both members now have default initializers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H5MjAoBz1WWcVpZGkJTM5K Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 19 ++ docs/cpp_library.rst | 251 ++++++++++++++++ docs/index.rst | 1 + src/core/element_container/TrafoContainer.hpp | 10 +- src/tests/CMakeLists.txt | 27 +- src/tests/test_lsgrid.cpp | 278 ++++++++++++++++++ 6 files changed, 572 insertions(+), 14 deletions(-) create mode 100644 docs/cpp_library.rst create mode 100644 src/tests/test_lsgrid.cpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dc25f208..3fa2cd6e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -437,6 +437,25 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. (`.github/workflows/cpp_unit_tests.yml`) -- practical only because the suite is a small plain binary. This is also the first framework for C++ unit tests of the core (eg future solver-level tests). +- [ADDED] C++ unit tests for the `LSGrid` main API (`src/tests/test_lsgrid.cpp`): + a 3-bus grid built programmatically through the `init_*` methods and solved + with the default Eigen SparseLU algorithms -- AC/DC powerflow contract + (converged => per-bus V, diverged => empty vector), physically-checked + results (power balance, analytic DC angles), copy / `get_state` / + `save_binary` round trips, setpoint changes, load deactivation and the + documented error paths. The test target now links `lightsim2grid_core`. +- [ADDED] documentation for using `lightsim2grid_core` as a standalone C++ + library (`docs/cpp_library.rst`): building/installing it from source + (`cmake -S src/core`), consuming the copy shipped inside the python wheel + (`lightsim2grid.get_cmake_dir()`), linking with CMake via + `find_package(lightsim2grid_core CONFIG)` / `lightsim2grid::core`, a + complete build-a-grid-and-solve example, and how to run the C++ unit tests. +- [FIXED] `TrafoContainer` left two bool members (`ignore_tap_side_for_shift_`, + `shift_dependent_rx_`) uninitialized when `init_trafo` was never called (any + grid built without trafos): copying or serializing such a grid read + indeterminate bools (undefined behavior, garbage written into binary files / + pickles). Found by valgrind over the new C++ LSGrid tests; both members now + have default initializers. - [FIXED] `LSGrid.save_binary`/`load_binary` (and pickle, which shares the same `LSGrid::get_state()`/`set_state()`/`StateRes` contract) silently dropped the per-solver `AlgoConfig` (scaling/refactor policy, line-search tolerances, etc. -- diff --git a/docs/cpp_library.rst b/docs/cpp_library.rst new file mode 100644 index 00000000..b8e30f7e --- /dev/null +++ b/docs/cpp_library.rst @@ -0,0 +1,251 @@ +.. _cpp_library: + +Using lightsim2grid as a C++ library +===================================== + +Since the split between the C++ core and the python bindings, everything that +computes in lightsim2grid lives in a standalone shared library called +``lightsim2grid_core``, with **no dependency on python, pybind11 or grid2op**. +The python package (``lightsim2grid_cpp``) is only a thin pybind11 wrapper +around it. + +This page documents how to build that library, install it, and link your own +C++ code against it. If your goal is to plug a custom powerflow algorithm into +the *python* package, see :ref:`solver_plugin` instead -- this page is about +consuming the C++ API directly. + +What the library contains +-------------------------- + +Everything is in the ``ls2g`` namespace, headers at the root of +``src/core/``. The main entry points: + +- ``ls2g::LSGrid`` (``LSGrid.hpp``): the grid model itself -- built + programmatically through the ``init_*`` methods (buses, powerlines, trafos, + loads, generators, ...), solved with ``ac_pf`` / ``dc_pf``, inspected with + the ``get_*_res`` methods. This is the C++ class exposed to python as + ``GridModel``. +- the element containers (``element_container/*.hpp``), time series and + contingency analysis (``batch_algorithm/``), the powerflow algorithms + (``powerflow_algorithm/``) and linear solvers (``linear_solvers/``). +- ``AlgorithmRegistry.hpp``: the registry used to add custom powerflow + algorithms (see :ref:`solver_plugin`). +- ``BinaryArchive.hpp``: the fast binary serialization used by + ``save_binary`` / ``load_binary`` (see :ref:`binary-serialization`). + +The public API is C++14 (the library itself compiles with the newest standard +your compiler supports, see below). Eigen is part of the public interface +(vector arguments are ``Eigen::VectorXd`` / ``Eigen::VectorXi`` and friends); +the matching Eigen headers are shipped alongside the library so you do not +need your own copy. + +Option 1: use the copy shipped with the python wheel +----------------------------------------------------- + +Every ``pip install lightsim2grid`` (>= 0.14) already contains everything a +C++ consumer needs, inside the installed package directory: + +- ``liblightsim2grid_core.so`` (or ``.dylib`` / ``.dll``) next to the python + extension; +- the headers (including the bundled ``Eigen/``) under + ``lightsim2grid/include/``; +- a CMake package config under + ``lightsim2grid/share/cmake/lightsim2grid_core/``. + +Two python helpers return those paths, so build scripts do not need to +hard-code anything: + +.. code-block:: python + + import lightsim2grid + lightsim2grid.get_include() # .../site-packages/lightsim2grid/include + lightsim2grid.get_cmake_dir() # .../site-packages/lightsim2grid/share/cmake/lightsim2grid_core + +Option 2: build and install the core from source +------------------------------------------------- + +``src/core`` is a self-contained CMake project (python is never required): + +.. code-block:: bash + + git clone https://github.com/Grid2op/lightsim2grid.git + cd lightsim2grid + git submodule update --init eigen # required (header-only) + git submodule update --init SuiteSparse # optional, for the KLU solver + + cmake -S src/core -B build_core -DCMAKE_BUILD_TYPE=Release + cmake --build build_core -j + cmake --install build_core --prefix /where/you/want/it + +The install lays out a standard prefix: ``lib/liblightsim2grid_core.so``, +``include/lightsim2grid/*.hpp`` (with Eigen bundled next to them) and +``lib/cmake/lightsim2grid_core/`` (the package config). + +About KLU: the build looks for the KLU sparse linear solver four ways (system +CMake config e.g. ``libsuitesparse-dev``, legacy system libraries, pre-built +static libraries inside the ``SuiteSparse`` submodule, and finally building it +from the submodule -- this last strategy needs CMake >= 3.22). **If none +succeeds the build does not fail**: the library simply falls back to the +always-available Eigen ``SparseLU`` algorithms (which are also the +``LSGrid`` defaults), and only the ``*_KLU`` entries of ``AlgorithmType`` +disappear. When KLU is found, ``KLU_SOLVER_AVAILABLE`` is defined on the +target (and propagated to consumers) and the KLU code is baked into the +shared library. + +A few environment variables tune the compilation, following the same +convention as the python build: ``__O3_OPTIM`` (``-O3``, on by default), +``__COMPILE_MARCHNATIVE`` (``-march=native``), ``__SANITIZE`` +(ASan + UBSan) and ``__DEBUG_ASSERTS`` (``-UNDEBUG -D_GLIBCXX_ASSERTIONS``, +re-enabling the Eigen bounds assertions). + +Linking against it with CMake +------------------------------ + +The package config exports a single imported target, +``lightsim2grid::core`` (with ``ls2g::core`` as a convenience alias). A +minimal consumer: + +.. code-block:: cmake + + cmake_minimum_required(VERSION 3.16) + project(my_grid_tool LANGUAGES CXX) + set(CMAKE_CXX_STANDARD 14) + + find_package(lightsim2grid_core CONFIG REQUIRED) + + add_executable(my_grid_tool main.cpp) + target_link_libraries(my_grid_tool PRIVATE lightsim2grid::core) + +The imported target carries the include directories (lightsim2grid headers +*and* the bundled Eigen), the ``Threads`` dependency, and the +``KLU_SOLVER_AVAILABLE`` definition when the library was built with KLU -- +nothing else to configure. + +Point CMake at the config with either of: + +.. code-block:: bash + + # a source install (option 2): the prefix you installed into + cmake -S . -B build -DCMAKE_PREFIX_PATH=/where/you/want/it + + # the python wheel (option 1): the exact config directory + cmake -S . -B build \ + -Dlightsim2grid_core_DIR=$(python -c "import lightsim2grid; print(lightsim2grid.get_cmake_dir())") + +Alternatively, a super-project that vendors the lightsim2grid sources can +skip ``find_package`` entirely and pull the target in directly (this is what +the python bindings and the C++ unit tests do): + +.. code-block:: cmake + + if(NOT TARGET lightsim2grid_core) + add_subdirectory(path/to/lightsim2grid/src/core lightsim2grid_core_build) + endif() + target_link_libraries(my_grid_tool PRIVATE lightsim2grid_core) + +One MSVC-specific note: the headers decorate the API with ``LS2G_API`` +(dllimport when consuming, dllexport when building). This is automatic as +long as you *link* the library. Only if you compile core ``.cpp`` files +directly into your own target must you add the ``LS2G_BUILDING_CORE`` +compile definition. + +Minimal example +---------------- + +Build a 3-bus grid (slack generator, two lines, one load) and solve an AC +powerflow -- no input file, no python: + +.. code-block:: c++ + + #include + #include "LSGrid.hpp" + + int main() + { + ls2g::LSGrid grid; + grid.set_sn_mva(100.); + + // three 138 kV buses (one busbar per substation) + ls2g::RealVect bus_vn_kv(3); + bus_vn_kv << 138., 138., 138.; + grid.init_bus(3, 1, bus_vn_kv, 0, 0); + + // two lines 0-1 and 1-2, r/x/h given in pu on the sn_mva base + ls2g::RealVect r(2), x(2); + r << 0.01, 0.01; + x << 0.10, 0.10; + ls2g::CplxVect h = ls2g::CplxVect::Zero(2); + Eigen::VectorXi from_id(2), to_id(2); + from_id << 0, 1; + to_id << 1, 2; + grid.init_powerlines(r, x, h, from_id, to_id); + + // a 50 MW / 10 MVar load at bus 2 + ls2g::RealVect load_p(1), load_q(1); + load_p << 50.; + load_q << 10.; + Eigen::VectorXi load_bus(1); + load_bus << 2; + grid.init_loads(load_p, load_q, load_bus); + + // a generator at bus 0 (1.02 pu), registered as the slack + ls2g::RealVect gen_p(1), gen_v(1), min_q(1), max_q(1); + gen_p << 0.; + gen_v << 1.02; + min_q << -1000.; + max_q << 1000.; + Eigen::VectorXi gen_bus(1); + gen_bus << 0; + grid.init_generators(gen_p, gen_v, min_q, max_q, gen_bus); + grid.add_gen_slackbus(0, 1.); + + // Newton-Raphson with Eigen SparseLU (the default, KLU not needed) + const ls2g::CplxVect Vinit = + ls2g::CplxVect::Constant(grid.total_bus(), ls2g::cplx_type(1., 0.)); + const ls2g::CplxVect V = grid.ac_pf(Vinit, 20, 1e-8); + + if (V.size() == 0) { // diverged: the result vector is empty + std::cerr << "powerflow diverged" << std::endl; + return 1; + } + for (Eigen::Index bus = 0; bus < V.size(); ++bus) { + std::cout << "bus " << bus << ": " << std::abs(V(bus)) << " pu, " + << std::arg(V(bus)) << " rad" << std::endl; + } + const auto gen_res = grid.get_gen_res(); // (p_mw, q_mvar, v_kv) + std::cout << "slack injects " << std::get<0>(gen_res)(0) << " MW" << std::endl; + return 0; + } + +Conventions worth knowing (they mirror the python API, which shares this +code): ``init_powerlines`` takes r/x/h **in pu** on the ``sn_mva`` base; +loads are in MW / MVar; generator voltage setpoints in pu; ``ac_pf`` / +``dc_pf`` take a per-bus complex initial voltage of size ``total_bus()`` and +return the per-bus complex voltage, or an **empty vector when the powerflow +diverged** (details via ``grid.get_algo().converged()`` / +``get_error()`` / ``get_nb_iter()``). Errors (bad element ids, wrong +``Vinit`` size, invalid slack) are reported as ``std::runtime_error`` / +``std::out_of_range`` exceptions. + +The C++ unit tests +------------------- + +The library has its own test suite (Catch2, under ``src/tests/``) covering +the binary serialization layer and the ``LSGrid`` API above -- the +``test_lsgrid.cpp`` file doubles as a working example of building and +solving a grid from C++. It builds standalone in seconds, without python: + +.. code-block:: bash + + git submodule update --init eigen Catch2 # SuiteSparse optional + cmake -S src/tests -B build_tests -DCMAKE_BUILD_TYPE=Debug + cmake --build build_tests -j + ctest --test-dir build_tests --output-on-failure + + # the whole suite is a small plain binary: valgrind is practical + valgrind --error-exitcode=1 --leak-check=full build_tests/lightsim2grid_unit_tests + +The same suite can be enabled from the top-level (python) build with +``-DBUILD_TESTING=ON`` (off by default: wheels never build it), and runs in +CI on every push -- once through ctest and once under valgrind +(``.github/workflows/cpp_unit_tests.yml``). diff --git a/docs/index.rst b/docs/index.rst index 85fa031a..d00dc70e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,6 +61,7 @@ This is a work in progress at the moment algorithm_names time_series security_analysis + cpp_library solver_plugin binary_serialization diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index d85e6b41..dfcb5a74 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -282,8 +282,13 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A is_tap_side1_; // whether the tap is hav side or not @@ -296,7 +301,8 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A > rx_corr_alpha_; // per trafo: alpha (rad), ascending diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index b8f13f6b..51f6b568 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -6,7 +6,9 @@ # this is how the cpp_unit_tests CI workflow builds them. if(NOT PROJECT_NAME) cmake_minimum_required(VERSION 3.16...4.99) - project(lightsim2grid_tests LANGUAGES CXX) + # the VERSION is required: src/core (added below) generates its CMake + # package version file from PROJECT_VERSION + project(lightsim2grid_tests VERSION 0.0.0 LANGUAGES CXX) if("cxx_std_23" IN_LIST CMAKE_CXX_COMPILE_FEATURES) set(CMAKE_CXX_STANDARD 23) @@ -32,22 +34,23 @@ if(NOT TARGET Catch2::Catch2WithMain) add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../Catch2" "${CMAKE_CURRENT_BINARY_DIR}/catch2") endif() -# The archive layer under test is deliberately compiled into the test binary -# instead of linking lightsim2grid_core: BinaryArchive.cpp is self-contained -# (see its header), so the tests need neither KLU/SuiteSparse detection nor a -# shared-library build, and compile in seconds. When solver-level unit tests -# arrive, link lightsim2grid_core here instead of growing this source list. +# The full core library: needed since the tests exercise LSGrid (powerflow, +# solvers). Same guard as src/bindings/python/CMakeLists.txt: when added from +# the root CMakeLists the bindings have already created the target; standalone +# it is built from src/core (KLU detection included -- the core degrades +# gracefully to Eigen SparseLU when KLU is not found, and the LSGrid tests +# only rely on the always-available SparseLU algorithms). +if(NOT TARGET lightsim2grid_core) + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../core" "${CMAKE_CURRENT_BINARY_DIR}/core") +endif() + add_executable(lightsim2grid_unit_tests - "${CMAKE_CURRENT_SOURCE_DIR}/../core/BinaryArchive.cpp" test_binary_archive.cpp test_binary_archive_stateres.cpp test_binary_archive_corruption.cpp + test_lsgrid.cpp ) -target_include_directories(lightsim2grid_unit_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../core") -# LS2G_API must resolve to dllexport (not dllimport) on MSVC, since the core -# translation unit is built into this binary rather than imported from a DLL -target_compile_definitions(lightsim2grid_unit_tests PRIVATE LS2G_BUILDING_CORE) -target_link_libraries(lightsim2grid_unit_tests PRIVATE Catch2::Catch2WithMain) +target_link_libraries(lightsim2grid_unit_tests PRIVATE lightsim2grid_core Catch2::Catch2WithMain) # one ctest entry per TEST_CASE list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../Catch2/extras") diff --git a/src/tests/test_lsgrid.cpp b/src/tests/test_lsgrid.cpp new file mode 100644 index 00000000..9071a5fe --- /dev/null +++ b/src/tests/test_lsgrid.cpp @@ -0,0 +1,278 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Basic tests of the LSGrid main API, driven entirely from C++ (no python, +// no grid2op, no external grid file): a 3-bus radial grid is built through +// the init_* methods and solved with the always-available Eigen SparseLU +// algorithms (the LSGrid constructor defaults, no KLU needed). Covers the +// AC / DC powerflow contract (converged => per-bus V, diverged => empty +// vector), physically-checkable results (power balance, DC angles), copy / +// get_state / save_binary round trips, setpoint changes, element +// deactivation, and the documented error paths. + +#include +#include +#include + +#include +#include + +#include "LSGrid.hpp" +#include "test_helpers.hpp" + +using Catch::Approx; +using ls2g::LSGrid; +using ls2g::CplxVect; +using ls2g::RealVect; +using ls2g::cplx_type; +using ls2g::real_type; + +namespace { + +// Slack generator (1.02 pu) at bus 0, two identical lines 0-1 and 1-2 +// (x = 0.1 pu each), one 50 MW / 10 MVar load at bus 2. sn_mva = 100, so the +// load is 0.5 pu and (with r = 0) the exact DC angles are 0 / -0.05 / -0.10. +LSGrid make_three_bus_grid(real_type r_pu = 0.01) +{ + LSGrid grid; + grid.set_sn_mva(100.); + grid.set_init_vm_pu(1.0); + + RealVect bus_vn_kv(3); + bus_vn_kv << 138., 138., 138.; + grid.init_bus(3, 1, bus_vn_kv, 0, 0); // nb_line / nb_trafo args are unused + + RealVect branch_r(2), branch_x(2); + branch_r << r_pu, r_pu; + branch_x << 0.1, 0.1; + const CplxVect branch_h = CplxVect::Zero(2); + Eigen::VectorXi from_id(2), to_id(2); + from_id << 0, 1; + to_id << 1, 2; + grid.init_powerlines(branch_r, branch_x, branch_h, from_id, to_id); + + RealVect load_p(1), load_q(1); + load_p << 50.; + load_q << 10.; + Eigen::VectorXi load_bus(1); + load_bus << 2; + grid.init_loads(load_p, load_q, load_bus); + + RealVect gen_p(1), gen_v(1), gen_min_q(1), gen_max_q(1); + gen_p << 0.; + gen_v << 1.02; + gen_min_q << -1000.; + gen_max_q << 1000.; + Eigen::VectorXi gen_bus(1); + gen_bus << 0; + grid.init_generators(gen_p, gen_v, gen_min_q, gen_max_q, gen_bus); + grid.add_gen_slackbus(0, 1.); + return grid; +} + +CplxVect flat_start(const LSGrid & grid) +{ + return CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); +} + +CplxVect solve_ac(LSGrid & grid) +{ + return grid.ac_pf(flat_start(grid), 20, 1e-8); +} + +} // anonymous namespace + +TEST_CASE("AC powerflow on a hand-built 3-bus grid converges and balances", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + const CplxVect V = solve_ac(grid); + + // converged <=> non-empty per-bus voltage vector + REQUIRE(V.size() == 3); + CHECK(grid.get_algo().converged()); + CHECK(grid.get_algo().get_error() == ls2g::ErrorType::NoError); + CHECK(grid.get_algo().get_nb_iter() >= 1); + + // slack holds its voltage setpoint; magnitude drops along the feeder + CHECK(std::abs(V(0)) == Approx(1.02).epsilon(1e-8)); + CHECK(std::abs(V(1)) < std::abs(V(0))); + CHECK(std::abs(V(2)) < std::abs(V(1))); + + // the load draws exactly its setpoint + const auto load_res = grid.get_loads_res(); // (p_mw, q_mvar, v_kv) + CHECK(std::get<0>(load_res)(0) == Approx(50.).epsilon(1e-8)); + CHECK(std::get<1>(load_res)(0) == Approx(10.).epsilon(1e-8)); + + // power balance: the slack covers the load plus the (positive) line + // losses, and its injection is what enters line 0 at bus 0 + const auto line1 = grid.get_line_res1(); // "from" side (p_mw, q_mvar, v_kv, a) + const auto line2 = grid.get_line_res2(); // "to" side + const real_type loss0 = std::get<0>(line1)(0) + std::get<0>(line2)(0); + const real_type loss1 = std::get<0>(line1)(1) + std::get<0>(line2)(1); + CHECK(loss0 > 0.); + CHECK(loss1 > 0.); + const auto gen_res = grid.get_gen_res(); + const real_type slack_p = std::get<0>(gen_res)(0); + CHECK(slack_p == Approx(50. + loss0 + loss1).epsilon(1e-6)); + CHECK(slack_p == Approx(std::get<0>(line1)(0)).epsilon(1e-6)); +} + +TEST_CASE("DC powerflow reproduces the analytic angles of a lossless feeder", "[LSGrid]") +{ + // r = 0 so theta_k = -P * x * k holds exactly: 0.5 pu through x = 0.1 pu + // per line gives 0 / -0.05 / -0.10 rad + LSGrid grid = make_three_bus_grid(0.); + const CplxVect V = grid.dc_pf(flat_start(grid), 1, 1e-8); + + REQUIRE(V.size() == 3); + CHECK(std::arg(V(0)) == Approx(0.).margin(1e-10)); + CHECK(std::arg(V(1)) == Approx(-0.05).epsilon(1e-8)); + CHECK(std::arg(V(2)) == Approx(-0.10).epsilon(1e-8)); + + // both lines carry the full 50 MW, losslessly + const auto line1 = grid.get_line_res1(); + CHECK(std::get<0>(line1)(0) == Approx(50.).epsilon(1e-8)); + CHECK(std::get<0>(line1)(1) == Approx(50.).epsilon(1e-8)); +} + +TEST_CASE("voltage getters follow the powerflow", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + + SECTION("before any powerflow they throw") { + CHECK_THROWS_AS(grid.get_V(), std::runtime_error); + CHECK_THROWS_AS(grid.get_Va(), std::runtime_error); + CHECK_THROWS_AS(grid.get_Vm(), std::runtime_error); + } + + SECTION("after a powerflow they match the returned vector") { + const CplxVect V = solve_ac(grid); + REQUIRE(V.size() == 3); + const CplxVect V_get = grid.get_V(); + const RealVect Vm = grid.get_Vm(); + const RealVect Va = grid.get_Va(); + REQUIRE(V_get.size() == 3); + for (Eigen::Index i = 0; i < 3; ++i) { + CHECK(std::abs(V_get(i) - V(i)) < 1e-10); + CHECK(Vm(i) == Approx(std::abs(V(i))).epsilon(1e-10)); + CHECK(Va(i) == Approx(std::arg(V(i))).margin(1e-10)); + } + } +} + +TEST_CASE("a diverging AC powerflow returns an empty vector, not garbage", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + // 100 GW through a 0.1 pu line cannot converge + grid.change_p_load(0, 1e5); + const CplxVect V = solve_ac(grid); + CHECK(V.size() == 0); + CHECK_FALSE(grid.get_algo().converged()); + CHECK(grid.get_algo().get_error() != ls2g::ErrorType::NoError); +} + +TEST_CASE("changing setpoints takes effect on the next solve", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + REQUIRE(solve_ac(grid).size() == 3); + const real_type slack_p_50 = std::get<0>(grid.get_gen_res())(0); + + grid.change_p_load(0, 80.); + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::get<0>(grid.get_loads_res())(0) == Approx(80.).epsilon(1e-8)); + CHECK(std::get<0>(grid.get_gen_res())(0) > slack_p_50); + + grid.change_v_gen(0, 1.05); + const CplxVect V = solve_ac(grid); + REQUIRE(V.size() == 3); + CHECK(std::abs(V(0)) == Approx(1.05).epsilon(1e-8)); +} + +TEST_CASE("deactivating and reactivating a load", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + + grid.deactivate_load(0); + REQUIRE(solve_ac(grid).size() == 3); + // nothing consumed: the slack only covers (near-zero) losses + CHECK(std::get<0>(grid.get_loads_res())(0) == Approx(0.).margin(1e-8)); + CHECK(std::get<0>(grid.get_gen_res())(0) == Approx(0.).margin(1e-6)); + + grid.reactivate_load(0); + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::get<0>(grid.get_loads_res())(0) == Approx(50.).epsilon(1e-8)); +} + +TEST_CASE("copies are independent and solve to the same state", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + LSGrid other = grid.copy(); + + const CplxVect V = solve_ac(grid); + const CplxVect V_other = solve_ac(other); + REQUIRE(V.size() == 3); + REQUIRE(V_other.size() == 3); + CHECK((V - V_other).norm() < 1e-10); + + // mutating the copy must not touch the original + other.change_p_load(0, 80.); + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::get<0>(grid.get_loads_res())(0) == Approx(50.).epsilon(1e-8)); +} + +TEST_CASE("get_state / set_state round-trips the whole grid", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + LSGrid::StateRes state = grid.get_state(); + + LSGrid restored; + restored.set_state(state); + + const CplxVect V = solve_ac(grid); + const CplxVect V_restored = solve_ac(restored); + REQUIRE(V.size() == 3); + REQUIRE(V_restored.size() == 3); + CHECK((V - V_restored).norm() < 1e-10); + CHECK(restored.get_sn_mva() == Approx(100.)); +} + +TEST_CASE("save_binary / load_binary round-trips the whole grid", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + ls2g_test::TempFile file; + grid.save_binary(file.str()); + + LSGrid loaded = LSGrid::load_binary(file.str()); + const CplxVect V = solve_ac(grid); + const CplxVect V_loaded = solve_ac(loaded); + REQUIRE(V.size() == 3); + REQUIRE(V_loaded.size() == 3); + CHECK((V - V_loaded).norm() < 1e-10); +} + +TEST_CASE("documented error paths", "[LSGrid]") +{ + LSGrid grid = make_three_bus_grid(); + + SECTION("Vinit of the wrong size is rejected") { + CHECK_THROWS_AS(grid.ac_pf(CplxVect::Constant(2, cplx_type(1., 0.)), 20, 1e-8), + std::runtime_error); + CHECK_THROWS_AS(grid.dc_pf(CplxVect::Constant(7, cplx_type(1., 0.)), 1, 1e-8), + std::runtime_error); + } + SECTION("slack registration validates the generator id and weight") { + CHECK_THROWS_AS(grid.add_gen_slackbus(12, 1.), std::runtime_error); + CHECK_THROWS_AS(grid.add_gen_slackbus(-1, 1.), std::runtime_error); + CHECK_THROWS_AS(grid.add_gen_slackbus(0, 0.), std::runtime_error); + } + SECTION("element setters validate the element id") { + CHECK_THROWS_AS(grid.change_p_load(3, 10.), std::out_of_range); + CHECK_THROWS_AS(grid.change_v_gen(3, 1.), std::out_of_range); + CHECK_THROWS_AS(grid.deactivate_load(3), std::out_of_range); + } +} From bd51caca4845dc1401fe83a176485b3cda22c9bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:48:37 +0000 Subject: [PATCH 093/166] Address review comments on the C++ library docs - GridModel does not exist anymore: the python class carries the same LSGrid name as the C++ one. - the wheel that first ships the core library will be 1.0, not 0.14. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H5MjAoBz1WWcVpZGkJTM5K Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- docs/cpp_library.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cpp_library.rst b/docs/cpp_library.rst index b8e30f7e..611b25ec 100644 --- a/docs/cpp_library.rst +++ b/docs/cpp_library.rst @@ -23,8 +23,8 @@ Everything is in the ``ls2g`` namespace, headers at the root of - ``ls2g::LSGrid`` (``LSGrid.hpp``): the grid model itself -- built programmatically through the ``init_*`` methods (buses, powerlines, trafos, loads, generators, ...), solved with ``ac_pf`` / ``dc_pf``, inspected with - the ``get_*_res`` methods. This is the C++ class exposed to python as - ``GridModel``. + the ``get_*_res`` methods. This is the C++ class exposed to python under + the same name, ``LSGrid``. - the element containers (``element_container/*.hpp``), time series and contingency analysis (``batch_algorithm/``), the powerflow algorithms (``powerflow_algorithm/``) and linear solvers (``linear_solvers/``). @@ -42,7 +42,7 @@ need your own copy. Option 1: use the copy shipped with the python wheel ----------------------------------------------------- -Every ``pip install lightsim2grid`` (>= 0.14) already contains everything a +Every ``pip install lightsim2grid`` (>= 1.0) already contains everything a C++ consumer needs, inside the installed package directory: - ``liblightsim2grid_core.so`` (or ``.dylib`` / ``.dll``) next to the python From 6423997c2650fc455ebd1960caca770d2ba92f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:57:47 +0000 Subject: [PATCH 094/166] Address review comments: C++26 in the standard cascade, more LSGrid element tests - src/tests/CMakeLists.txt: try cxx_std_26 first in the standalone standard cascade. The feature only appears in CMAKE_CXX_COMPILE_FEATURES when both CMake (>= 3.30) and the compiler support C++26, so with the 3.16 minimum the branch is simply never taken elsewhere. - src/tests/test_lsgrid.cpp: extend the LSGrid coverage per review with the remaining element types and slack/voltage-control scenarios, on the same 3-bus fixture (plus a two-generator variant of it): * shunts: a capacitive bank raises its bus voltage and shows a reactive injection in get_shunts_res; * storage units: a charging unit behaves as an extra load (load sign convention), covered by the slack; * SVC: VOLTAGE mode holds the regulated bus at its setpoint, REACTIVE_POWER mode injects exactly its setpoint, OFF mode is a no-op; * HVDC: a lossless VSC-VSC link moves its 20 MW setpoint (rectifier consumes, inverter injects), a voltage-regulating VSC station holds its bus, an LCC station consumes reactive per its power factor (q = -|p| tan(acos(pf))); * distributed slack: two slack generators (weights 0.7/0.3) share the mismatch proportionally to their weights; * remote voltage control: a PV generator regulating another bus pins that bus to its target_vm_pu; out-of-range regulated bus throws. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H5MjAoBz1WWcVpZGkJTM5K Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- src/tests/CMakeLists.txt | 7 +- src/tests/test_lsgrid.cpp | 214 +++++++++++++++++++++++++++++++++++++- 2 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 51f6b568..3fff646d 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -10,7 +10,12 @@ if(NOT PROJECT_NAME) # package version file from PROJECT_VERSION project(lightsim2grid_tests VERSION 0.0.0 LANGUAGES CXX) - if("cxx_std_23" IN_LIST CMAKE_CXX_COMPILE_FEATURES) + # cxx_std_26 only appears in CMAKE_CXX_COMPILE_FEATURES when both CMake + # (>= 3.30) and the compiler know C++26, so the check is safe with the + # 3.16 minimum above: elsewhere it is simply never selected. + if("cxx_std_26" IN_LIST CMAKE_CXX_COMPILE_FEATURES) + set(CMAKE_CXX_STANDARD 26) + elseif("cxx_std_23" IN_LIST CMAKE_CXX_COMPILE_FEATURES) set(CMAKE_CXX_STANDARD 23) elseif("cxx_std_17" IN_LIST CMAKE_CXX_COMPILE_FEATURES) set(CMAKE_CXX_STANDARD 17) diff --git a/src/tests/test_lsgrid.cpp b/src/tests/test_lsgrid.cpp index 9071a5fe..1730e579 100644 --- a/src/tests/test_lsgrid.cpp +++ b/src/tests/test_lsgrid.cpp @@ -34,10 +34,10 @@ using ls2g::real_type; namespace { -// Slack generator (1.02 pu) at bus 0, two identical lines 0-1 and 1-2 -// (x = 0.1 pu each), one 50 MW / 10 MVar load at bus 2. sn_mva = 100, so the -// load is 0.5 pu and (with r = 0) the exact DC angles are 0 / -0.05 / -0.10. -LSGrid make_three_bus_grid(real_type r_pu = 0.01) +// Three 138 kV buses, two identical lines 0-1 and 1-2 (x = 0.1 pu each), one +// 50 MW / 10 MVar load at bus 2. sn_mva = 100, so the load is 0.5 pu and +// (with r = 0) the exact DC angles are 0 / -0.05 / -0.10. No generator yet. +LSGrid make_three_bus_skeleton(real_type r_pu) { LSGrid grid; grid.set_sn_mva(100.); @@ -62,7 +62,13 @@ LSGrid make_three_bus_grid(real_type r_pu = 0.01) Eigen::VectorXi load_bus(1); load_bus << 2; grid.init_loads(load_p, load_q, load_bus); + return grid; +} +// the skeleton plus a single slack generator (1.02 pu) at bus 0 +LSGrid make_three_bus_grid(real_type r_pu = 0.01) +{ + LSGrid grid = make_three_bus_skeleton(r_pu); RealVect gen_p(1), gen_v(1), gen_min_q(1), gen_max_q(1); gen_p << 0.; gen_v << 1.02; @@ -75,6 +81,22 @@ LSGrid make_three_bus_grid(real_type r_pu = 0.01) return grid; } +// the skeleton plus generators at bus 0 (1.02 pu) and bus 1 (gen1_v_pu). +// NO slack is registered: each test declares the slack setup it needs. +LSGrid make_three_bus_grid_two_gens(real_type gen1_v_pu = 1.02) +{ + LSGrid grid = make_three_bus_skeleton(0.01); + RealVect gen_p(2), gen_v(2), gen_min_q(2), gen_max_q(2); + gen_p << 0., 0.; + gen_v << 1.02, gen1_v_pu; + gen_min_q << -1000., -1000.; + gen_max_q << 1000., 1000.; + Eigen::VectorXi gen_bus(2); + gen_bus << 0, 1; + grid.init_generators(gen_p, gen_v, gen_min_q, gen_max_q, gen_bus); + return grid; +} + CplxVect flat_start(const LSGrid & grid) { return CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); @@ -255,6 +277,190 @@ TEST_CASE("save_binary / load_binary round-trips the whole grid", "[LSGrid]") CHECK((V - V_loaded).norm() < 1e-10); } +TEST_CASE("a capacitive shunt raises the local voltage", "[LSGrid][shunt]") +{ + LSGrid base = make_three_bus_grid(); + REQUIRE(solve_ac(base).size() == 3); + const real_type vm2_without = std::abs(base.get_V()(2)); + + LSGrid grid = make_three_bus_grid(); + // pandapower convention: q_mvar is what the shunt consumes at v = 1 pu, + // so -25 MVar is a 25 MVar capacitor bank + RealVect shunt_p(1), shunt_q(1); + shunt_p << 0.; + shunt_q << -25.; + Eigen::VectorXi shunt_bus(1); + shunt_bus << 2; + grid.init_shunt(shunt_p, shunt_q, shunt_bus); + + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::abs(grid.get_V()(2)) > vm2_without); + // the shunt injects reactive power (generator sign convention on results) + CHECK(std::get<1>(grid.get_shunts_res())(0) < 0.); +} + +TEST_CASE("a charging storage unit behaves as an extra load", "[LSGrid][storage]") +{ + LSGrid grid = make_three_bus_grid(); + // load convention: positive target_p = charging (consumes from the grid) + RealVect storage_p(1), storage_q(1); + storage_p << 10.; + storage_q << 0.; + Eigen::VectorXi storage_bus(1); + storage_bus << 1; + grid.init_storages(storage_p, storage_q, storage_bus); + + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::get<0>(grid.get_storages_res())(0) == Approx(10.).epsilon(1e-8)); + // the slack now covers the load, the storage and the (positive) losses + CHECK(std::get<0>(grid.get_gen_res())(0) > 60.); +} + +TEST_CASE("static var compensators", "[LSGrid][svc]") +{ + LSGrid base = make_three_bus_grid(); + REQUIRE(solve_ac(base).size() == 3); + const CplxVect V_without = base.get_V(); + + // SvcContainer::RegulationMode: OFF = 0, VOLTAGE = 1, REACTIVE_POWER = 2 + RealVect slope(1), b_min(1), b_max(1); + slope << 0.; + b_min << -100.; + b_max << 100.; + Eigen::VectorXi reg_bus(1), svc_bus(1); + reg_bus << 2; + svc_bus << 2; + + SECTION("VOLTAGE mode holds the regulated bus at its setpoint") { + LSGrid grid = make_three_bus_grid(); + RealVect target_vm(1), q_set(1); + target_vm << 1.03; + q_set << 0.; + grid.init_svcs({1}, target_vm, q_set, slope, b_min, b_max, reg_bus, svc_bus); + grid.tell_solver_need_reset(); + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::abs(grid.get_V()(2)) == Approx(1.03).epsilon(1e-8)); + // raising the bus above its natural voltage takes reactive injection + CHECK(std::get<1>(grid.get_svcs().get_res())(0) > 0.); + } + SECTION("REACTIVE_POWER mode injects exactly its setpoint") { + LSGrid grid = make_three_bus_grid(); + RealVect target_vm(1), q_set(1); + target_vm << 1.0; + q_set << 25.; + grid.init_svcs({2}, target_vm, q_set, slope, b_min, b_max, reg_bus, svc_bus); + grid.tell_solver_need_reset(); + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::get<1>(grid.get_svcs().get_res())(0) == Approx(25.).epsilon(1e-8)); + CHECK(std::abs(grid.get_V()(2)) > std::abs(V_without(2))); + } + SECTION("OFF mode changes nothing") { + LSGrid grid = make_three_bus_grid(); + RealVect target_vm(1), q_set(1); + target_vm << 1.03; + q_set << 25.; + grid.init_svcs({0}, target_vm, q_set, slope, b_min, b_max, reg_bus, svc_bus); + grid.tell_solver_need_reset(); + REQUIRE(solve_ac(grid).size() == 3); + CHECK((grid.get_V() - V_without).norm() < 1e-10); + } +} + +TEST_CASE("HVDC links between two converter stations", "[LSGrid][hvdc]") +{ + // one hvdc line between bus 1 (side 1, rectifier) and bus 2 (side 2), + // drawing 20 MW; helper filling the many init_hvdc_lines arguments + const auto add_hvdc = [](LSGrid & grid, int type2, bool vreg2_on, + real_type vm2, real_type power_factor2) { + Eigen::VectorXi bus1(1), bus2(1); + bus1 << 1; + bus2 << 2; + // ConverterStationContainer::ConverterType: VSC = 0, LCC = 1 + const std::vector type1{0}; + // HvdcLineContainer::ConvertersMode: SIDE_1_RECTIFIER = 0 + const std::vector mode{0}; + const std::vector vreg1{false}, vreg2{vreg2_on}, no_droop{false}; + const RealVect zero = RealVect::Zero(1); + RealVect vm1_pu(1), vm2_pu(1), q_min(1), q_max(1); + RealVect pf1(1), pf2(1), p_set(1), p_max(1); + vm1_pu << 1.0; + vm2_pu << vm2; + q_min << -1e6; + q_max << 1e6; + pf1 << 1.; + pf2 << power_factor2; + p_set << 20.; + p_max << 300.; + grid.init_hvdc_lines(bus1, bus2, type1, {type2}, + zero, zero, // no station losses + vreg1, vreg2, vm1_pu, vm2_pu, + zero, zero, // q setpoints + q_min, q_max, q_min, q_max, + pf1, pf2, mode, p_set, + zero, zero, // r_ohm, nominal_v (no line loss) + no_droop, zero, zero, p_max, p_max); + grid.tell_solver_need_reset(); + }; + + SECTION("a lossless VSC-VSC link moves its power setpoint") { + LSGrid grid = make_three_bus_grid(); + add_hvdc(grid, /*type2=VSC*/ 0, false, 1.0, 1.); + REQUIRE(solve_ac(grid).size() == 3); + // generator sign convention: the rectifier consumes at bus 1, the + // inverter injects the (lossless) 20 MW at bus 2 + CHECK(std::get<0>(grid.get_dcline_res1())(0) == Approx(-20.).epsilon(1e-8)); + CHECK(std::get<0>(grid.get_dcline_res2())(0) == Approx(20.).epsilon(1e-8)); + } + SECTION("a voltage-regulating VSC station holds its bus") { + LSGrid grid = make_three_bus_grid(); + add_hvdc(grid, /*type2=VSC*/ 0, true, 1.03, 1.); + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::abs(grid.get_V()(2)) == Approx(1.03).epsilon(1e-8)); + } + SECTION("an LCC station consumes reactive per its power factor") { + LSGrid grid = make_three_bus_grid(); + add_hvdc(grid, /*type2=LCC*/ 1, false, 1.0, 0.9); + REQUIRE(solve_ac(grid).size() == 3); + const real_type p2 = std::get<0>(grid.get_dcline_res2())(0); + const real_type q2 = std::get<1>(grid.get_dcline_res2())(0); + CHECK(p2 == Approx(20.).epsilon(1e-8)); + CHECK(q2 == Approx(-std::abs(p2) * std::tan(std::acos(0.9))).epsilon(1e-6)); + } +} + +TEST_CASE("distributed slack splits the mismatch by weight", "[LSGrid][slack]") +{ + LSGrid grid = make_three_bus_grid_two_gens(); + grid.add_gen_slackbus(0, 0.7); + grid.add_gen_slackbus(1, 0.3); + + REQUIRE(solve_ac(grid).size() == 3); + // both setpoints are 0 MW, so each gen's whole output is its slack share, + // and shares are proportional to the weights + const auto gen_res = grid.get_gen_res(); + const real_type p0 = std::get<0>(gen_res)(0); + const real_type p1 = std::get<0>(gen_res)(1); + CHECK(p0 > 0.); + CHECK(p1 > 0.); + CHECK(p0 / 0.7 == Approx(p1 / 0.3).epsilon(1e-6)); +} + +TEST_CASE("remote voltage control pins the regulated bus", "[LSGrid][vctrl]") +{ + // the PV generator at bus 1 (target 1.04 pu) regulates bus 2 instead of + // its own bus (bordered VoltageControl extension, AC NR only) + LSGrid grid = make_three_bus_grid_two_gens(1.04); + grid.add_gen_slackbus(0, 1.); + grid.set_gen_regulated_bus(1, 2); + grid.tell_solver_need_reset(); + + REQUIRE(solve_ac(grid).size() == 3); + CHECK(std::abs(grid.get_V()(2)) == Approx(1.04).epsilon(1e-8)); + + // out-of-range regulated bus is rejected + CHECK_THROWS_AS(grid.set_gen_regulated_bus(1, 12), std::out_of_range); +} + TEST_CASE("documented error paths", "[LSGrid]") { LSGrid grid = make_three_bus_grid(); From 48ff859c7710877eb10014d27c957ce0e660d1e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:13:30 +0000 Subject: [PATCH 095/166] Address review comments: HVDC droop tests, unfeasible dual-controller case, trafo tests - HVDC: a VSC-VSC link with angle droop enabled is checked against the droop law itself (transferred power == p0 + k * (theta1 - theta2), with k converted from MW/deg to MW/rad), and enabling droop on a line with an LCC station is asserted to be rejected (runtime_error, VSC-VSC only). - Voltage control: two generators on the same bus, one regulating that bus locally and the other regulating a remote bus, is an unfeasible state -- the remote controller's own bus has no reactive equation left -- and ac_pf must reject it with the dedicated "no reactive (Q) equation" error instead of producing garbage. - Transformers (previously untested): a radial trafo shows the tap ratio scaling the downstream voltage, a trafo closing a loop shows the phase shifter redirecting flow (opposite shifts move it in opposite directions), and in both cases mutating a solved grid through change_ratio_trafo / change_shift_trafo_deg is asserted to reach the exact same state as initializing with those values directly. 43 test cases, all green and valgrind-clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H5MjAoBz1WWcVpZGkJTM5K Signed-off-by: Claude Signed-off-by: Benjamin Donnot --- CHANGELOG.rst | 9 +- src/tests/test_lsgrid.cpp | 177 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3fa2cd6e..d961125c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -443,7 +443,14 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. (converged => per-bus V, diverged => empty vector), physically-checked results (power balance, analytic DC angles), copy / `get_state` / `save_binary` round trips, setpoint changes, load deactivation and the - documented error paths. The test target now links `lightsim2grid_core`. + documented error paths. Also covers every other element type and control + scenario: shunts, storage units, SVCs (all three regulation modes), HVDC + (VSC-VSC with and without angle droop, voltage-regulating VSC, LCC power + factor, LCC+droop rejection), transformers (tap ratio and phase shifter, + incl. the `change_ratio_trafo` / `change_shift_trafo` setters), distributed + slack, remote generator voltage control and the rejection of an unfeasible + local+remote controller pair on one bus. The test target now links + `lightsim2grid_core`. - [ADDED] documentation for using `lightsim2grid_core` as a standalone C++ library (`docs/cpp_library.rst`): building/installing it from source (`cmake -S src/core`), consuming the copy shipped inside the python wheel diff --git a/src/tests/test_lsgrid.cpp b/src/tests/test_lsgrid.cpp index 1730e579..4953f9bf 100644 --- a/src/tests/test_lsgrid.cpp +++ b/src/tests/test_lsgrid.cpp @@ -21,11 +21,15 @@ #include #include +#include +#include #include "LSGrid.hpp" #include "test_helpers.hpp" using Catch::Approx; +using Catch::Matchers::ContainsSubstring; +using Catch::Matchers::MessageMatches; using ls2g::LSGrid; using ls2g::CplxVect; using ls2g::RealVect; @@ -368,10 +372,13 @@ TEST_CASE("static var compensators", "[LSGrid][svc]") TEST_CASE("HVDC links between two converter stations", "[LSGrid][hvdc]") { - // one hvdc line between bus 1 (side 1, rectifier) and bus 2 (side 2), - // drawing 20 MW; helper filling the many init_hvdc_lines arguments + // one hvdc line between bus 1 (side 1, rectifier) and bus 2 (side 2); + // helper filling the many init_hvdc_lines arguments const auto add_hvdc = [](LSGrid & grid, int type2, bool vreg2_on, - real_type vm2, real_type power_factor2) { + real_type vm2, real_type power_factor2, + real_type p_setpoint = 20., + bool droop = false, real_type droop_p0 = 0., + real_type droop_k_per_deg = 0.) { Eigen::VectorXi bus1(1), bus2(1); bus1 << 1; bus2 << 2; @@ -379,18 +386,20 @@ TEST_CASE("HVDC links between two converter stations", "[LSGrid][hvdc]") const std::vector type1{0}; // HvdcLineContainer::ConvertersMode: SIDE_1_RECTIFIER = 0 const std::vector mode{0}; - const std::vector vreg1{false}, vreg2{vreg2_on}, no_droop{false}; + const std::vector vreg1{false}, vreg2{vreg2_on}, droop_on{droop}; const RealVect zero = RealVect::Zero(1); RealVect vm1_pu(1), vm2_pu(1), q_min(1), q_max(1); - RealVect pf1(1), pf2(1), p_set(1), p_max(1); + RealVect pf1(1), pf2(1), p_set(1), p_max(1), p0(1), k_deg(1); vm1_pu << 1.0; vm2_pu << vm2; q_min << -1e6; q_max << 1e6; pf1 << 1.; pf2 << power_factor2; - p_set << 20.; + p_set << p_setpoint; p_max << 300.; + p0 << droop_p0; + k_deg << droop_k_per_deg; grid.init_hvdc_lines(bus1, bus2, type1, {type2}, zero, zero, // no station losses vreg1, vreg2, vm1_pu, vm2_pu, @@ -398,7 +407,7 @@ TEST_CASE("HVDC links between two converter stations", "[LSGrid][hvdc]") q_min, q_max, q_min, q_max, pf1, pf2, mode, p_set, zero, zero, // r_ohm, nominal_v (no line loss) - no_droop, zero, zero, p_max, p_max); + droop_on, p0, k_deg, p_max, p_max); grid.tell_solver_need_reset(); }; @@ -426,6 +435,28 @@ TEST_CASE("HVDC links between two converter stations", "[LSGrid][hvdc]") CHECK(p2 == Approx(20.).epsilon(1e-8)); CHECK(q2 == Approx(-std::abs(p2) * std::tan(std::acos(0.9))).epsilon(1e-6)); } + SECTION("a VSC-VSC link with angle droop follows the droop law") { + LSGrid grid = make_three_bus_grid(); + const real_type p0 = 10., k_per_deg = 2.; + add_hvdc(grid, /*type2=VSC*/ 0, false, 1.0, 1., /*p_setpoint=*/0., + /*droop=*/true, p0, k_per_deg); + const CplxVect V = solve_ac(grid); + REQUIRE(V.size() == 3); + // linear regime: transferred power (1 -> 2) is p0 + k * (theta1 - theta2), + // with k converted from MW/deg to MW/rad + const real_type k_per_rad = k_per_deg * 180. / std::acos(real_type(-1.)); + const real_type expected = p0 + k_per_rad * (std::arg(V(1)) - std::arg(V(2))); + CHECK(std::get<0>(grid.get_dcline_res2())(0) == Approx(expected).epsilon(1e-6)); + CHECK(std::get<0>(grid.get_dcline_res1())(0) == Approx(-expected).epsilon(1e-6)); + } + SECTION("angle droop cannot be combined with an LCC station") { + LSGrid grid = make_three_bus_grid(); + REQUIRE_THROWS_MATCHES( + add_hvdc(grid, /*type2=LCC*/ 1, false, 1.0, 0.9, /*p_setpoint=*/0., + /*droop=*/true, 10., 2.), + std::runtime_error, + MessageMatches(ContainsSubstring("only available for VSC - VSC"))); + } } TEST_CASE("distributed slack splits the mismatch by weight", "[LSGrid][slack]") @@ -461,6 +492,138 @@ TEST_CASE("remote voltage control pins the regulated bus", "[LSGrid][vctrl]") CHECK_THROWS_AS(grid.set_gen_regulated_bus(1, 12), std::out_of_range); } +TEST_CASE("a local and a remote voltage controller on one bus is unfeasible", "[LSGrid][vctrl]") +{ + // two generators on bus 1: gen 1 regulates its own bus (ordinary PV + // pinning) while gen 2 regulates bus 2 remotely. Gen 2's own bus then + // has no reactive equation left to trade against the remote target, so + // the solve must reject the state instead of producing garbage. + LSGrid grid = make_three_bus_skeleton(0.01); + RealVect gen_p(3), gen_v(3), gen_min_q(3), gen_max_q(3); + gen_p << 0., 0., 0.; + gen_v << 1.02, 1.02, 1.04; + gen_min_q << -1000., -1000., -1000.; + gen_max_q << 1000., 1000., 1000.; + Eigen::VectorXi gen_bus(3); + gen_bus << 0, 1, 1; + grid.init_generators(gen_p, gen_v, gen_min_q, gen_max_q, gen_bus); + grid.add_gen_slackbus(0, 1.); + grid.set_gen_regulated_bus(2, 2); + grid.tell_solver_need_reset(); + + REQUIRE_THROWS_MATCHES( + solve_ac(grid), std::runtime_error, + MessageMatches(ContainsSubstring("no reactive (Q) equation"))); +} + +TEST_CASE("transformers: tap ratio and phase shifter", "[LSGrid][trafo]") +{ + // buses at 138 kV, slack gen (1.02 pu) at bus 0, 50 MW / 10 MVar load at + // bus 2. `loop` false: line 0-1 plus a trafo 1-2 (radial, for the ratio + // effect); true: lines 0-1 and 1-2 plus a trafo 0-2 closing a loop (a + // phase shifter only redirects flow inside a loop). + const auto make_trafo_grid = [](bool loop, real_type ratio, real_type shift_deg) { + LSGrid grid; + grid.set_sn_mva(100.); + grid.set_init_vm_pu(1.0); + RealVect bus_vn_kv(3); + bus_vn_kv << 138., 138., 138.; + grid.init_bus(3, 1, bus_vn_kv, 0, 0); + + const int nb_line = loop ? 2 : 1; + RealVect line_r(nb_line), line_x(nb_line); + Eigen::VectorXi line_from(nb_line), line_to(nb_line); + if (loop) { + line_r << 0.01, 0.01; + line_x << 0.1, 0.1; + line_from << 0, 1; + line_to << 1, 2; + } else { + line_r << 0.01; + line_x << 0.1; + line_from << 0; + line_to << 1; + } + grid.init_powerlines(line_r, line_x, CplxVect::Zero(nb_line), line_from, line_to); + + RealVect trafo_r(1), trafo_x(1), trafo_ratio(1), trafo_shift(1); + trafo_r << 0.01; + trafo_x << 0.2; + trafo_ratio << ratio; + trafo_shift << shift_deg; // degrees at init time + Eigen::VectorXi trafo_bus1(1), trafo_bus2(1); + trafo_bus1 << (loop ? 0 : 1); + trafo_bus2 << 2; + grid.init_trafo(trafo_r, trafo_x, CplxVect::Zero(1), trafo_ratio, trafo_shift, + {true}, trafo_bus1, trafo_bus2, true); + + RealVect load_p(1), load_q(1); + load_p << 50.; + load_q << 10.; + Eigen::VectorXi load_bus(1); + load_bus << 2; + grid.init_loads(load_p, load_q, load_bus); + + RealVect gen_p(1), gen_v(1), gen_min_q(1), gen_max_q(1); + gen_p << 0.; + gen_v << 1.02; + gen_min_q << -1000.; + gen_max_q << 1000.; + Eigen::VectorXi gen_bus(1); + gen_bus << 0; + grid.init_generators(gen_p, gen_v, gen_min_q, gen_max_q, gen_bus); + grid.add_gen_slackbus(0, 1.); + return grid; + }; + + SECTION("the tap ratio scales the downstream voltage") { + LSGrid grid = make_trafo_grid(false, 1., 0.); + REQUIRE(solve_ac(grid).size() == 3); + const real_type vm2_ratio1 = std::abs(grid.get_V()(2)); + + grid.change_ratio_trafo(0, 1.05); + const CplxVect V_changed = solve_ac(grid); + REQUIRE(V_changed.size() == 3); + // raising the tap on the hv side lowers the lv-side voltage, by + // roughly the ratio (not exactly: the load's voltage dependence) + CHECK(std::abs(V_changed(2)) < vm2_ratio1); + CHECK(std::abs(V_changed(2)) == Approx(vm2_ratio1 / 1.05).epsilon(0.02)); + + // changing the ratio on a solved grid == initializing with it + LSGrid fresh = make_trafo_grid(false, 1.05, 0.); + const CplxVect V_fresh = solve_ac(fresh); + REQUIRE(V_fresh.size() == 3); + CHECK((V_changed - V_fresh).norm() < 1e-8); + } + + SECTION("the phase shifter redirects the loop flow") { + LSGrid grid = make_trafo_grid(true, 1., 0.); + REQUIRE(solve_ac(grid).size() == 3); + const real_type p_trafo_0 = std::get<0>(grid.get_trafo_res1())(0); + + grid.change_shift_trafo_deg(0, 5.); + REQUIRE(solve_ac(grid).size() == 3); + const real_type p_trafo_plus = std::get<0>(grid.get_trafo_res1())(0); + const CplxVect V_plus = grid.get_V(); + + grid.change_shift_trafo_deg(0, -5.); + REQUIRE(solve_ac(grid).size() == 3); + const real_type p_trafo_minus = std::get<0>(grid.get_trafo_res1())(0); + + // opposite shifts move the trafo flow in opposite directions, and + // the load is served throughout + CHECK(p_trafo_plus != Approx(p_trafo_0).margin(1.)); + CHECK((p_trafo_plus - p_trafo_0) * (p_trafo_minus - p_trafo_0) < 0.); + CHECK(std::get<0>(grid.get_loads_res())(0) == Approx(50.).epsilon(1e-8)); + + // changing the shift on a solved grid == initializing with it + LSGrid fresh = make_trafo_grid(true, 1., 5.); + const CplxVect V_fresh = solve_ac(fresh); + REQUIRE(V_fresh.size() == 3); + CHECK((V_plus - V_fresh).norm() < 1e-8); + } +} + TEST_CASE("documented error paths", "[LSGrid]") { LSGrid grid = make_three_bus_grid(); From 38356d73d37e494e8442b818e12d8aae83b72e0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:42:40 +0000 Subject: [PATCH 096/166] Eliminate unnecessary Eigen copies on the powerflow hot path - BaseFDPFAlgo: solve the P-iteration in place on p_ instead of copying into a temporary, matching the Q-iteration right below it. - NRSystem::mismatch(): cache the read-only zero dx vector instead of heap-allocating and zero-filling it on every call (>=2x per NR iter). - GaussSeidelAlgo/GaussSeidelSynchAlgo::one_iter: take pq as Eigen::Ref instead of a concrete VectorXi, avoiding a copy every GS iteration when the caller already holds a Ref. - BaseAlgo::isin: take the vector by Eigen::Ref instead of by value, removing an O(n_bus) chain of copies from fill_mat_bus_id. - Element containers: change bus_vn_kv (compute_results/_compute_results, v_kv_from_vpu, v_deg_from_va) and _get_amps's p/q/v params from concrete RealVect to Eigen::Ref, matching what the producers (SubstationContainer::get_bus_vn_kv, OneSideContainer's tuple3d getters) already return. Removes 9 full-vector copies and 6 more per LSGrid::compute_results() call (i.e. every powerflow solve). All changes verified against the Catch2 C++ test suite (43/43 passing). Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/element_container/ConverterStationContainer.cpp | 2 +- src/core/element_container/ConverterStationContainer.hpp | 2 +- src/core/element_container/GeneratorContainer.hpp | 2 +- src/core/element_container/GenericContainer.cpp | 9 ++++++--- src/core/element_container/GenericContainer.hpp | 9 ++++++--- src/core/element_container/HvdcLineContainer.cpp | 2 +- src/core/element_container/HvdcLineContainer.hpp | 2 +- src/core/element_container/LineContainer.hpp | 2 +- src/core/element_container/LoadContainer.hpp | 2 +- src/core/element_container/OneSideContainer.hpp | 4 ++-- src/core/element_container/OneSideContainer_PQ.hpp | 2 +- .../element_container/OneSideContainer_forBranch.hpp | 2 +- src/core/element_container/SGenContainer.hpp | 2 +- src/core/element_container/ShuntContainer.cpp | 2 +- src/core/element_container/ShuntContainer.hpp | 2 +- src/core/element_container/StorageContainer.hpp | 2 +- src/core/element_container/SvcContainer.cpp | 2 +- src/core/element_container/SvcContainer.hpp | 2 +- src/core/element_container/TrafoContainer.hpp | 2 +- src/core/element_container/TwoSidesContainer_rxh_A.hpp | 4 ++-- src/core/powerflow_algorithm/BaseAlgo.hpp | 2 +- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 5 ++--- src/core/powerflow_algorithm/GaussSeidelAlgo.cpp | 4 ++-- src/core/powerflow_algorithm/GaussSeidelAlgo.hpp | 4 ++-- src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp | 4 ++-- src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp | 4 ++-- src/core/powerflow_algorithm/NRSystem.hpp | 5 +++++ src/core/powerflow_algorithm/NRSystem.tpp | 4 +++- 28 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index 663365f3..9e66d1fe 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -360,7 +360,7 @@ void ConverterStationContainer::_compute_results( const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool ac) { diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index 49f62592..2a96541d 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -171,7 +171,7 @@ class LS2G_API ConverterStationContainer : public OneSideContainer_PQ, public It const Eigen::Ref & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac) override; diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 1b78eb6a..fd2a0db7 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -156,7 +156,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool ac) override { set_osc_pq_res_p(); diff --git a/src/core/element_container/GenericContainer.cpp b/src/core/element_container/GenericContainer.cpp index d6dfbbc3..b933f013 100644 --- a/src/core/element_container/GenericContainer.cpp +++ b/src/core/element_container/GenericContainer.cpp @@ -16,7 +16,10 @@ namespace ls2g { const int GenericContainer::_deactivated_bus_id = BaseConstants::_deactivated_bus_id; // TODO all functions bellow are generic ! Make a base class for that -void GenericContainer::_get_amps(RealVect & a, const RealVect & p, const RealVect & q, const RealVect & v) const { +void GenericContainer::_get_amps(Eigen::Ref a, + const Eigen::Ref & p, + const Eigen::Ref & q, + const Eigen::Ref & v) const { RealVect p2q2 = p.array() * p.array() + q.array() * q.array(); p2q2 = p2q2.array().cwiseSqrt(); @@ -115,7 +118,7 @@ void GenericContainer::v_kv_from_vpu(const Eigen::Ref & /*Va*/, int nb_element, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, RealVect & v) const { for(int el_id = 0; el_id < nb_element; ++el_id){ @@ -153,7 +156,7 @@ void GenericContainer::v_deg_from_va(const Eigen::Ref & Va, int nb_element, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, RealVect & theta) const { for(int el_id = 0; el_id < nb_element; ++el_id){ diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index f6d1054b..491f444b 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -199,7 +199,10 @@ class LS2G_API GenericContainer : public BaseConstants /** compute the amps from the p, the q and the v (v should NOT be pair unit) **/ - void _get_amps(RealVect & a, const RealVect & p, const RealVect & q, const RealVect & v) const; + void _get_amps(Eigen::Ref a, + const Eigen::Ref & p, + const Eigen::Ref & q, + const Eigen::Ref & v) const; /** convert v from pu to v in kv (and assign it to the right element...) @@ -210,7 +213,7 @@ class LS2G_API GenericContainer : public BaseConstants int nb_element, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, RealVect & v) const; @@ -223,7 +226,7 @@ class LS2G_API GenericContainer : public BaseConstants int nb_element, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, RealVect & v) const; }; diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index bd9b0bd5..86961134 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -419,7 +419,7 @@ void HvdcLineContainer::compute_results(const Eigen::Ref & Va, const Eigen::Ref & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac) { diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index 31141a2d..ea201e7b 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -375,7 +375,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac); diff --git a/src/core/element_container/LineContainer.hpp b/src/core/element_container/LineContainer.hpp index 50cad88d..794ac7f4 100644 --- a/src/core/element_container/LineContainer.hpp +++ b/src/core/element_container/LineContainer.hpp @@ -86,7 +86,7 @@ class LS2G_API LineContainer final: public TwoSidesContainer_rxh_A & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac){ compute_results_tsc_rxha(Va, Vm, V, id_grid_to_solver, bus_vn_kv, sn_mva, ac); diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 7b349c07..1afbd371 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -87,7 +87,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool ac) override { diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 97637a94..a86373f4 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -245,7 +245,7 @@ class OneSideContainer : public GenericContainer const Eigen::Ref & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac) final { @@ -440,7 +440,7 @@ class OneSideContainer : public GenericContainer const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool /*ac*/) { // nothing to do by default diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index be966155..ed21c555 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -204,7 +204,7 @@ class OneSideContainer_PQ : public OneSideContainer const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool /*ac*/) override { // nothing to do by default, as this class should be used as template for "one side" (eg loads or generators) diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index d84f894c..e055f0d4 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -116,7 +116,7 @@ class OneSideContainer_ForBranch : public OneSideContainer const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool /*ac*/) override { diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index af15e01f..2231ac3c 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -94,7 +94,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool ac) override { diff --git a/src/core/element_container/ShuntContainer.cpp b/src/core/element_container/ShuntContainer.cpp index b0bf6445..48c38320 100644 --- a/src/core/element_container/ShuntContainer.cpp +++ b/src/core/element_container/ShuntContainer.cpp @@ -134,7 +134,7 @@ void ShuntContainer::_compute_results(const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type sn_mva, bool ac) { diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index 6b6fd1e0..3eea2e74 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -131,7 +131,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator const Eigen::Ref & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac) override; diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index 6197a6f4..a97a1d80 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -89,7 +89,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool ac) override { diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index b74ee468..b85083ff 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -155,7 +155,7 @@ void SvcContainer::_compute_results( const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, - const RealVect & /*bus_vn_kv*/, + const Eigen::Ref & /*bus_vn_kv*/, real_type /*sn_mva*/, bool ac) { diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 30be9933..47926cc9 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -146,7 +146,7 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac) override; bool _deactivate(int svc_id, DualAlgoControl & solver_control) final; diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index dfcb5a74..b1611846 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -144,7 +144,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac) { diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 855a1315..18dabf0a 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -238,7 +238,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer const Eigen::Ref & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac ) @@ -396,7 +396,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer const Eigen::Ref & Vm, const Eigen::Ref & V, const SolverBusIdVect & id_grid_to_solver, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, real_type sn_mva, bool ac ) diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 4222b32e..4ac753a2 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -412,7 +412,7 @@ class LS2G_API BaseAlgo : public BaseConstants } // terribly inefficient way to know if an element is in a vector - bool isin(int k, const Eigen::VectorXi vect) const{ + bool isin(int k, Eigen::Ref vect) const{ for(auto el : vect){ if(el == k) return true; } diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp index 6f7d7408..10ffd9f8 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp @@ -113,14 +113,13 @@ bool BaseFDPFAlgo::compute_pf(const Eigen::SparseMatrix() + my_i * Va_.array().sin().template cast() ); // reused for Q iteration if(has_converged(tmp_va, Ybus, Sbus, slack_bus_id, slack_absorbed, slack_weights, pvpq, pq, tol)){ converged = true; diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp index e3266223..555e00a5 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp @@ -82,8 +82,8 @@ bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, void GaussSeidelAlgo::one_iter(CplxVect & tmp_Sbus, const Eigen::SparseMatrix & Ybus, - const Eigen::VectorXi & pv, - const Eigen::VectorXi & pq) + Eigen::Ref pv, + Eigen::Ref pq) { // do an update with the standard GS algorithm cplx_type tmp; diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp index b6856482..0d154fd8 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp @@ -43,8 +43,8 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo virtual void one_iter(CplxVect & tmp_Sbus, const Eigen::SparseMatrix & Ybus, - const Eigen::VectorXi & pv, - const Eigen::VectorXi & pq + Eigen::Ref pv, + Eigen::Ref pq ); private: diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp index f6c83646..11d71009 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp @@ -12,8 +12,8 @@ namespace ls2g { void GaussSeidelSynchAlgo::one_iter(CplxVect & tmp_Sbus, const Eigen::SparseMatrix & Ybus, - const Eigen::VectorXi & pv, - const Eigen::VectorXi & pq) + Eigen::Ref pv, + Eigen::Ref pq) { // do an update with all nodes being updated at the same time (different than the original GaussSeidel) cplx_type tmp; diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp index 4ed83dfa..5e7a2a9a 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp @@ -27,8 +27,8 @@ class LS2G_API GaussSeidelSynchAlgo final: public GaussSeidelAlgo protected: void one_iter(CplxVect & tmp_Sbus, const Eigen::SparseMatrix & Ybus, - const Eigen::VectorXi & pv, - const Eigen::VectorXi & pq + Eigen::Ref pv, + Eigen::Ref pq ); private: diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 8143e734..88f76d63 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -990,6 +990,11 @@ class NRSystem // ---- Shared data (one copy, shared by all components) ----------------------- RealVect Va_, Vm_; CplxVect V_; + // cache for mismatch(): a persistent all-zero dx, resized (and re-zeroed) only + // when total_state_variables() changes; never written to otherwise, so it is + // safe to reuse across calls instead of allocating a fresh RealVect::Zero(n) + // every time (mismatch() runs at least twice per NR iteration). + mutable RealVect dx_zero_cache_; Eigen::SparseMatrix J_; double timer_dSbus_, timer_fillJ_; Eigen::SparseMatrix dS_dVm_, dS_dVa_; diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index 5a81876c..1be238bd 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -218,7 +218,9 @@ template inline RealVect NRSystem::mismatch() const { // current state: no step (dx == 0), residual evaluated at V_ - return _residual(V_, RealVect::Zero(static_cast(total_state_variables()))); + const auto n = static_cast(total_state_variables()); + if(dx_zero_cache_.size() != n) dx_zero_cache_ = RealVect::Zero(n); + return _residual(V_, dx_zero_cache_); } template From b36547bae34239e4293000b7228322e3338b8469 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:45:53 +0000 Subject: [PATCH 097/166] Eliminate unnecessary Eigen copies in the batch/time-series layer - TimeSeries::fill_SBus_real/imag: take temporal_data as Eigen::Ref instead of a concrete RealMat, avoiding a full matrix copy per call in compute_Vs() (gen_p/sgen_p/load_p/load_q are already Eigen::Ref at the call site). - BaseBatchSolverSynch::compute_one_powerflow (DC path): replace the Pbus ternary (which always copy-constructed a common RealVect, even in the common Sbus-empty case) with an if/else so the common branch binds Pbus_ directly. - ContingencyAnalysis: same fix for the per-contingency slack_weights ternary -- the common (non-masked) branch now passes slack_weights_ directly instead of copying it into a unified local every contingency. All changes verified against the Catch2 C++ test suite (43/43 passing). Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .../batch_algorithm/BaseBatchSolverSynch.cpp | 17 ++++++------ .../batch_algorithm/ContingencyAnalysis.cpp | 26 ++++++++++++++----- src/core/batch_algorithm/TimeSeries.hpp | 4 +-- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index 6fdbb618..ec91e405 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -70,15 +70,14 @@ bool BaseBatchSolverSynch::compute_one_powerflow( // prepare_solver_input_base; Pbus is the real part of the (possibly per-step) // injection. ContingencyAnalysis leaves Sbus_ empty in DC (it relies on the // member Pbus_ built once), so fall back to Pbus_ when no complex Sbus is given. - const RealVect Pbus = (Sbus.size() == 0) ? Pbus_ : RealVect(Sbus.real()); - conv = algo.compute_pf_dc( - Bbus_, - V, - Pbus, - slack_ids, - slack_weights, - bus_pv, - bus_pq); + // Split into two branches (rather than a ternary) so the common/no-Sbus case + // binds Pbus_ directly instead of unifying both branches into a fresh copy. + if(Sbus.size() == 0){ + conv = algo.compute_pf_dc(Bbus_, V, Pbus_, slack_ids, slack_weights, bus_pv, bus_pq); + } else { + const RealVect Pbus = Sbus.real(); + conv = algo.compute_pf_dc(Bbus_, V, Pbus, slack_ids, slack_weights, bus_pv, bus_pq); + } } if(conv){ V = algo.get_V().array(); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index c6472e79..47c31bbc 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -801,14 +801,26 @@ void ContingencyAnalysis::run_contingency_range( timer_modif_Ybus_acc += timer_modif_Ybus.duration(); algo.set_masked_buses(masked); - const RealVect sw = masked.empty() ? slack_weights_ : masked_slack_weights(masked); V = Vinit_solver; // Vinit is reused for each contingencies - conv = compute_one_powerflow( - algo, control, nb_solved, timer_solver, - Ybus, V, Sbus_, - slack_ids_solver_.as_eigen(), sw, - bus_pv_.as_eigen(), bus_pq_.as_eigen(), - max_iter, tol / sn_mva); + // avoid a ternary here: masked.empty() is the common case, and + // unifying both branches into one RealVect would copy + // slack_weights_ even then; call with it directly instead. + if(masked.empty()){ + conv = compute_one_powerflow( + algo, control, nb_solved, timer_solver, + Ybus, V, Sbus_, + slack_ids_solver_.as_eigen(), slack_weights_, + bus_pv_.as_eigen(), bus_pq_.as_eigen(), + max_iter, tol / sn_mva); + } else { + const RealVect sw = masked_slack_weights(masked); + conv = compute_one_powerflow( + algo, control, nb_solved, timer_solver, + Ybus, V, Sbus_, + slack_ids_solver_.as_eigen(), sw, + bus_pv_.as_eigen(), bus_pq_.as_eigen(), + max_iter, tol / sn_mva); + } if(needs_solver_init){ control.tell_none_changed(); needs_solver_init = false; diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index ccbd0b50..c199a906 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -81,7 +81,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch template void fill_SBus_real(CplxMat & Sbuses, const T & structure_data, - const RealMat & temporal_data, + const Eigen::Ref & temporal_data, const SolverBusIdVect & id_me_to_ac_solver, bool add // if true call += else calls -= ) const @@ -118,7 +118,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch template void fill_SBus_imag(CplxMat & Sbuses, const T & structure_data, - const RealMat & temporal_data, + const Eigen::Ref & temporal_data, const SolverBusIdVect & id_me_to_ac_solver, bool add // if true call += else calls -= ) const From f34e8838ec89cb20f4e3ff52ccc00f82a9969971 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:50:51 +0000 Subject: [PATCH 098/166] Fix dangling Eigen::Ref and NR extension slack_weights copies - LineContainer::dc_x_tau_shift(): was returning an Eigen::Ref bound to a temporary RealVect() (dangling once the call returns); bind it to a function-local static instead, matching TrafoContainer's pattern of referencing something with a real lifetime. - NRSystem extensions (SingleSlack/Base, MultiSlack, Hvdc, VoltageControl)::update_state: take slack_weights as Eigen::Ref instead of const RealVect&, matching what the caller (NRSystem::update_state) already holds. Three of the four extensions don't even use the parameter, so this removes a wasted copy-conversion on every solve for no behavioral change; MultiSlack (which does store it) now copies once instead of twice. All changes verified against the Catch2 C++ test suite (43/43 passing). Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/element_container/LineContainer.hpp | 8 +++++++- src/core/powerflow_algorithm/NRSystem.hpp | 8 ++++---- src/core/powerflow_algorithm/NRSystemBase.cpp | 2 +- src/core/powerflow_algorithm/NRSystemHvdc.cpp | 2 +- src/core/powerflow_algorithm/NRSystemMultiSlack.cpp | 2 +- src/core/powerflow_algorithm/NRSystemVoltageControl.cpp | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/core/element_container/LineContainer.hpp b/src/core/element_container/LineContainer.hpp index 794ac7f4..82ab37fe 100644 --- a/src/core/element_container/LineContainer.hpp +++ b/src/core/element_container/LineContainer.hpp @@ -95,7 +95,13 @@ class LS2G_API LineContainer final: public TwoSidesContainer_rxh_A dc_x_tau_shift() const {return RealVect();} + // lines never have a phase shift: the Ref must point at something with a + // lifetime that outlives the call, not a temporary (a plain `return RealVect();` + // would bind the Ref to a temporary destroyed before the caller sees it). + Eigen::Ref dc_x_tau_shift() const { + static const RealVect empty_dc_x_tau_shift{}; + return empty_dc_x_tau_shift; + } protected: // physical properties diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 88f76d63..13f9075c 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -177,7 +177,7 @@ class LS2G_API Base const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& Sbus, - const RealVect& slack_weights + Eigen::Ref slack_weights ); // call after update_state @@ -303,7 +303,7 @@ class LS2G_API MultiSlack // distributed-slack extension const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& Sbus, - const RealVect& slack_weights + Eigen::Ref slack_weights ); // call after update_state @@ -448,7 +448,7 @@ class LS2G_API Hvdc const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& Sbus, - const RealVect& slack_weights + Eigen::Ref slack_weights ); void init_topology( @@ -616,7 +616,7 @@ class LS2G_API VoltageControl const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& Sbus, - const RealVect& slack_weights + Eigen::Ref slack_weights ); void init_topology( diff --git a/src/core/powerflow_algorithm/NRSystemBase.cpp b/src/core/powerflow_algorithm/NRSystemBase.cpp index 4f9355fb..b6deefe4 100644 --- a/src/core/powerflow_algorithm/NRSystemBase.cpp +++ b/src/core/powerflow_algorithm/NRSystemBase.cpp @@ -24,7 +24,7 @@ void Base::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, const CplxVect& /*Sbus*/, - const RealVect& /*slack_weights*/ + Eigen::Ref /*slack_weights*/ ) { // Slack buses not pinned by a LOCAL voltage-regulating generator need a diff --git a/src/core/powerflow_algorithm/NRSystemHvdc.cpp b/src/core/powerflow_algorithm/NRSystemHvdc.cpp index 0b22bf2e..b42f8ba8 100644 --- a/src/core/powerflow_algorithm/NRSystemHvdc.cpp +++ b/src/core/powerflow_algorithm/NRSystemHvdc.cpp @@ -19,7 +19,7 @@ void Hvdc::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, const CplxVect& /*Sbus*/, - const RealVect& /*slack_weights*/ + Eigen::Ref /*slack_weights*/ ) { data_.clear(); diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp index e5c90707..1f35cdbd 100644 --- a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -15,7 +15,7 @@ void MultiSlack::update_state( const LSGrid * /*lsgrid_ptr*/, const Eigen::SparseMatrix& /*Ybus*/, const CplxVect& Sbus, - const RealVect& slack_weights + Eigen::Ref slack_weights ) { slack_weights_ = slack_weights; diff --git a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp index 0080ca8e..7e80ac6a 100644 --- a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp +++ b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp @@ -19,7 +19,7 @@ void VoltageControl::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, const CplxVect& /*Sbus*/, - const RealVect& /*slack_weights*/ + Eigen::Ref /*slack_weights*/ ) { data_.clear(); From 81cc362f14e8bbfd73b1c93ada336955deafa3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:54:14 +0000 Subject: [PATCH 099/166] Fix dangling numpy arrays from get_ptdf/get_lodf/get_Bf* Python bindings These 5 LSGrid methods all return their matrix BY VALUE (freshly computed on each call, not a reference to persistent grid state), but were bound with py::return_value_policy::reference. That policy wraps the returned numpy array around the C++ return value's memory without copying -- for a by-value return, that memory is a temporary freed as soon as the call returns, so the numpy array on the Python side was left dangling. Use the default (copy/move into a Python-owned array), the only safe policy for a by-value return. Also drop the redundant const-ref-then-return in LSGrid::get_ptdf_solver(), which defeated RVO and forced an extra full-matrix copy on top of the dangling-reference issue. Verified: binding_lsgrid.cpp syntax-checked clean with pybind11 (full python extension build not attempted in this sandbox); C++ core/tests verified via the Catch2 suite (43/43 passing). Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/bindings/python/binding_lsgrid.cpp | 17 ++++++++++++----- src/core/LSGrid.cpp | 6 ++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 2049a0e8..a6a4f15e 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -446,11 +446,18 @@ Empty for a grid not built that way, or a default-constructed one. .def("get_dc_algo_controler", &LSGrid::get_dc_algo_controler, "TODO", py::return_value_policy::reference) // .def("get_solver_control", &LSGrid::get_algo_controler, "DEPRECATED use 'get_algo_controler'", py::return_value_policy::reference) .def("compute_newton", &LSGrid::ac_pf, DocLSGrid::ac_pf.c_str()) - .def("get_ptdf", &LSGrid::get_ptdf, DocLSGrid::get_ptdf.c_str(), py::return_value_policy::reference) - .def("get_ptdf_solver", &LSGrid::get_ptdf_solver, DocLSGrid::get_ptdf_solver.c_str(), py::return_value_policy::reference) - .def("get_lodf", &LSGrid::get_lodf, DocLSGrid::get_lodf.c_str(), py::return_value_policy::reference) - .def("get_Bf", &LSGrid::get_Bf, DocLSGrid::get_Bf.c_str(), py::return_value_policy::reference) - .def("get_Bf_solver", &LSGrid::get_Bf_solver, DocLSGrid::get_Bf_solver.c_str(), py::return_value_policy::reference) + // get_ptdf/get_ptdf_solver/get_lodf/get_Bf/get_Bf_solver all return their + // matrix BY VALUE (freshly computed, not a reference to persistent state): + // no return_value_policy::reference here, since that would wrap the numpy + // array around the returned temporary's memory, which is freed as soon as + // this call returns (dangling on the Python side). The default policy + // (copy/move into a Python-owned array) is the only safe choice for a + // by-value return. + .def("get_ptdf", &LSGrid::get_ptdf, DocLSGrid::get_ptdf.c_str()) + .def("get_ptdf_solver", &LSGrid::get_ptdf_solver, DocLSGrid::get_ptdf_solver.c_str()) + .def("get_lodf", &LSGrid::get_lodf, DocLSGrid::get_lodf.c_str()) + .def("get_Bf", &LSGrid::get_Bf, DocLSGrid::get_Bf.c_str()) + .def("get_Bf_solver", &LSGrid::get_Bf_solver, DocLSGrid::get_Bf_solver.c_str()) // apply action faster (optimized for grid2op representation) .def("update_gens_p", &LSGrid::update_gens_p, DocLSGrid::_internal_do_not_use.c_str()) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 601f62b5..0b36a29f 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -1462,8 +1462,10 @@ RealMat LSGrid::get_ptdf_solver(){ if(Bbus_dc_.size() == 0){ throw std::runtime_error("LSGrid::get_ptdf: Cannot get the ptdf without having first computed a DC powerflow."); } - const RealMat & PTDF_solver = _dc_algo.get_ptdf(); - return PTDF_solver; + // return the freshly-computed matrix directly (RVO/move) instead of binding it + // to a local const-ref first: `const RealMat& x = ...; return x;` defeats RVO + // and forces an extra full-matrix copy, since a reference can't be moved from. + return _dc_algo.get_ptdf(); } From e86a5c77367651f3019dbd770e34dce870821e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:56:17 +0000 Subject: [PATCH 100/166] DataConverter: take pandapower import vectors as Eigen::Ref PandaPowerConverter's get_trafo_param_pp2/pp3 and get_line_param(_legacy) only ever read their RealVect parameters. Switching them to Eigen::Ref lets pybind11 bind numpy arrays at this boundary without copying, instead of always materializing a fresh RealVect from each argument. Verified: C++ core/tests via the Catch2 suite (43/43 passing); the consuming binding (binding_misc.cpp) syntax-checked clean with pybind11. Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/DataConverter.cpp | 64 +++++++++++++++++++------------------- src/core/DataConverter.hpp | 64 +++++++++++++++++++------------------- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/src/core/DataConverter.cpp b/src/core/DataConverter.cpp index 8c6d6358..df55f230 100644 --- a/src/core/DataConverter.cpp +++ b/src/core/DataConverter.cpp @@ -25,17 +25,17 @@ void PandaPowerConverter::_check_init(){ std::tuple - PandaPowerConverter::get_trafo_param_pp2(const RealVect & tap_step_pct, - const RealVect & tap_pos, - const RealVect & tap_angles, + PandaPowerConverter::get_trafo_param_pp2(const Eigen::Ref & tap_step_pct, + const Eigen::Ref & tap_pos, + const Eigen::Ref & tap_angles, const std::vector & is_tap_hv_side, - const RealVect & vn_hv, // nominal voltage of hv bus - const RealVect & vn_lv, // nominal voltage of lv bus - const RealVect & trafo_vk_percent, - const RealVect & trafo_vkr_percent, - const RealVect & trafo_sn_trafo_mva, - const RealVect & trafo_pfe_kw, - const RealVect & trafo_i0_pct) + const Eigen::Ref & vn_hv, // nominal voltage of hv bus + const Eigen::Ref & vn_lv, // nominal voltage of lv bus + const Eigen::Ref & trafo_vk_percent, + const Eigen::Ref & trafo_vkr_percent, + const Eigen::Ref & trafo_sn_trafo_mva, + const Eigen::Ref & trafo_pfe_kw, + const Eigen::Ref & trafo_i0_pct) { //TODO consistency: move this class outside of here _check_init(); @@ -129,12 +129,12 @@ std::tuple - PandaPowerConverter::get_line_param_legacy(const RealVect & branch_r, - const RealVect & branch_x, - const RealVect & branch_g, - const RealVect & branch_c, - const RealVect & branch_from_kv, - const RealVect & /*branch_to_kv*/) + PandaPowerConverter::get_line_param_legacy(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_g, + const Eigen::Ref & branch_c, + const Eigen::Ref & branch_from_kv, + const Eigen::Ref & /*branch_to_kv*/) { //TODO does not use c at the moment! _check_init(); @@ -161,12 +161,12 @@ std::tuple - PandaPowerConverter::get_line_param(const RealVect & branch_r, - const RealVect & branch_x, - const RealVect & branch_g, - const RealVect & branch_c, - const RealVect & branch_from_kv, - const RealVect & /*branch_to_kv*/) + PandaPowerConverter::get_line_param(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_g, + const Eigen::Ref & branch_c, + const Eigen::Ref & branch_from_kv, + const Eigen::Ref & /*branch_to_kv*/) { _check_init(); const int nb_line = static_cast(branch_r.size()); @@ -194,17 +194,17 @@ std::tuple - PandaPowerConverter::get_trafo_param_pp3(const RealVect & tap_step_pct, - const RealVect & tap_pos, - const RealVect & tap_angles, + PandaPowerConverter::get_trafo_param_pp3(const Eigen::Ref & tap_step_pct, + const Eigen::Ref & tap_pos, + const Eigen::Ref & tap_angles, const std::vector & is_tap_hv_side, - const RealVect & vn_hv, // nominal voltage of hv bus - const RealVect & vn_lv, // nominal voltage of lv bus - const RealVect & trafo_vk_percent, - const RealVect & trafo_vkr_percent, - const RealVect & trafo_sn_mva, - const RealVect & trafo_pfe_kw, - const RealVect & trafo_i0_pct, + const Eigen::Ref & vn_hv, // nominal voltage of hv bus + const Eigen::Ref & vn_lv, // nominal voltage of lv bus + const Eigen::Ref & trafo_vk_percent, + const Eigen::Ref & trafo_vkr_percent, + const Eigen::Ref & trafo_sn_mva, + const Eigen::Ref & trafo_pfe_kw, + const Eigen::Ref & trafo_i0_pct, bool trafo_model_is_t) { //TODO consistency: move this class outside of here diff --git a/src/core/DataConverter.hpp b/src/core/DataConverter.hpp index 50b1f847..0765ce0f 100644 --- a/src/core/DataConverter.hpp +++ b/src/core/DataConverter.hpp @@ -41,17 +41,17 @@ class LS2G_API PandaPowerConverter final : public BaseConstants std::tuple - get_trafo_param_pp3(const RealVect & tap_step_pct, - const RealVect & tap_pos, - const RealVect & tap_angles, + get_trafo_param_pp3(const Eigen::Ref & tap_step_pct, + const Eigen::Ref & tap_pos, + const Eigen::Ref & tap_angles, const std::vector & is_tap_hv_side, - const RealVect & vn_hv, // nominal voltage of hv bus - const RealVect & vn_lv, // nominal voltage of lv bus - const RealVect & trafo_vk_percent, - const RealVect & trafo_vkr_percent, - const RealVect & trafo_sn_trafo_mva, - const RealVect & trafo_pfe_kw, - const RealVect & trafo_i0_pct, + const Eigen::Ref & vn_hv, // nominal voltage of hv bus + const Eigen::Ref & vn_lv, // nominal voltage of lv bus + const Eigen::Ref & trafo_vk_percent, + const Eigen::Ref & trafo_vkr_percent, + const Eigen::Ref & trafo_sn_trafo_mva, + const Eigen::Ref & trafo_pfe_kw, + const Eigen::Ref & trafo_i0_pct, bool trafo_model_is_t); /** @@ -60,17 +60,17 @@ class LS2G_API PandaPowerConverter final : public BaseConstants std::tuple - get_trafo_param_pp2(const RealVect & tap_step_pct, - const RealVect & tap_pos, - const RealVect & tap_angles, + get_trafo_param_pp2(const Eigen::Ref & tap_step_pct, + const Eigen::Ref & tap_pos, + const Eigen::Ref & tap_angles, const std::vector & is_tap_hv_side, - const RealVect & vn_hv, // nominal voltage of hv bus - const RealVect & vn_lv, // nominal voltage of lv bus - const RealVect & trafo_vk_percent, - const RealVect & trafo_vkr_percent, - const RealVect & trafo_sn_trafo_mva, - const RealVect & trafo_pfe_kw, - const RealVect & trafo_i0_pct); + const Eigen::Ref & vn_hv, // nominal voltage of hv bus + const Eigen::Ref & vn_lv, // nominal voltage of lv bus + const Eigen::Ref & trafo_vk_percent, + const Eigen::Ref & trafo_vkr_percent, + const Eigen::Ref & trafo_sn_trafo_mva, + const Eigen::Ref & trafo_pfe_kw, + const Eigen::Ref & trafo_i0_pct); /** @@ -79,12 +79,12 @@ class LS2G_API PandaPowerConverter final : public BaseConstants std::tuple - get_line_param_legacy(const RealVect & branch_r, - const RealVect & branch_x, - const RealVect & branch_g, - const RealVect & branch_c, - const RealVect & branch_from_kv, - const RealVect & branch_to_kv); + get_line_param_legacy(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_g, + const Eigen::Ref & branch_c, + const Eigen::Ref & branch_from_kv, + const Eigen::Ref & branch_to_kv); /** pair unit properly the powerlines (for most recent pandapower) **/ @@ -92,12 +92,12 @@ class LS2G_API PandaPowerConverter final : public BaseConstants RealVect, CplxVect, CplxVect> - get_line_param(const RealVect & branch_r, - const RealVect & branch_x, - const RealVect & branch_g, - const RealVect & branch_c, - const RealVect & branch_from_kv, - const RealVect & branch_to_kv); + get_line_param(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_g, + const Eigen::Ref & branch_c, + const Eigen::Ref & branch_from_kv, + const Eigen::Ref & branch_to_kv); private: real_type sn_mva_; From 0ba9e92589788dcabb21ed3dfaad1438b76ebeb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:30:53 +0000 Subject: [PATCH 101/166] [BREAKING] BaseAlgo::compute_pf/compute_pf_dc: Sbus/Pbus/slack_weights as Eigen::Ref Propagates Eigen::Ref/Eigen::Ref for Sbus/Pbus/slack_weights through the whole solver-plugin interface (BaseAlgo::compute_pf, compute_pf_dc, their *_with_input_validation wrappers, check_pf_inputs, _evaluate_Fx) and every concrete implementation (NRAlgo, BaseDCAlgo, BaseFDPFAlgo, GaussSeidelAlgo), AlgorithmSelector's dispatch, and the example external-algorithm plugin. Was concrete RealVect/CplxVect before, forcing a copy at every solve when the caller already held a Ref (or a Block, e.g. a batch solver's per-timestep Sbus row). This is a breaking change for third-party solver plugins (see examples/external_algorithm/) -- landing now, before this interface has shipped in a release, to avoid landing it as a second breaking change later. Fixes a latent correctness hazard along the way: NRSystem::update_state used to cache Sbus as `Sbus_ptr_ = &Sbus`, a raw pointer to the (reference) parameter. With Sbus now passed as an Eigen::Ref -- itself a function-local wrapper object -- taking its address would dangle the instant update_state() returns, silently corrupting every NR iteration afterwards. Replaced with Sbus_data_ptr_/Sbus_size_ (an Eigen::Map reconstructed on demand), which instead points at the real, caller-owned buffer the Ref views -- the same contract Ybus_ptr_ already relies on. Verified with a full AddressSanitizer + UBSan test run (in addition to the normal Catch2 suite, 43/43 both ways) to confirm no use-after-free was introduced. Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .../external_algorithm/DummyExternalAlgo.cpp | 4 +- src/core/AlgorithmSelector.hpp | 8 ++-- src/core/powerflow_algorithm/BaseAlgo.cpp | 16 ++++---- src/core/powerflow_algorithm/BaseAlgo.hpp | 24 +++++------ src/core/powerflow_algorithm/BaseDCAlgo.hpp | 4 +- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 4 +- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 12 +++--- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 4 +- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 4 +- .../powerflow_algorithm/GaussSeidelAlgo.hpp | 4 +- src/core/powerflow_algorithm/NRAlgo.hpp | 4 +- src/core/powerflow_algorithm/NRAlgo.tpp | 4 +- src/core/powerflow_algorithm/NRSystem.hpp | 41 ++++++++++++------- src/core/powerflow_algorithm/NRSystem.tpp | 9 ++-- src/core/powerflow_algorithm/NRSystemBase.cpp | 2 +- src/core/powerflow_algorithm/NRSystemHvdc.cpp | 2 +- .../NRSystemMultiSlack.cpp | 2 +- .../NRSystemVoltageControl.cpp | 2 +- 18 files changed, 81 insertions(+), 69 deletions(-) diff --git a/examples/external_algorithm/DummyExternalAlgo.cpp b/examples/external_algorithm/DummyExternalAlgo.cpp index d33b99e6..0f6d8b49 100644 --- a/examples/external_algorithm/DummyExternalAlgo.cpp +++ b/examples/external_algorithm/DummyExternalAlgo.cpp @@ -30,9 +30,9 @@ class DummyExternalAlgo : public ls2g::BaseAlgo { bool compute_pf( const Eigen::SparseMatrix& /*Ybus*/, ls2g::CplxVect& V, - const ls2g::CplxVect& /*Sbus*/, + Eigen::Ref /*Sbus*/, Eigen::Ref /*slack_ids*/, - const ls2g::RealVect& /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref /*pv*/, Eigen::Ref /*pq*/, int /*max_iter*/, diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 6976dd02..365744e7 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -153,9 +153,9 @@ class LS2G_API AlgorithmSelector final bool compute_pf(const Eigen::SparseMatrix& Ybus, CplxVect& V, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, @@ -169,9 +169,9 @@ class LS2G_API AlgorithmSelector final // Native real-valued DC entry point (only valid for DC solvers). bool compute_pf_dc(const Eigen::SparseMatrix& Bbus, CplxVect& V, - const RealVect& Pbus, + Eigen::Ref Pbus, Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq) { diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 9485821c..28b2639b 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -54,7 +54,7 @@ void BaseAlgo::check_pf_inputs(const std::string & caller, Eigen::Index ybus_rows, Eigen::Index ybus_cols, Eigen::Index v_size, Eigen::Index sbus_size, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq) { @@ -113,9 +113,9 @@ void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_typ bool BaseAlgo::compute_pf_with_input_validation( const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, @@ -130,9 +130,9 @@ bool BaseAlgo::compute_pf_with_input_validation( bool BaseAlgo::compute_pf_dc_with_input_validation( const Eigen::SparseMatrix & Bbus, CplxVect & V, - const RealVect & Pbus, + Eigen::Ref Pbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq) { @@ -160,7 +160,7 @@ void BaseAlgo::reset(){ RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, const CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref pv, Eigen::Ref pq) { @@ -186,10 +186,10 @@ RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, const CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, size_t slack_id, // id of the ref slack bus real_type slack_absorbed, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq) { diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 4ac753a2..757a9586 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -111,7 +111,7 @@ class LS2G_API BaseAlgo : public BaseConstants Eigen::Index ybus_rows, Eigen::Index ybus_cols, Eigen::Index v_size, Eigen::Index sbus_size, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq); @@ -136,9 +136,9 @@ class LS2G_API BaseAlgo : public BaseConstants bool compute_pf_with_input_validation( const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, @@ -151,9 +151,9 @@ class LS2G_API BaseAlgo : public BaseConstants bool compute_pf_dc_with_input_validation( const Eigen::SparseMatrix & Bbus, CplxVect & V, - const RealVect & Pbus, + Eigen::Ref Pbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq); @@ -284,9 +284,9 @@ class LS2G_API BaseAlgo : public BaseConstants virtual bool compute_pf(const Eigen::SparseMatrix & /*Ybus*/, CplxVect & /*V*/, // store the results of the powerflow and the Vinit ! - const CplxVect & /*Sbus*/, + Eigen::Ref /*Sbus*/, Eigen::Ref /*slack_ids*/, - const RealVect & /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref /*pv*/, Eigen::Ref /*pq*/, int /*max_iter*/, @@ -302,9 +302,9 @@ class LS2G_API BaseAlgo : public BaseConstants virtual bool compute_pf_dc(const Eigen::SparseMatrix & /*Bbus*/, CplxVect & /*V*/, - const RealVect & /*Pbus*/, + Eigen::Ref /*Pbus*/, Eigen::Ref /*slack_ids*/, - const RealVect & /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref /*pv*/, Eigen::Ref /*pq*/){ throw std::runtime_error("compute_pf_dc is only available for DC solvers."); @@ -356,16 +356,16 @@ class LS2G_API BaseAlgo : public BaseConstants } RealVect _evaluate_Fx(const Eigen::SparseMatrix & Ybus, const CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, size_t slack_id, // id of the slack bus real_type slack_absorbed, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq); RealVect _evaluate_Fx(const Eigen::SparseMatrix & Ybus, const CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref pv, Eigen::Ref pq); diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index baa3fbe9..0909fac6 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -84,9 +84,9 @@ class BaseDCAlgo final: public BaseAlgo virtual bool compute_pf_dc(const Eigen::SparseMatrix & Bbus, CplxVect & V, - const RealVect & Pbus, + Eigen::Ref Pbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq ) final; diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 96973060..4ca341fb 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -13,9 +13,9 @@ template bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix & Bbus, CplxVect & V, - const RealVect & Pbus, + Eigen::Ref Pbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq ) diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index ddd50b5d..21bacaf5 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -26,9 +26,9 @@ class BaseFDPFAlgo: public BaseAlgo virtual bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, @@ -64,10 +64,10 @@ class BaseFDPFAlgo: public BaseAlgo CplxVect evaluate_mismatch(const Eigen::SparseMatrix & Ybus, const CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, size_t /*slack_id*/, // id of the ref slack bus real_type slack_absorbed, - const RealVect & slack_weights) + Eigen::Ref slack_weights) { // CplxVect tmp = Ybus * V; // this is a vector // tmp = tmp.array().conjugate(); // i take the conjugate @@ -125,10 +125,10 @@ class BaseFDPFAlgo: public BaseAlgo bool has_converged(const Eigen::Ref & tmp_va, const Eigen::SparseMatrix & Ybus, - const CplxVect & Sbus, + Eigen::Ref Sbus, size_t slack_bus_id, real_type & slack_absorbed, - const RealVect & slack_weights, + Eigen::Ref slack_weights, const Eigen::Ref & pvpq, const Eigen::Ref & pq, real_type tol) diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp index 10ffd9f8..c3201b47 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp @@ -11,9 +11,9 @@ template bool BaseFDPFAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp index 555e00a5..436aae64 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp @@ -12,9 +12,9 @@ namespace ls2g { bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & /*slack_weights*/, // currently unused + Eigen::Ref /*slack_weights*/, // currently unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp index 0d154fd8..018ee4d1 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp @@ -29,9 +29,9 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo virtual bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, // currently unused + Eigen::Ref slack_weights, // currently unused Eigen::Ref pv, Eigen::Ref pq, int max_iter, diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index f1495918..f9a84405 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -127,9 +127,9 @@ class NRAlgo : public BaseAlgo virtual bool compute_pf(const Eigen::SparseMatrix& Ybus, CplxVect& V, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index 94468d75..e1e547f7 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -10,9 +10,9 @@ template bool NRAlgo::compute_pf( const Eigen::SparseMatrix& Ybus, CplxVect& V, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, int max_iter, diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 13f9075c..3b4f5a70 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -176,7 +176,7 @@ class LS2G_API Base void update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights ); @@ -185,7 +185,7 @@ class LS2G_API Base // only if the topology has changed void init_topology( Eigen::Ref /*slack_ids*/, - const RealVect& /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref pv, Eigen::Ref pq ) { @@ -302,7 +302,7 @@ class LS2G_API MultiSlack // distributed-slack extension const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights ); @@ -311,7 +311,7 @@ class LS2G_API MultiSlack // distributed-slack extension // only if the topology has changed void init_topology( Eigen::Ref slack_ids, - const RealVect& /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref /*pv*/, Eigen::Ref /*pq*/ ) { @@ -447,13 +447,13 @@ class LS2G_API Hvdc const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights ); void init_topology( Eigen::Ref /*slack_ids*/, - const RealVect& /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref /*pv*/, Eigen::Ref /*pq*/ ) {} @@ -615,13 +615,13 @@ class LS2G_API VoltageControl const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights ); void init_topology( Eigen::Ref /*slack_ids*/, - const RealVect& /*slack_weights*/, + Eigen::Ref /*slack_weights*/, Eigen::Ref /*pv*/, Eigen::Ref /*pq*/ ) {} @@ -831,7 +831,8 @@ class NRSystem masked_dirty_(false), lsgrid_ptr_(nullptr), Ybus_ptr_(nullptr), - Sbus_ptr_(nullptr) {} + Sbus_data_ptr_(nullptr), + Sbus_size_(0) {} virtual ~NRSystem() = default; @@ -839,7 +840,7 @@ class NRSystem void init_topology( Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq); @@ -849,7 +850,7 @@ class NRSystem const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& V_init, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights); // ----- Phase 2: build J sparsity + value maps ------------------------------- @@ -1060,7 +1061,17 @@ class NRSystem // visible attribute for derived class (non owning ptr) const LSGrid * lsgrid_ptr_; const Eigen::SparseMatrix* Ybus_ptr_; - const CplxVect* Sbus_ptr_; + // Sbus is cached as a raw data pointer + size (reconstructed as an + // Eigen::Map on demand via _Sbus_view()) rather than a `const CplxVect*`: + // update_state() now receives Sbus as an Eigen::Ref, which is itself a + // function-local wrapper object -- taking its address (as was done + // previously for the concrete-reference version) would dangle the moment + // update_state() returns. `.data()` instead points at the real, + // caller-owned buffer the Ref views, which is what must outlive the + // whole solve (same contract as Ybus_ptr_ above). + const cplx_type* Sbus_data_ptr_; + Eigen::Index Sbus_size_; + Eigen::Map _Sbus_view() const { return Eigen::Map(Sbus_data_ptr_, Sbus_size_); } static CplxVect _reconstruct_V(const RealVect& Va, const RealVect& Vm); CplxVect _compute_trial_V(const RealVect& dx) const; @@ -1073,7 +1084,7 @@ class NRSystem template void _init_topology_extensions( Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq, std::index_sequence) { @@ -1090,8 +1101,8 @@ class NRSystem void _update_state_extensions( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - const CplxVect& Sbus, - const RealVect& slack_weights, + Eigen::Ref Sbus, + Eigen::Ref slack_weights, std::index_sequence){ int dummy[] = { 0, (std::get(extensions_).update_state( &base_, diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index 1be238bd..1f11e656 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -12,7 +12,7 @@ namespace ls2g { template inline void NRSystem::init_topology( Eigen::Ref slack_ids, - const RealVect& slack_weights, + Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq) { @@ -42,12 +42,13 @@ inline void NRSystem::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& V_init, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights) { lsgrid_ptr_ = lsgrid_ptr; Ybus_ptr_ = &Ybus; - Sbus_ptr_ = &Sbus; + Sbus_data_ptr_ = Sbus.data(); + Sbus_size_ = Sbus.size(); Va_ = V_init.array().arg(); Vm_ = V_init.array().abs(); @@ -280,7 +281,7 @@ inline RealVect NRSystem::_residual(const CplxVect& V_t, const Re { // per-bus complex power mismatch: V .* conj(Ybus V) - Sbus CplxVect mis = V_t.array() * (*Ybus_ptr_ * V_t).array().conjugate() - - Sbus_ptr_->array(); + - _Sbus_view().array(); // components adjust the complex injection (e.g. + slack_absorbed * slack_weights, // + the theta-dependent hvdc droop flows) base_.adjust_mismatch(V_t, dx, mis); diff --git a/src/core/powerflow_algorithm/NRSystemBase.cpp b/src/core/powerflow_algorithm/NRSystemBase.cpp index b6deefe4..b332ef42 100644 --- a/src/core/powerflow_algorithm/NRSystemBase.cpp +++ b/src/core/powerflow_algorithm/NRSystemBase.cpp @@ -23,7 +23,7 @@ namespace ls2g { void Base::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, - const CplxVect& /*Sbus*/, + Eigen::Ref /*Sbus*/, Eigen::Ref /*slack_weights*/ ) { diff --git a/src/core/powerflow_algorithm/NRSystemHvdc.cpp b/src/core/powerflow_algorithm/NRSystemHvdc.cpp index b42f8ba8..0512611a 100644 --- a/src/core/powerflow_algorithm/NRSystemHvdc.cpp +++ b/src/core/powerflow_algorithm/NRSystemHvdc.cpp @@ -18,7 +18,7 @@ void Hvdc::update_state( const Base * /*nr_system_base_ptr*/, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, - const CplxVect& /*Sbus*/, + Eigen::Ref /*Sbus*/, Eigen::Ref /*slack_weights*/ ) { diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp index 1f35cdbd..f9b103a7 100644 --- a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -14,7 +14,7 @@ void MultiSlack::update_state( const Base * /*nr_system_base_ptr*/, const LSGrid * /*lsgrid_ptr*/, const Eigen::SparseMatrix& /*Ybus*/, - const CplxVect& Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_weights ) { diff --git a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp index 7e80ac6a..73fb6314 100644 --- a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp +++ b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp @@ -18,7 +18,7 @@ void VoltageControl::update_state( const Base * /*nr_system_base_ptr*/, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, - const CplxVect& /*Sbus*/, + Eigen::Ref /*Sbus*/, Eigen::Ref /*slack_weights*/ ) { From c1918418e2f49847602a0a6d581b6ae4dcb85ddf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:32:01 +0000 Subject: [PATCH 102/166] compute_one_powerflow: Sbus as Eigen::Ref, closing the batch-layer copy Now that BaseAlgo::compute_pf/compute_pf_dc take Sbus/Pbus via Eigen::Ref, propagate the same change to compute_one_powerflow's Sbus parameter (both overloads). This was called out but deliberately left unfixed in the original audit because it forwards straight into compute_pf, which was itself still a concrete-type ABI boundary at the time. TimeSeries::compute_Vs's per-timestep call (compute_one_powerflow(..., _Sbuses.row(i), ...)) now binds that RowMajor row with zero copy instead of materializing a fresh CplxVect every simulated timestep. Verified against the Catch2 C++ test suite (43/43 passing). Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/batch_algorithm/BaseBatchSolverSynch.cpp | 8 ++++---- src/core/batch_algorithm/BaseBatchSolverSynch.hpp | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index ec91e405..46bb9a1f 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -17,9 +17,9 @@ namespace ls2g { bool BaseBatchSolverSynch::compute_one_powerflow( const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref bus_pv, Eigen::Ref bus_pq, int max_iter, @@ -43,9 +43,9 @@ bool BaseBatchSolverSynch::compute_one_powerflow( double & timer_solver, const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref bus_pv, Eigen::Ref bus_pq, int max_iter, diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index 478bf23f..e83d2e70 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -288,9 +288,9 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // member solver / control / accumulators (single-threaded path). bool compute_one_powerflow(const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref bus_pv, Eigen::Ref bus_pq, int max_iter, @@ -307,9 +307,9 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants double & timer_solver, const Eigen::SparseMatrix & Ybus, CplxVect & V, - const CplxVect & Sbus, + Eigen::Ref Sbus, Eigen::Ref slack_ids, - const RealVect & slack_weights, + Eigen::Ref slack_weights, Eigen::Ref bus_pv, Eigen::Ref bus_pq, int max_iter, From f541a4d11be8b44d3ce491f6bf7b59faa95a36a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:33:26 +0000 Subject: [PATCH 103/166] Document the compute_amps_flows/compute_active_power_flows copy as a TODO The per-line/trafo CplxVect(_voltages.col(...)) construction identified in the Eigen copy audit isn't a simple reference-type fix (_voltages is RowMajor, so a column isn't contiguous and can't bind to Eigen::Ref; the surrounding ternary also needs a common type with CplxVect::Zero(nb_steps)). Recorded in-code and in the CHANGELOG [TODO] section instead of left silently unaddressed. Also documents the compute_pf/compute_pf_dc Eigen::Ref signature change as [BREAKING] for plugin developers, alongside the existing similar entry for set_gridmodel -> set_lsgrid. Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- CHANGELOG.rst | 16 ++++++++++++++++ .../batch_algorithm/BaseBatchSolverSynch.hpp | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d961125c..b719649e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -43,6 +43,13 @@ TODO: in ContingencyAnalysisCpp: add back the `if(!ac_solver_used)` inside the TODO: in `main.cpp` check the returned policy of pybind11 and also the `py::call_guard()` stuff TODO: a cpp class that is able to compute (DC powerflow) ContingencyAnalysis and TimeSeries using PTDF and LODF TODO: integration test with pandapower (see `pandapower/contingency/contingency.py` and import `lightsim2grid_installed` and check it's True) +TODO: speed: `BaseBatchSolverSynch::compute_amps_flows` / `compute_active_power_flows` build + `Efrom`/`Eto` via `CplxVect(_voltages.col(...))` for every line/trafo element, once per + call -- a full nb_steps-sized copy each time. `_voltages` is RowMajor, so a column isn't + contiguous and can't bind to `Eigen::Ref`, and the surrounding ternary also needs a + common type with `CplxVect::Zero(nb_steps)` for the open-side case. Removing the copy + would need a control-flow restructure (separate open-side / closed-side code paths), + not just a reference-type change. [0.14.0] 2026-xx-yy --------------------- @@ -54,6 +61,15 @@ TODO: integration test with pandapower (see `pandapower/contingency/contingency. - [BREAKING] For plugin developers (C++ side): the virtual method ``set_gridmodel`` in ``BaseAlgo`` is renamed ``set_lsgrid``, and the protected member ``gridmodel_ptr_`` is renamed ``lsgrid_ptr_``. +- [BREAKING] For plugin developers (C++ side): ``BaseAlgo::compute_pf`` / + ``compute_pf_dc`` (and their ``*_with_input_validation`` wrappers) now take + ``Sbus`` / ``Pbus`` / ``slack_weights`` as ``Eigen::Ref`` / + ``Eigen::Ref`` instead of ``const CplxVect&`` / ``const RealVect&``, + matching the ``Eigen::Ref`` convention already used for ``slack_ids`` / ``pv`` / ``pq``. + This avoids a copy at every solve when the caller already holds a ``Eigen::Ref`` or a + matrix row/block (e.g. a batch solver's per-timestep ``Sbus``). Any external algorithm + plugin overriding ``compute_pf`` / ``compute_pf_dc`` (see ``examples/external_algorithm/``) + needs to update its signature to match. - [BREAKING] for Newton based solvers, the Jacobian row / columns does not have the same ordering as before, this is because some modularity is being implemented at this level to allow for other types of "extensions" (similar to distributed slack) diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index e83d2e70..efaa90fa 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -159,6 +159,12 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // DC has no such reduction (handled explicitly below). GlobalBusId bus_from_me = bus_from(el_id); GlobalBusId bus_to_me = bus_to(el_id); + // TODO speed: this copies a full nb_steps-sized column per line/trafo, + // every call. _voltages is RowMajor, so a column isn't contiguous and + // can't bind to Eigen::Ref; the ternary also needs a common type with + // CplxVect::Zero(nb_steps). Avoiding the copy would need a control-flow + // restructure (separate open-side / closed-side code paths) rather than + // a reference-type change -- see CHANGELOG [TODO]. const CplxVect Efrom = s1 ? CplxVect(_voltages.col(bus_from_me.cast_int())) : CplxVect::Zero(nb_steps); const CplxVect Eto = s2 ? CplxVect(_voltages.col(bus_to_me.cast_int())) : CplxVect::Zero(nb_steps); @@ -247,6 +253,9 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // DC has no such reduction (handled explicitly below). GlobalBusId bus_from_me = bus_from(el_id); GlobalBusId bus_to_me = bus_to(el_id); + // TODO speed: same structural copy as in compute_amps_flows above (see + // the TODO there for why this isn't a simple Eigen::Ref fix) -- see + // CHANGELOG [TODO]. const CplxVect Efrom = s1 ? CplxVect(_voltages.col(bus_from_me.cast_int())) : CplxVect::Zero(nb_steps); const CplxVect Eto = s2 ? CplxVect(_voltages.col(bus_to_me.cast_int())) : CplxVect::Zero(nb_steps); From b897a81f66e07a4b6a9711b41237401302c3cff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:46:56 +0000 Subject: [PATCH 104/166] init/init_full/init_osc_pq/init_bus: take vectors as Eigen::Ref Consistency pass over every element container's construction-time ingestion methods (GeneratorContainer, LoadContainer, StorageContainer, SGenContainer, ShuntContainer, LineContainer, TrafoContainer, HvdcLineContainer, ConverterStationContainer, SvcContainer, plus the shared OneSideContainer::init_osc / OneSideContainer_PQ::init_osc_pq / TwoSidesContainer::init_tsc helpers) and the LSGrid/SubstationContainer init_bus / init_* wrappers that sit directly on the pybind11 boundary. These aren't hot path (called once per grid build), so this is a readability/consistency change rather than a performance one: every Eigen vector parameter now matches the Eigen::Ref convention already used everywhere else in the codebase, instead of mixing concrete RealVect/CplxVect/VectorXi with Eigen::Ref depending on which function you're looking at. Also lets pybind11 bind numpy arrays through this path without an intermediate copy. Verified: Catch2 C++ test suite and a full AddressSanitizer + UBSan run (43/43 both ways), plus a syntax-check of every pybind11 binding file with pybind11 installed. Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/LSGrid.cpp | 2 +- src/core/LSGrid.hpp | 186 +++++++++--------- src/core/SubstationContainer.hpp | 2 +- .../ConverterStationContainer.cpp | 14 +- .../ConverterStationContainer.hpp | 14 +- .../element_container/GeneratorContainer.cpp | 22 +-- .../element_container/GeneratorContainer.hpp | 24 +-- .../element_container/HvdcLineContainer.cpp | 68 +++---- .../element_container/HvdcLineContainer.hpp | 68 +++---- src/core/element_container/LineContainer.cpp | 22 +-- src/core/element_container/LineContainer.hpp | 24 +-- src/core/element_container/LoadContainer.hpp | 6 +- .../element_container/OneSideContainer.hpp | 2 +- .../element_container/OneSideContainer_PQ.hpp | 6 +- src/core/element_container/SGenContainer.cpp | 14 +- src/core/element_container/SGenContainer.hpp | 14 +- src/core/element_container/ShuntContainer.hpp | 6 +- .../element_container/StorageContainer.hpp | 6 +- src/core/element_container/SvcContainer.cpp | 14 +- src/core/element_container/SvcContainer.hpp | 14 +- src/core/element_container/TrafoContainer.cpp | 30 +-- src/core/element_container/TrafoContainer.hpp | 30 +-- .../element_container/TwoSidesContainer.hpp | 4 +- 23 files changed, 296 insertions(+), 296 deletions(-) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 0b36a29f..39fb3a0c 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -316,7 +316,7 @@ void LSGrid::set_ls_to_orig_internal(const IntVect & ls_to_orig) noexcept{ //init void LSGrid::init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, - const RealVect & bus_vn_kv, + const Eigen::Ref & bus_vn_kv, int /*nb_line*/, int /*nb_trafo*/){ /** diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index daa6a1c1..15039488 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -300,123 +300,123 @@ class LS2G_API LSGrid final void reactivate_result_computation(){compute_results_=true;} // All methods to init this data model, all need to be pair unit when applicable - void init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, const RealVect & bus_vn_kv, int nb_line, int nb_trafo); + void init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, const Eigen::Ref & bus_vn_kv, int nb_line, int nb_trafo); void set_init_vm_pu(real_type init_vm_pu) {init_vm_pu_ = init_vm_pu; } real_type get_init_vm_pu() {return init_vm_pu_;} void set_sn_mva(real_type sn_mva) {sn_mva_ = sn_mva; } real_type get_sn_mva() {return sn_mva_;} - void init_powerlines(const RealVect & branch_r, - const RealVect & branch_x, - const CplxVect & branch_h, - const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id + void init_powerlines(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_h, + const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id ){ powerlines_.init(branch_r, branch_x, branch_h, branch_from_id, branch_to_id); } - void init_powerlines_full(const RealVect & branch_r, - const RealVect & branch_x, - const CplxVect & branch_h1, - const CplxVect & branch_h2, - const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id + void init_powerlines_full(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_h1, + const Eigen::Ref & branch_h2, + const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id ){ powerlines_.init(branch_r, branch_x, branch_h1, branch_h2, branch_from_id, branch_to_id); } - void init_shunt(const RealVect & shunt_p_mw, - const RealVect & shunt_q_mvar, - const Eigen::VectorXi & shunt_bus_id){ + void init_shunt(const Eigen::Ref & shunt_p_mw, + const Eigen::Ref & shunt_q_mvar, + const Eigen::Ref & shunt_bus_id){ shunts_.init(shunt_p_mw, shunt_q_mvar, shunt_bus_id); } - void init_trafo_pandapower(const RealVect & trafo_r, - const RealVect & trafo_x, - const CplxVect & trafo_b, - const RealVect & trafo_tap_step_pct, - const RealVect & trafo_tap_pos, - const RealVect & trafo_shift_degree, + void init_trafo_pandapower(const Eigen::Ref & trafo_r, + const Eigen::Ref & trafo_x, + const Eigen::Ref & trafo_b, + const Eigen::Ref & trafo_tap_step_pct, + const Eigen::Ref & trafo_tap_pos, + const Eigen::Ref & trafo_shift_degree, const std::vector & trafo_tap_hv, // is tap on high voltage (true) or low voltate - const Eigen::VectorXi & bus1_id, - const Eigen::VectorXi & bus2_id, + const Eigen::Ref & bus1_id, + const Eigen::Ref & bus2_id, bool ignore_tap_side_for_shift ){ trafos_.init(trafo_r, trafo_x, trafo_b, trafo_tap_step_pct, trafo_tap_pos, trafo_shift_degree, trafo_tap_hv, bus1_id, bus2_id, ignore_tap_side_for_shift); } - void init_trafo(const RealVect & trafo_r, - const RealVect & trafo_x, - const CplxVect & trafo_b, - const RealVect & trafo_ratio, - const RealVect & trafo_shift_degree, + void init_trafo(const Eigen::Ref & trafo_r, + const Eigen::Ref & trafo_x, + const Eigen::Ref & trafo_b, + const Eigen::Ref & trafo_ratio, + const Eigen::Ref & trafo_shift_degree, const std::vector & trafo_tap_hv, // is tap on high voltage (true) or low voltate - const Eigen::VectorXi & bus1_id, - const Eigen::VectorXi & bus2_id, + const Eigen::Ref & bus1_id, + const Eigen::Ref & bus2_id, bool ignore_tap_side_for_shift ){ trafos_.init(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, bus1_id, bus2_id, ignore_tap_side_for_shift); } - void init_generators(const RealVect & generators_p, - const RealVect & generators_v, - const RealVect & generators_min_q, - const RealVect & generators_max_q, - const Eigen::VectorXi & generators_bus_id){ + void init_generators(const Eigen::Ref & generators_p, + const Eigen::Ref & generators_v, + const Eigen::Ref & generators_min_q, + const Eigen::Ref & generators_max_q, + const Eigen::Ref & generators_bus_id){ generators_.init(generators_p, generators_v, generators_min_q, generators_max_q, generators_bus_id); } - void init_generators_full(const RealVect & generators_p, - const RealVect & generators_v, - const RealVect & generators_q, + void init_generators_full(const Eigen::Ref & generators_p, + const Eigen::Ref & generators_v, + const Eigen::Ref & generators_q, const std::vector & voltage_regulator_on, - const RealVect & generators_min_q, - const RealVect & generators_max_q, - const Eigen::VectorXi & generators_bus_id){ + const Eigen::Ref & generators_min_q, + const Eigen::Ref & generators_max_q, + const Eigen::Ref & generators_bus_id){ generators_.init_full(generators_p, generators_v, generators_q, voltage_regulator_on, generators_min_q, generators_max_q, generators_bus_id); } - void init_loads(const RealVect & loads_p, - const RealVect & loads_q, - const Eigen::VectorXi & loads_bus_id){ + void init_loads(const Eigen::Ref & loads_p, + const Eigen::Ref & loads_q, + const Eigen::Ref & loads_bus_id){ loads_.init(loads_p, loads_q, loads_bus_id); } - void init_sgens(const RealVect & sgen_p, - const RealVect & sgen_q, - const RealVect & sgen_pmin, - const RealVect & sgen_pmax, - const RealVect & sgen_qmin, - const RealVect & sgen_qmax, - const Eigen::VectorXi & sgen_bus_id){ + void init_sgens(const Eigen::Ref & sgen_p, + const Eigen::Ref & sgen_q, + const Eigen::Ref & sgen_pmin, + const Eigen::Ref & sgen_pmax, + const Eigen::Ref & sgen_qmin, + const Eigen::Ref & sgen_qmax, + const Eigen::Ref & sgen_bus_id){ sgens_.init(sgen_p, sgen_q, sgen_pmin, sgen_pmax, sgen_qmin, sgen_qmax, sgen_bus_id); } void init_svcs(const std::vector & regulation_mode, - const RealVect & target_vm_pu, - const RealVect & q_setpoint_mvar, - const RealVect & slope_pu, - const RealVect & b_min, - const RealVect & b_max, - const Eigen::VectorXi & regulated_bus_id, - const Eigen::VectorXi & svc_bus_id){ + const Eigen::Ref & target_vm_pu, + const Eigen::Ref & q_setpoint_mvar, + const Eigen::Ref & slope_pu, + const Eigen::Ref & b_min, + const Eigen::Ref & b_max, + const Eigen::Ref & regulated_bus_id, + const Eigen::Ref & svc_bus_id){ svcs_.init(regulation_mode, target_vm_pu, q_setpoint_mvar, slope_pu, b_min, b_max, regulated_bus_id, svc_bus_id); } - void init_storages(const RealVect & storages_p, - const RealVect & storages_q, - const Eigen::VectorXi & storages_bus_id){ + void init_storages(const Eigen::Ref & storages_p, + const Eigen::Ref & storages_q, + const Eigen::Ref & storages_bus_id){ storages_.init(storages_p, storages_q, storages_bus_id); } - void init_dclines(const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id, - const RealVect & p_mw, - const RealVect & loss_percent, - const RealVect & loss_mw, - const RealVect & vm1_pu, - const RealVect & vm2_pu, - const RealVect & min_q1, - const RealVect & max_q1, - const RealVect & min_q2, - const RealVect & max_q2){ + void init_dclines(const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id, + const Eigen::Ref & p_mw, + const Eigen::Ref & loss_percent, + const Eigen::Ref & loss_mw, + const Eigen::Ref & vm1_pu, + const Eigen::Ref & vm2_pu, + const Eigen::Ref & min_q1, + const Eigen::Ref & max_q1, + const Eigen::Ref & min_q2, + const Eigen::Ref & max_q2){ hvdc_lines_.init_legacy(branch_from_id, branch_to_id, p_mw, loss_percent, loss_mw, vm1_pu, vm2_pu, min_q1, max_q1, min_q2, max_q2); @@ -425,33 +425,33 @@ class LS2G_API LSGrid final * Full IIDM-style hvdc initialization (two converter stations - VSC or * LCC - per line, optional angle-droop). See HvdcLineContainer::init. */ - void init_hvdc_lines(const Eigen::VectorXi & bus1_id, - const Eigen::VectorXi & bus2_id, + void init_hvdc_lines(const Eigen::Ref & bus1_id, + const Eigen::Ref & bus2_id, const std::vector & type1, const std::vector & type2, - const RealVect & loss_factor1, - const RealVect & loss_factor2, + const Eigen::Ref & loss_factor1, + const Eigen::Ref & loss_factor2, const std::vector & vreg1_on, const std::vector & vreg2_on, - const RealVect & vm1_pu, - const RealVect & vm2_pu, - const RealVect & q1_setpoint_mvar, - const RealVect & q2_setpoint_mvar, - const RealVect & min_q1, - const RealVect & max_q1, - const RealVect & min_q2, - const RealVect & max_q2, - const RealVect & power_factor1, - const RealVect & power_factor2, + const Eigen::Ref & vm1_pu, + const Eigen::Ref & vm2_pu, + const Eigen::Ref & q1_setpoint_mvar, + const Eigen::Ref & q2_setpoint_mvar, + const Eigen::Ref & min_q1, + const Eigen::Ref & max_q1, + const Eigen::Ref & min_q2, + const Eigen::Ref & max_q2, + const Eigen::Ref & power_factor1, + const Eigen::Ref & power_factor2, const std::vector & converters_mode, - const RealVect & p_setpoint_mw, - const RealVect & r_ohm, - const RealVect & nominal_v_kv, + const Eigen::Ref & p_setpoint_mw, + const Eigen::Ref & r_ohm, + const Eigen::Ref & nominal_v_kv, const std::vector & droop_enabled, - const RealVect & droop_p0_mw, - const RealVect & droop_mw_per_deg, - const RealVect & pmax_1to2_mw, - const RealVect & pmax_2to1_mw){ + const Eigen::Ref & droop_p0_mw, + const Eigen::Ref & droop_mw_per_deg, + const Eigen::Ref & pmax_1to2_mw, + const Eigen::Ref & pmax_2to1_mw){ const RealVect no_legacy_loss = RealVect::Zero(bus1_id.size()); hvdc_lines_.init(bus1_id, bus2_id, type1, type2, loss_factor1, loss_factor2, vreg1_on, vreg2_on, diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 9c45ad20..7fb42d4a 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -163,7 +163,7 @@ class LS2G_API SubstationContainer final : public IteratorAdder & bus_vn_kv) { if(bus_vn_kv.size() != n_sub * nmax_busbar_per_sub){ std::ostringstream exc_; diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index 9e66d1fe..e5e97a48 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -14,14 +14,14 @@ namespace ls2g { void ConverterStationContainer::init(const std::vector & type, - const RealVect & loss_factor, + const Eigen::Ref & loss_factor, const std::vector & voltage_regulator_on, - const RealVect & target_vm_pu, - const RealVect & q_setpoint_mvar, - const RealVect & min_q, - const RealVect & max_q, - const RealVect & power_factor, - const Eigen::VectorXi & bus_id) + const Eigen::Ref & target_vm_pu, + const Eigen::Ref & q_setpoint_mvar, + const Eigen::Ref & min_q, + const Eigen::Ref & max_q, + const Eigen::Ref & power_factor, + const Eigen::Ref & bus_id) { // active power is derived (owned by the HvdcLineContainer), it starts at 0 const auto p_init = RealVect::Zero(bus_id.size()); diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index 2a96541d..67d65a67 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -104,14 +104,14 @@ class LS2G_API ConverterStationContainer : public OneSideContainer_PQ, public It virtual ~ConverterStationContainer() noexcept = default; void init(const std::vector & type, - const RealVect & loss_factor, + const Eigen::Ref & loss_factor, const std::vector & voltage_regulator_on, - const RealVect & target_vm_pu, - const RealVect & q_setpoint_mvar, - const RealVect & min_q, - const RealVect & max_q, - const RealVect & power_factor, - const Eigen::VectorXi & bus_id); + const Eigen::Ref & target_vm_pu, + const Eigen::Ref & q_setpoint_mvar, + const Eigen::Ref & min_q, + const Eigen::Ref & max_q, + const Eigen::Ref & power_factor, + const Eigen::Ref & bus_id); // pickle ConverterStationContainer::StateRes get_state() const; diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 6d07cd18..46799939 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -14,11 +14,11 @@ namespace ls2g { -void GeneratorContainer::init(const RealVect & generators_p, - const RealVect & generators_v, - const RealVect & generators_min_q, - const RealVect & generators_max_q, - const Eigen::VectorXi & generators_bus_id) +void GeneratorContainer::init(const Eigen::Ref & generators_p, + const Eigen::Ref & generators_v, + const Eigen::Ref & generators_min_q, + const Eigen::Ref & generators_max_q, + const Eigen::Ref & generators_bus_id) { const auto generators_q = RealVect::Zero(generators_p.size()); const auto voltage_regulator_on = std::vector(generators_p.size(), true); @@ -32,13 +32,13 @@ void GeneratorContainer::init(const RealVect & generators_p, generators_bus_id); } -void GeneratorContainer::init_full(const RealVect & generators_p, - const RealVect & generators_v, - const RealVect & generators_q, +void GeneratorContainer::init_full(const Eigen::Ref & generators_p, + const Eigen::Ref & generators_v, + const Eigen::Ref & generators_q, const std::vector & voltage_regulator_on, - const RealVect & generators_min_q, - const RealVect & generators_max_q, - const Eigen::VectorXi & generators_bus_id + const Eigen::Ref & generators_min_q, + const Eigen::Ref & generators_max_q, + const Eigen::Ref & generators_bus_id ) { init_osc_pq(generators_p, generators_q, generators_bus_id, "generators"); diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index fd2a0db7..10123c0c 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -73,20 +73,20 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter virtual ~GeneratorContainer() noexcept = default; // TODO add pmin and pmax here ! - void init(const RealVect & generators_p, - const RealVect & generators_v, - const RealVect & generators_min_q, - const RealVect & generators_max_q, - const Eigen::VectorXi & generators_bus_id + void init(const Eigen::Ref & generators_p, + const Eigen::Ref & generators_v, + const Eigen::Ref & generators_min_q, + const Eigen::Ref & generators_max_q, + const Eigen::Ref & generators_bus_id ); - - void init_full(const RealVect & generators_p, - const RealVect & generators_v, - const RealVect & generators_q, + + void init_full(const Eigen::Ref & generators_p, + const Eigen::Ref & generators_v, + const Eigen::Ref & generators_q, const std::vector & voltage_regulator_on, - const RealVect & generators_min_q, - const RealVect & generators_max_q, - const Eigen::VectorXi & generators_bus_id + const Eigen::Ref & generators_min_q, + const Eigen::Ref & generators_max_q, + const Eigen::Ref & generators_bus_id ); // pickle diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index 86961134..ddaf9d79 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -14,35 +14,35 @@ namespace ls2g { -void HvdcLineContainer::init(const Eigen::VectorXi & bus1_id, - const Eigen::VectorXi & bus2_id, +void HvdcLineContainer::init(const Eigen::Ref & bus1_id, + const Eigen::Ref & bus2_id, const std::vector & type1, const std::vector & type2, - const RealVect & loss_factor1, - const RealVect & loss_factor2, + const Eigen::Ref & loss_factor1, + const Eigen::Ref & loss_factor2, const std::vector & vreg1_on, const std::vector & vreg2_on, - const RealVect & vm1_pu, - const RealVect & vm2_pu, - const RealVect & q1_setpoint_mvar, - const RealVect & q2_setpoint_mvar, - const RealVect & min_q1, - const RealVect & max_q1, - const RealVect & min_q2, - const RealVect & max_q2, - const RealVect & power_factor1, - const RealVect & power_factor2, + const Eigen::Ref & vm1_pu, + const Eigen::Ref & vm2_pu, + const Eigen::Ref & q1_setpoint_mvar, + const Eigen::Ref & q2_setpoint_mvar, + const Eigen::Ref & min_q1, + const Eigen::Ref & max_q1, + const Eigen::Ref & min_q2, + const Eigen::Ref & max_q2, + const Eigen::Ref & power_factor1, + const Eigen::Ref & power_factor2, const std::vector & converters_mode, - const RealVect & p_setpoint_mw, - const RealVect & r_ohm, - const RealVect & nominal_v_kv, - const RealVect & loss_percent, - const RealVect & loss_mw, + const Eigen::Ref & p_setpoint_mw, + const Eigen::Ref & r_ohm, + const Eigen::Ref & nominal_v_kv, + const Eigen::Ref & loss_percent, + const Eigen::Ref & loss_mw, const std::vector & droop_enabled, - const RealVect & droop_p0_mw, - const RealVect & droop_mw_per_deg, - const RealVect & pmax_1to2_mw, - const RealVect & pmax_2to1_mw + const Eigen::Ref & droop_p0_mw, + const Eigen::Ref & droop_mw_per_deg, + const Eigen::Ref & pmax_1to2_mw, + const Eigen::Ref & pmax_2to1_mw ) { init_tsc(bus1_id, bus2_id, "hvdc lines"); @@ -118,17 +118,17 @@ void HvdcLineContainer::init(const Eigen::VectorXi & bus1_id, } } -void HvdcLineContainer::init_legacy(const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id, - const RealVect & p_mw, - const RealVect & loss_percent, - const RealVect & loss_mw, - const RealVect & vm_or_pu, - const RealVect & vm_ex_pu, - const RealVect & min_q_or, - const RealVect & max_q_or, - const RealVect & min_q_ex, - const RealVect & max_q_ex) +void HvdcLineContainer::init_legacy(const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id, + const Eigen::Ref & p_mw, + const Eigen::Ref & loss_percent, + const Eigen::Ref & loss_mw, + const Eigen::Ref & vm_or_pu, + const Eigen::Ref & vm_ex_pu, + const Eigen::Ref & min_q_or, + const Eigen::Ref & max_q_or, + const Eigen::Ref & min_q_ex, + const Eigen::Ref & max_q_ex) { const int size = static_cast(p_mw.size()); const std::vector type_vsc(size, ConverterStationContainer::ConverterType::VSC); diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index ea201e7b..61174ce1 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -160,35 +160,35 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & bus1_id, + const Eigen::Ref & bus2_id, const std::vector & type1, const std::vector & type2, - const RealVect & loss_factor1, - const RealVect & loss_factor2, + const Eigen::Ref & loss_factor1, + const Eigen::Ref & loss_factor2, const std::vector & vreg1_on, const std::vector & vreg2_on, - const RealVect & vm1_pu, - const RealVect & vm2_pu, - const RealVect & q1_setpoint_mvar, - const RealVect & q2_setpoint_mvar, - const RealVect & min_q1, - const RealVect & max_q1, - const RealVect & min_q2, - const RealVect & max_q2, - const RealVect & power_factor1, - const RealVect & power_factor2, + const Eigen::Ref & vm1_pu, + const Eigen::Ref & vm2_pu, + const Eigen::Ref & q1_setpoint_mvar, + const Eigen::Ref & q2_setpoint_mvar, + const Eigen::Ref & min_q1, + const Eigen::Ref & max_q1, + const Eigen::Ref & min_q2, + const Eigen::Ref & max_q2, + const Eigen::Ref & power_factor1, + const Eigen::Ref & power_factor2, const std::vector & converters_mode, - const RealVect & p_setpoint_mw, - const RealVect & r_ohm, - const RealVect & nominal_v_kv, - const RealVect & loss_percent, - const RealVect & loss_mw, + const Eigen::Ref & p_setpoint_mw, + const Eigen::Ref & r_ohm, + const Eigen::Ref & nominal_v_kv, + const Eigen::Ref & loss_percent, + const Eigen::Ref & loss_mw, const std::vector & droop_enabled, - const RealVect & droop_p0_mw, - const RealVect & droop_mw_per_deg, - const RealVect & pmax_1to2_mw, - const RealVect & pmax_2to1_mw + const Eigen::Ref & droop_p0_mw, + const Eigen::Ref & droop_mw_per_deg, + const Eigen::Ref & pmax_1to2_mw, + const Eigen::Ref & pmax_2to1_mw ); /** @@ -197,17 +197,17 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & branch_from_id, + const Eigen::Ref & branch_to_id, + const Eigen::Ref & p_mw, + const Eigen::Ref & loss_percent, + const Eigen::Ref & loss_mw, + const Eigen::Ref & vm_or_pu, + const Eigen::Ref & vm_ex_pu, + const Eigen::Ref & min_q_or, + const Eigen::Ref & max_q_or, + const Eigen::Ref & min_q_ex, + const Eigen::Ref & max_q_ex ); // accessor / modifiers diff --git a/src/core/element_container/LineContainer.cpp b/src/core/element_container/LineContainer.cpp index 3bd9e979..0dcbf28d 100644 --- a/src/core/element_container/LineContainer.cpp +++ b/src/core/element_container/LineContainer.cpp @@ -14,11 +14,11 @@ namespace ls2g { void LineContainer::init( - const RealVect & branch_r, - const RealVect & branch_x, - const CplxVect & branch_h, - const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id + const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_h, + const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id ) { /** @@ -36,12 +36,12 @@ void LineContainer::init( branch_to_id); } -void LineContainer::init(const RealVect & branch_r, - const RealVect & branch_x, - const CplxVect & branch_h_or, - const CplxVect & branch_h_ex, - const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id +void LineContainer::init(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_h_or, + const Eigen::Ref & branch_h_ex, + const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id ) { /** diff --git a/src/core/element_container/LineContainer.hpp b/src/core/element_container/LineContainer.hpp index 82ab37fe..e5a18dd6 100644 --- a/src/core/element_container/LineContainer.hpp +++ b/src/core/element_container/LineContainer.hpp @@ -49,19 +49,19 @@ class LS2G_API LineContainer final: public TwoSidesContainer_rxh_A & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_h, + const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id ); - - void init(const RealVect & branch_r, - const RealVect & branch_x, - const CplxVect & branch_h_or, - const CplxVect & branch_h_ex, - const Eigen::VectorXi & branch_from_id, - const Eigen::VectorXi & branch_to_id + + void init(const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_h_or, + const Eigen::Ref & branch_h_ex, + const Eigen::Ref & branch_from_id, + const Eigen::Ref & branch_to_id ); // pickle diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 1afbd371..2d9fdf71 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -68,9 +68,9 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA static LoadContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "LoadContainer"; } // written into / checked against the binary file header - void init(const RealVect & load_p_mw, - const RealVect & load_q_mvar, - const Eigen::VectorXi & load_bus_id + void init(const Eigen::Ref & load_p_mw, + const Eigen::Ref & load_q_mvar, + const Eigen::Ref & load_bus_id ) { init_osc_pq(load_p_mw, diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index a86373f4..03f807cd 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -396,7 +396,7 @@ class OneSideContainer : public GenericContainer } void init_osc( - const Eigen::VectorXi & els_bus_id + const Eigen::Ref & els_bus_id ) // osc: one side container { bus_id_ = GlobalBusIdVect(els_bus_id); diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index ed21c555..9ffd5b16 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -157,9 +157,9 @@ class OneSideContainer_PQ : public OneSideContainer this->reset_results(); } - void init_osc_pq(const RealVect & els_p, - const RealVect & els_q, - const Eigen::VectorXi & els_bus_id, + void init_osc_pq(const Eigen::Ref & els_p, + const Eigen::Ref & els_q, + const Eigen::Ref & els_bus_id, const std::string & name_el ) // osc: one side element { diff --git a/src/core/element_container/SGenContainer.cpp b/src/core/element_container/SGenContainer.cpp index 496b5f4d..f04c0efb 100644 --- a/src/core/element_container/SGenContainer.cpp +++ b/src/core/element_container/SGenContainer.cpp @@ -13,13 +13,13 @@ namespace ls2g { -void SGenContainer::init(const RealVect & sgen_p, - const RealVect & sgen_q, - const RealVect & sgen_pmin, - const RealVect & sgen_pmax, - const RealVect & sgen_qmin, - const RealVect & sgen_qmax, - const Eigen::VectorXi & sgen_bus_id) +void SGenContainer::init(const Eigen::Ref & sgen_p, + const Eigen::Ref & sgen_q, + const Eigen::Ref & sgen_pmin, + const Eigen::Ref & sgen_pmax, + const Eigen::Ref & sgen_qmin, + const Eigen::Ref & sgen_qmax, + const Eigen::Ref & sgen_bus_id) { init_osc_pq(sgen_p, sgen_q, sgen_bus_id, "static_generators"); diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index 2231ac3c..e3d78f05 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -77,13 +77,13 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA static const char * binary_type_tag() { return "SGenContainer"; } // written into / checked against the binary file header - void init(const RealVect & sgen_p, - const RealVect & sgen_q, - const RealVect & sgen_pmin, - const RealVect & sgen_pmax, - const RealVect & sgen_qmin, - const RealVect & sgen_qmax, - const Eigen::VectorXi & sgen_bus_id + void init(const Eigen::Ref & sgen_p, + const Eigen::Ref & sgen_q, + const Eigen::Ref & sgen_pmin, + const Eigen::Ref & sgen_pmax, + const Eigen::Ref & sgen_qmin, + const Eigen::Ref & sgen_qmax, + const Eigen::Ref & sgen_bus_id ); virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const ; diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index 3eea2e74..3bd0ca13 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -50,9 +50,9 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator virtual ~ShuntContainer() noexcept = default; - void init(const RealVect & shunt_p_mw, - const RealVect & shunt_q_mvar, - const Eigen::VectorXi & shunt_bus_id + void init(const Eigen::Ref & shunt_p_mw, + const Eigen::Ref & shunt_q_mvar, + const Eigen::Ref & shunt_bus_id ) { init_osc_pq(shunt_p_mw, diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index a97a1d80..36dde8c8 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -70,9 +70,9 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat static StorageContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "StorageContainer"; } // written into / checked against the binary file header - void init(const RealVect & storage_p_mw, - const RealVect & storage_q_mvar, - const Eigen::VectorXi & storage_bus_id + void init(const Eigen::Ref & storage_p_mw, + const Eigen::Ref & storage_q_mvar, + const Eigen::Ref & storage_bus_id ) { init_osc_pq(storage_p_mw, diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index b85083ff..d448d1f9 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -15,13 +15,13 @@ namespace ls2g { void SvcContainer::init(const std::vector & regulation_mode, - const RealVect & target_vm_pu, - const RealVect & q_setpoint_mvar, - const RealVect & slope_pu, - const RealVect & b_min, - const RealVect & b_max, - const Eigen::VectorXi & regulated_bus_id, - const Eigen::VectorXi & bus_id) + const Eigen::Ref & target_vm_pu, + const Eigen::Ref & q_setpoint_mvar, + const Eigen::Ref & slope_pu, + const Eigen::Ref & b_min, + const Eigen::Ref & b_max, + const Eigen::Ref & regulated_bus_id, + const Eigen::Ref & bus_id) { const int size = static_cast(bus_id.size()); // an SVC has NO active power diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 47926cc9..4cd8b8ad 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -97,13 +97,13 @@ class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder & regulation_mode, - const RealVect & target_vm_pu, - const RealVect & q_setpoint_mvar, - const RealVect & slope_pu, - const RealVect & b_min, - const RealVect & b_max, - const Eigen::VectorXi & regulated_bus_id, - const Eigen::VectorXi & bus_id); + const Eigen::Ref & target_vm_pu, + const Eigen::Ref & q_setpoint_mvar, + const Eigen::Ref & slope_pu, + const Eigen::Ref & b_min, + const Eigen::Ref & b_max, + const Eigen::Ref & regulated_bus_id, + const Eigen::Ref & bus_id); // pickle SvcContainer::StateRes get_state() const; diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 93889cb7..7d102ebc 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -15,15 +15,15 @@ namespace ls2g { void TrafoContainer::init( - const RealVect & trafo_r, - const RealVect & trafo_x, - const CplxVect & trafo_b, - const RealVect & trafo_tap_step_pct, - const RealVect & trafo_tap_pos, - const RealVect & trafo_shift_degree, + const Eigen::Ref & trafo_r, + const Eigen::Ref & trafo_x, + const Eigen::Ref & trafo_b, + const Eigen::Ref & trafo_tap_step_pct, + const Eigen::Ref & trafo_tap_pos, + const Eigen::Ref & trafo_shift_degree, const std::vector & trafo_tap_hv, // is tap on high voltage (true) or low voltate - const Eigen::VectorXi & trafo_hv_id, - const Eigen::VectorXi & trafo_lv_id, + const Eigen::Ref & trafo_hv_id, + const Eigen::Ref & trafo_lv_id, bool ignore_tap_side_for_shift ) { /** @@ -42,14 +42,14 @@ void TrafoContainer::init( } void TrafoContainer::init( - const RealVect & trafo_r, - const RealVect & trafo_x, - const CplxVect & trafo_b, - const RealVect & trafo_ratio, - const RealVect & trafo_shift_degree, + const Eigen::Ref & trafo_r, + const Eigen::Ref & trafo_x, + const Eigen::Ref & trafo_b, + const Eigen::Ref & trafo_ratio, + const Eigen::Ref & trafo_shift_degree, const std::vector & trafo_tap_side1, // is tap on high voltage (true) or low voltate - const Eigen::VectorXi & trafo_hv_id, - const Eigen::VectorXi & trafo_lv_id, + const Eigen::Ref & trafo_hv_id, + const Eigen::Ref & trafo_lv_id, bool ignore_tap_side_for_shift ) { const int size = static_cast(trafo_r.size()); diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index b1611846..bb7423bc 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -84,26 +84,26 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A & trafo_r, + const Eigen::Ref & trafo_x, + const Eigen::Ref & trafo_b, + const Eigen::Ref & trafo_tap_step_pct, + const Eigen::Ref & trafo_tap_pos, + const Eigen::Ref & trafo_shift_degree, const std::vector & trafo_tap_hv, // is tap on high voltage (true) or low voltate - const Eigen::VectorXi & trafo_hv_id, - const Eigen::VectorXi & trafo_lv_id, + const Eigen::Ref & trafo_hv_id, + const Eigen::Ref & trafo_lv_id, bool ignore_tap_side_for_shift ); - void init(const RealVect & trafo_r, - const RealVect & trafo_x, - const CplxVect & trafo_b, - const RealVect & trafo_ratio, - const RealVect & trafo_shift_degree, + void init(const Eigen::Ref & trafo_r, + const Eigen::Ref & trafo_x, + const Eigen::Ref & trafo_b, + const Eigen::Ref & trafo_ratio, + const Eigen::Ref & trafo_shift_degree, const std::vector & trafo_tap_hv, // is tap on high voltage (true) or low voltate - const Eigen::VectorXi & trafo_hv_id, - const Eigen::VectorXi & trafo_lv_id, + const Eigen::Ref & trafo_hv_id, + const Eigen::Ref & trafo_lv_id, bool ignore_tap_side_for_shift ); diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index 89916183..9af33dbe 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -135,8 +135,8 @@ class TwoSidesContainer : public GenericContainer bool get_connected_side_2(int el_id) const {return side_2_.get_status(el_id);} void init_tsc( - const Eigen::VectorXi & els_bus1_id, - const Eigen::VectorXi & els_bus2_id, + const Eigen::Ref & els_bus1_id, + const Eigen::Ref & els_bus2_id, const std::string & name_elements ) // tsc: two sides container { From d1d925ce4e2e1f2c8f031c5baab19b89da758fb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:57:05 +0000 Subject: [PATCH 105/166] Add a report-only clang-tidy CI workflow (performance-*, readability-const-return-type) New .github/workflows/clang-tidy.yml runs clang-tidy over src/core on workflow_dispatch (manual "Run workflow"), on push to master, and on version tags -- not on every topic-branch push like the test/sanitizer workflows, since this is advisory and slower (full-TU analysis). Findings are posted to the job summary and uploaded as an artifact but do not fail the job yet; see the workflow's header comment for how to promote it to a real gate once the baseline noise level is known. .clang-tidy scopes the checks to performance-* (the family that covers the unnecessary-copy patterns found and fixed across the Eigen audit: by-value params, const-ref-to-value copies, ...) and readability-const-return-type, with a header filter restricted to src/core and src/bindings so vendored trees (eigen/, Catch2/, SuiteSparse/) are never analyzed. Both validated locally in this environment: the config is correctly auto-discovered, the header filter correctly limits findings to project code, and a real run surfaced (among genuine findings) a notable false-positive class -- performance-unnecessary-value-param flags every by-value Eigen::Ref parameter (the idiom used throughout this codebase) as an expensive copy, which it isn't. Documented in .clang-tidy rather than pre-emptively suppressed, so the report-only phase shows the real signal-to-noise ratio. Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- .clang-tidy | 26 ++++++++++ .github/workflows/clang-tidy.yml | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 .clang-tidy create mode 100644 .github/workflows/clang-tidy.yml diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 00000000..c6901330 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,26 @@ +# Advisory only for now: see .github/workflows/clang-tidy.yml, which runs +# this report-only (it does not fail the job on findings). The performance-* +# family targets exactly the class of bug found in the Eigen unnecessary-copy +# audit (by-value Eigen/STL params, const-ref-to-value copies, ...); +# readability-const-return-type catches `const T foo()` return types that +# block move semantics on the caller side. +# +# Known false-positive class: performance-unnecessary-value-param flags every +# `Eigen::Ref<...>` parameter passed by value (the idiom used throughout this +# codebase -- Ref is a thin pointer+size+stride wrapper, cheap to copy) as if +# it were an expensive-to-copy type. Confirmed while validating this config; +# left un-suppressed for now so the report-only phase shows the real noise +# level before anyone spends time tuning NOLINT / config exclusions for it. +Checks: > + -*, + performance-*, + readability-const-return-type +WarningsAsErrors: '' +# Only report on our own headers (src/core, src/bindings) -- clang-tidy's +# default (empty) header filter would otherwise only ever look at the +# primary .cpp file and silently skip every inline-in-header function, which +# is most of this codebase's element_container / powerflow_algorithm classes. +# Vendored trees (eigen/, Catch2/, SuiteSparse/) live outside src/, so this +# regex excludes them without needing a separate exclude list. +HeaderFilterRegex: '.*/src/(core|bindings)/.*' +FormatStyle: none diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml new file mode 100644 index 00000000..917f8e94 --- /dev/null +++ b/.github/workflows/clang-tidy.yml @@ -0,0 +1,83 @@ +# Static analysis: runs clang-tidy's performance-* / readability-const-return-type +# checks over src/core (the checks that would have caught the unnecessary Eigen +# copies found and fixed across several PRs). Report-only for now (see the +# "Summarize" step): findings are posted to the job summary and uploaded as an +# artifact, but do not fail the job. Once the baseline noise level is known, +# flip `continue-on-error` below (and drop the `|| true`) to make this a real +# gate -- e.g. restricted to the master / tag triggers already below, so a +# regression blocks a release without slowing down every topic-branch push. +# +# Triggers: on demand (Actions tab -> this workflow -> "Run workflow"), on +# every push to master, and on every version tag -- not on topic branches, +# since this is advisory / slower than the test suites that do run there. +name: clang-tidy + +on: + workflow_dispatch: + push: + branches: + - master + tags: + - 'v*.*.*' + +jobs: + clang-tidy: + name: performance / readability checks (src/core) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # eigen (transitive include of the core headers), Catch2 (needed to + # configure src/tests, even though test files are not analyzed) and + # SuiteSparse (KLU headers) + submodules: true + + - name: Install clang-tidy + run: | + sudo apt-get update + sudo apt-get install -y clang-tidy + clang-tidy --version + + - name: Configure (generates compile_commands.json for src/core) + # standalone test project: no python / pybind11 involved, matching + # the C++ unit tests workflow. -DCMAKE_EXPORT_COMPILE_COMMANDS=ON is + # the only addition -- clang-tidy needs it to see the real include + # paths / defines each translation unit was configured with. + run: cmake -S src/tests -B build_tidy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + + - name: Run clang-tidy over src/core + # Not built: clang-tidy re-parses each translation unit itself via + # libTooling and does not need object files or a link step. + # `|| true`: see the report-only note above -- warnings must not fail + # this job yet, only errors in clang-tidy's own invocation would + # (caught separately by checking the report is non-empty below). + run: | + find src/core -name '*.cpp' | sort > /tmp/tidy_files.txt + wc -l < /tmp/tidy_files.txt + clang-tidy -p build_tidy $(cat /tmp/tidy_files.txt) > clang-tidy-report.txt 2>&1 || true + + - name: Summarize + run: | + { + echo "## clang-tidy (performance-*, readability-const-return-type)" + echo + if [ ! -s clang-tidy-report.txt ]; then + echo "clang-tidy produced no output -- something upstream (configure/invocation) likely failed silently. Check the previous step's log." + else + n_warnings=$(grep -c 'warning:' clang-tidy-report.txt || true) + echo "**${n_warnings} finding(s)** across $(wc -l < /tmp/tidy_files.txt) files in \`src/core\`." + echo + echo "This workflow is report-only: it does not fail the job. See \`.github/workflows/clang-tidy.yml\` for how to promote it to a gate." + echo + echo '```' + head -c 60000 clang-tidy-report.txt + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload full report + uses: actions/upload-artifact@v4 + with: + name: clang-tidy-report + path: clang-tidy-report.txt + retention-days: 30 From e908549e712e387be7ecdbd70a9fbcb002aaf51a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:57:44 +0000 Subject: [PATCH 106/166] LSGrid::ac_pf/dc_pf/check_solution and the Vinit chain: Eigen::Ref Vinit (the initial voltage guess) is the single most-called entry point in this codebase -- once per Grid2Op step, straight from Python -- and was still a concrete const CplxVect&, forcing a numpy copy at the pybind11 boundary on every solve for no reason: confirmed nothing downstream ever caches its address (unlike the earlier Sbus_ptr_ hazard), it's only ever read (indexed, or checked for .size()). Converted the whole chain to Eigen::Ref: - LSGrid::ac_pf, dc_pf, check_solution (the public entry points) - LSGrid::pre_process_solver, pre_process_dc_solver, _pre_process_solver_impl, process_results (their shared internals) - BaseBatchSolverSynch::prepare_solver_input_base, extract_Vsolver_from_Vinit, _reset_data_and_check_vinit - TimeSeries::compute_Vs, ContingencyAnalysis::compute (the batch entry points that feed the same pre_process_solver chain) Also fixed a related copy found while tracing this: LSGrid:: process_results bound `_algo.get_V()`/`_dc_algo.get_V()` -- which already return Eigen::Ref -- into a local `const CplxVect&`, materializing a full-vector copy on every single solve. Now binds as Eigen::Ref instead, and _get_results_back_to_orig_nodes (which consumes it) takes the same type. Verified: Catch2 C++ test suite and a full AddressSanitizer + UBSan run (43/43 both ways), plus a syntax-check of every pybind11 binding file with pybind11 installed. Signed-off-by: Benjamin Donnot Signed-off-by: Claude --- src/core/LSGrid.cpp | 21 +++++++++++-------- src/core/LSGrid.hpp | 16 +++++++------- .../batch_algorithm/BaseBatchSolverSynch.hpp | 6 +++--- .../batch_algorithm/ContingencyAnalysis.cpp | 2 +- .../batch_algorithm/ContingencyAnalysis.hpp | 2 +- src/core/batch_algorithm/TimeSeries.cpp | 2 +- src/core/batch_algorithm/TimeSeries.hpp | 2 +- 7 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 39fb3a0c..9a5ba7bf 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -377,7 +377,7 @@ void LSGrid::reset(bool reset_solver, bool reset_ac, bool reset_dc) } } -CplxVect LSGrid::ac_pf(const CplxVect & Vinit, +CplxVect LSGrid::ac_pf(const Eigen::Ref & Vinit, int max_iter, real_type tol) { @@ -836,7 +836,7 @@ void LSGrid::check_solution_q_values(CplxVect & res, bool check_q_limits) const{ } } -CplxVect LSGrid::check_solution(const CplxVect & V_proposed, bool check_q_limits) +CplxVect LSGrid::check_solution(const Eigen::Ref & V_proposed, bool check_q_limits) { // pre process the data to define a proper jacobian matrix, the proper voltage vector etc. const int nb_bus = static_cast(V_proposed.size()); @@ -951,7 +951,7 @@ void LSGrid::prepare_injection(RealVect & Pbus, bool redo_all, bool converter_ch template CplxVect LSGrid::_pre_process_solver_impl( - const CplxVect & Vinit, + const Eigen::Ref & Vinit, InjVect & inj, Eigen::SparseMatrix & mat, SolverBusIdVect & id_me_to_solver, @@ -1071,7 +1071,7 @@ CplxVect LSGrid::_pre_process_solver_impl( } CplxVect LSGrid::pre_process_solver( - const CplxVect & Vinit, + const Eigen::Ref & Vinit, CplxVect & Sbus, Eigen::SparseMatrix & Ybus, SolverBusIdVect & id_me_to_solver, @@ -1088,7 +1088,7 @@ CplxVect LSGrid::pre_process_solver( } CplxVect LSGrid::pre_process_dc_solver( - const CplxVect & Vinit, + const Eigen::Ref & Vinit, RealVect & Pbus, Eigen::SparseMatrix & Bbus, SolverBusIdVect & id_me_to_solver, @@ -1102,7 +1102,7 @@ CplxVect LSGrid::pre_process_dc_solver( slack_bus_id_me, slack_bus_id_solver, solver_control, true); } -CplxVect LSGrid::_get_results_back_to_orig_nodes(const CplxVect & res_tmp, +CplxVect LSGrid::_get_results_back_to_orig_nodes(const Eigen::Ref & res_tmp, SolverBusIdVect & id_me_to_solver, int size) { @@ -1125,7 +1125,7 @@ CplxVect LSGrid::_get_results_back_to_orig_nodes(const CplxVect & res_tmp, void LSGrid::process_results(bool conv, CplxVect & res, - const CplxVect & Vinit, + const Eigen::Ref & Vinit, bool ac, SolverBusIdVect & id_me_to_solver) { @@ -1135,7 +1135,10 @@ void LSGrid::process_results(bool conv, compute_results(ac); } // solver_control_.tell_none_changed(); // todo automatically set for ac / dc the `tell_none_changed()` - const CplxVect & res_tmp = ac ? _algo.get_V(): _dc_algo.get_V() ; + // was `const CplxVect & res_tmp = ...`: get_V() already returns Eigen::Ref, so binding that to a concrete CplxVect& forced a full-vector copy + // here on every single solve. + const Eigen::Ref res_tmp = ac ? _algo.get_V(): _dc_algo.get_V() ; // convert back the results to "big" vector res = _get_results_back_to_orig_nodes(res_tmp, @@ -1405,7 +1408,7 @@ void LSGrid::reset_results(){ svcs_.reset_results(); } -CplxVect LSGrid::dc_pf(const CplxVect & Vinit, +CplxVect LSGrid::dc_pf(const Eigen::Ref & Vinit, int max_iter, // only validated: not used for DC (single linear solve) real_type tol // only validated: not used for DC (single linear solve) ) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 15039488..2affb39f 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -569,7 +569,7 @@ class LS2G_API LSGrid final const AlgoControl & get_dc_algo_controler() const {return algo_controler_.dc_algo_controler();} // dc powerflow - CplxVect dc_pf(const CplxVect & Vinit, + CplxVect dc_pf(const Eigen::Ref & Vinit, int max_iter, // not used for DC real_type tol // not used for DC ); @@ -610,12 +610,12 @@ class LS2G_API LSGrid final Eigen::SparseMatrix get_Bf(); // ac powerflow - CplxVect ac_pf(const CplxVect & Vinit, + CplxVect ac_pf(const Eigen::Ref & Vinit, int max_iter, real_type tol); // check the kirchoff law - CplxVect check_solution(const CplxVect & V, bool check_q_limits); + CplxVect check_solution(const Eigen::Ref & V, bool check_q_limits); // deactivate a bus. Be careful, if a bus is deactivated, but an element is // still connected to it, it will throw an exception @@ -1702,7 +1702,7 @@ class LS2G_API LSGrid final // physically-correct gap between that voltage and the local target (the // regulator doing its job) can look like a large spurious power mismatch at a // strongly-meshed bus. - CplxVect pre_process_solver(const CplxVect & Vinit, + CplxVect pre_process_solver(const Eigen::Ref & Vinit, CplxVect & Sbus, Eigen::SparseMatrix & Ybus, SolverBusIdVect & id_me_to_solver, @@ -1716,7 +1716,7 @@ class LS2G_API LSGrid final // DC-specific pre processing: builds the real Bbus (admittance) matrix and the // real Pbus (active power) vector, reusing the shared bus-mapping helpers. Mirrors // pre_process_solver but keeps the whole DC path real (no complex Ybus / Sbus). - CplxVect pre_process_dc_solver(const CplxVect & Vinit, + CplxVect pre_process_dc_solver(const Eigen::Ref & Vinit, RealVect & Pbus, Eigen::SparseMatrix & Bbus, SolverBusIdVect & id_me_to_solver, @@ -1884,7 +1884,7 @@ class LS2G_API LSGrid final // family (cplx_type => AC, real_type => DC); the type-specific steps (matrix init / fill, // injection assembly) are tag-dispatched to the overloads just below. template - CplxVect _pre_process_solver_impl(const CplxVect & Vinit, + CplxVect _pre_process_solver_impl(const Eigen::Ref & Vinit, InjVect & inj, Eigen::SparseMatrix & mat, SolverBusIdVect & id_me_to_solver, @@ -1919,7 +1919,7 @@ class LS2G_API LSGrid final // results /**process the results from the solver to this instance **/ - void process_results(bool conv, CplxVect & res, const CplxVect & Vinit, bool ac, + void process_results(bool conv, CplxVect & res, const Eigen::Ref & Vinit, bool ac, SolverBusIdVect & id_me_to_solver); /** @@ -1961,7 +1961,7 @@ class LS2G_API LSGrid final } } - CplxVect _get_results_back_to_orig_nodes(const CplxVect & res_tmp, + CplxVect _get_results_back_to_orig_nodes(const Eigen::Ref & res_tmp, SolverBusIdVect & id_me_to_solver, int size); diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index efaa90fa..e9b814a6 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -339,7 +339,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants void compute_flows_from_Vs(bool amps=true); - CplxVect extract_Vsolver_from_Vinit(const CplxVect& Vinit, + CplxVect extract_Vsolver_from_Vinit(const Eigen::Ref & Vinit, size_t nb_buses_solver, size_t nb_total_bus, const SolverBusIdVect & id_me_to_ac_solver){ @@ -355,7 +355,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants } protected: - CplxVect prepare_solver_input_base(const CplxVect & Vinit, bool ac_solver_used){ + CplxVect prepare_solver_input_base(const Eigen::Ref & Vinit, bool ac_solver_used){ // clear previous data Sbus_ = CplxVect(); Ybus_ = Eigen::SparseMatrix(); @@ -408,7 +408,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants return res; } - size_t _reset_data_and_check_vinit(const CplxVect & Vinit){ + size_t _reset_data_and_check_vinit(const Eigen::Ref & Vinit){ const size_t nb_total_bus = _grid_model.total_bus(); if(static_cast(Vinit.size()) != nb_total_bus){ std::ostringstream exc_; diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 47c31bbc..5ce14c35 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -553,7 +553,7 @@ void ContingencyAnalysis::readd_to_Ybus( } } -void ContingencyAnalysis::compute(const CplxVect & Vinit, int max_iter, real_type tol) +void ContingencyAnalysis::compute(const Eigen::Ref & Vinit, int max_iter, real_type tol) { auto timer = CustTimer(); auto timer_preproc = CustTimer(); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index b9b92c5e..59bc7ea3 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -158,7 +158,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch // make the computation. Throws if the pre-contingency ("n", no disconnection) powerflow // itself does not converge -- every contingency is solved starting from / relative to // that base case, so a diverging base case makes the whole analysis meaningless. - void compute(const CplxVect & Vinit, int max_iter, real_type tol); + void compute(const Eigen::Ref & Vinit, int max_iter, real_type tol); IntVect is_grid_connected_after_contingency(); // Choose, over the currently-registered contingencies, the reference slack diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index f26481c2..84ba72dd 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -17,7 +17,7 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, Eigen::Ref sgen_p, Eigen::Ref load_p, Eigen::Ref load_q, - const CplxVect & Vinit, + const Eigen::Ref & Vinit, const int max_iter, const real_type tol) { diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index c199a906..ecf42a56 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -54,7 +54,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch Eigen::Ref sgen_p, Eigen::Ref load_p, Eigen::Ref load_q, - const CplxVect & Vinit, + const Eigen::Ref & Vinit, const int max_iter, const real_type tol); From fcb2b69961cc165050a69c44dd15c73edfd5bc31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:27:36 +0000 Subject: [PATCH 107/166] element_container: final on leaf classes, override on missed overrides ConverterStationContainer and SvcContainer have no subclasses; mark them final like their sibling leaf containers. Add the override keyword on virtual overrides that were missing it (GeneratorContainer, ShuntContainer, SGenContainer, StorageContainer, LoadContainer, ConverterStationContainer, TrafoContainer, and the shared OneSideContainer/TwoSidesContainer/ TwoSidesContainer_rxh_A base overrides of GenericContainer's virtuals). TrafoContainer::hack_Sbus_for_dc_phase_shifter is not declared in any base class and has no overrider (the class is final), so its virtual keyword was dead weight; dropped instead of adding an override that wouldn't compile. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- .../ConverterStationContainer.hpp | 4 ++-- .../element_container/GeneratorContainer.hpp | 6 +++--- src/core/element_container/LoadContainer.hpp | 2 +- src/core/element_container/OneSideContainer.hpp | 2 +- src/core/element_container/SGenContainer.hpp | 2 +- src/core/element_container/ShuntContainer.hpp | 6 +++--- src/core/element_container/StorageContainer.hpp | 2 +- src/core/element_container/SvcContainer.hpp | 2 +- src/core/element_container/TrafoContainer.hpp | 16 ++++++++-------- src/core/element_container/TwoSidesContainer.hpp | 2 +- .../TwoSidesContainer_rxh_A.hpp | 6 +++--- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index 67d65a67..3c7ac3fc 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -58,7 +58,7 @@ A station with a (derived) active power of exactly 0 MW is considered (this mirrors the behaviour of the legacy DC lines, whose embedded generators were configured with `turnedoff_no_pv`). **/ -class LS2G_API ConverterStationContainer : public OneSideContainer_PQ, public IteratorAdder +class LS2G_API ConverterStationContainer final : public OneSideContainer_PQ, public IteratorAdder { friend class ConverterStationInfo; @@ -151,7 +151,7 @@ class LS2G_API ConverterStationContainer : public OneSideContainer_PQ, public It virtual void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, const SolverBusIdVect & slack_bus_id_solver, - const SolverBusIdVect & id_grid_to_solver) const; + const SolverBusIdVect & id_grid_to_solver) const override; void init_q_vector(int nb_bus, Eigen::VectorXi & total_gen_per_bus, RealVect & total_q_min_per_bus, diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 10123c0c..3b2b2980 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -184,7 +184,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter RealVect get_slack_weights_solver(size_t nb_bus_solver, const SolverBusIdVect & id_grid_to_solver); GlobalBusIdVect get_slack_bus_id() const; - virtual void set_p_slack(const RealVect& node_mismatch, const SolverBusIdVect & id_grid_to_solver); + virtual void set_p_slack(const RealVect& node_mismatch, const SolverBusIdVect & id_grid_to_solver) override; // modification void turnedoff_no_pv(DualAlgoControl & solver_control){ @@ -257,11 +257,11 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter void change_v(int gen_id, real_type new_v_pu, DualAlgoControl & solver_control); void change_v_nothrow(int gen_id, real_type new_v_pu, DualAlgoControl & solver_control); - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; + virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; virtual void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, const SolverBusIdVect & slack_bus_id_solver, - const SolverBusIdVect & id_grid_to_solver) const; + const SolverBusIdVect & id_grid_to_solver) const override; void init_q_vector(int nb_bus, Eigen::VectorXi & total_gen_per_bus, RealVect & total_q_min_per_bus, diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 2d9fdf71..64169182 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -80,7 +80,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA reset_results(); } - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; + virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: virtual void _compute_results(const Eigen::Ref & /*Va*/, diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 03f807cd..4ea43909 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -193,7 +193,7 @@ class OneSideContainer : public GenericContainer } } - virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) final { + virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override final { const int nb_el = nb(); DualAlgoControl unused_solver_control; for(int el_id = 0; el_id < nb_el; ++el_id) diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index e3d78f05..4cbedad8 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -86,7 +86,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA const Eigen::Ref & sgen_bus_id ); - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const ; + virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: virtual void _compute_results( diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index 3bd0ca13..67f467f1 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -74,13 +74,13 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator virtual void fillYbus(std::vector > & res, bool ac, const SolverBusIdVect & id_grid_to_solver, - real_type sn_mva) const; + real_type sn_mva) const override; virtual void fillBp_Bpp(std::vector > & Bp, std::vector > & Bpp, const SolverBusIdVect & id_grid_to_solver, real_type sn_mva, - FDPFMethod xb_or_bx) const; - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; // in DC i need that + FDPFMethod xb_or_bx) const override; + virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; // in DC i need that protected: virtual void _change_p(int shunt_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index 36dde8c8..a855cf67 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -82,7 +82,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat reset_results(); } - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const; + virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: virtual void _compute_results(const Eigen::Ref & /*Va*/, diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 4cd8b8ad..c236388a 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -52,7 +52,7 @@ IIDM model of powsybl: three regulation modes `b_min_` / `b_max_` are stored for introspection but NEVER enforced (no outer loop, no limit check), mirroring the generator Qmin/Qmax handling. **/ -class LS2G_API SvcContainer : public OneSideContainer_PQ, public IteratorAdder +class LS2G_API SvcContainer final : public OneSideContainer_PQ, public IteratorAdder { friend class SvcInfo; diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index bb7423bc..879c85b6 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -135,10 +135,10 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A > & rx_corr_pct, DualAlgoControl & solver_control); - virtual void hack_Sbus_for_dc_phase_shifter( + void hack_Sbus_for_dc_phase_shifter( CplxVect & Sbus, bool ac, - const SolverBusIdVect & id_grid_to_solver); // needed for dc mode + const SolverBusIdVect & id_grid_to_solver); // needed for dc mode void compute_results(const Eigen::Ref & Va, const Eigen::Ref & Vm, @@ -237,12 +237,12 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A & side1_changed, const std::vector & side2_changed - ) + ) override { bool onechanged_1 = std::any_of(side1_changed.begin(), side1_changed.end(), [](bool v) { return v; }); bool onechanged_2 = std::any_of(side2_changed.begin(), side2_changed.end(), [](bool v) { return v; }); diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index 9af33dbe..aaed8052 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -207,7 +207,7 @@ class TwoSidesContainer : public GenericContainer } } } - virtual void nb_line_end(std::vector & res) const final { + virtual void nb_line_end(std::vector & res) const override final { const int nb_el = nb(); for(int el_id = 0; el_id < nb_el; ++el_id){ // don't do anything if the element is disconnected diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 18dabf0a..10953eb3 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -406,7 +406,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer compute_amps_after_all_set(); } - virtual void get_graph(std::vector > & res) const + virtual void get_graph(std::vector > & res) const override { const auto my_size = nb(); for(size_t el_id = 0; el_id < my_size; ++el_id){ @@ -449,7 +449,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer std::vector > & res, bool ac, const SolverBusIdVect & id_grid_to_solver, - real_type /*sn_mva*/) const + real_type /*sn_mva*/) const override { const size_t nb_els = nb(); const std::vector & status1 = side_1_.get_status(); @@ -575,7 +575,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer std::vector > & Bpp, const SolverBusIdVect & id_grid_to_solver, real_type /*sn_mva*/, - FDPFMethod xb_or_bx) const + FDPFMethod xb_or_bx) const override { // For Bp From 6b5a50bca8ad1bab332355d7bd4389402073d588 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:33:49 +0000 Subject: [PATCH 108/166] powerflow_algorithm/batch_algorithm: final on leaves, override everywhere else, drop dead virtuals NRAlgo and BaseFDPFAlgo have no subclasses (confirmed: not used as a base anywhere in-tree or in examples/external_algorithm) and are now final, matching BaseDCAlgo. Add override throughout: BaseDCAlgo's final-only overrides of BaseAlgo, GaussSeidelSynchAlgo::one_iter (had neither virtual nor override), the ScalingPolicy derived policies' type()/scale() (dispatched through a base pointer via dynamic_cast in update_scaling_policy_params, so this one is a real, not just cosmetic, virtual-hierarchy), and ContingencyAnalysis/TimeSeries's clear() override of BaseBatchSolverSynch. Also drop three vestigial `virtual` keywords found while verifying the override additions signature-by-signature: BaseFDPFAlgo::initialize/solve and NRAlgo::get_J_python declare nothing a base class declares and have no overrider (both classes are leaves), and NRSystem::mismatch/apply_step/mismatch_sq_norm_at are called only through a concrete NRSystem member (never a base pointer) with zero overriders -- these three sit in the hottest part of the NR loop, so the vtable indirection they were paying for was pure waste. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- .../batch_algorithm/ContingencyAnalysis.hpp | 2 +- src/core/batch_algorithm/TimeSeries.hpp | 2 +- src/core/powerflow_algorithm/BaseDCAlgo.hpp | 24 +++++++++---------- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 4 +--- .../GaussSeidelSynchAlgo.hpp | 2 +- src/core/powerflow_algorithm/NRAlgo.hpp | 3 +-- src/core/powerflow_algorithm/NRSystem.hpp | 6 ++--- .../powerflow_algorithm/ScalingPolicies.hpp | 16 ++++++------- 8 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 59bc7ea3..3e9cb542 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -84,7 +84,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch } // utilities to remove defaults to simulate (TODO) - virtual void clear(){ + virtual void clear() override { BaseBatchSolverSynch::clear(); _li_defaults.clear(); _li_coeffs.clear(); diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index ecf42a56..4b4b18c8 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -58,7 +58,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch const int max_iter, const real_type tol); - virtual void clear(){ + virtual void clear() override { BaseBatchSolverSynch::clear(); _Sbuses = CplxMat(); _status = 1; diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index 0909fac6..0f60210b 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -40,8 +40,8 @@ class BaseDCAlgo final: public BaseAlgo virtual ~BaseDCAlgo() noexcept = default; - virtual void reset() final; - virtual void reset_timer() final{ + virtual void reset() override final; + virtual void reset_timer() override final{ BaseAlgo::reset_timer(); timer_refactor_ = 0.; timer_factor_ = 0.; @@ -53,7 +53,7 @@ class BaseDCAlgo final: public BaseAlgo timer_lodf_ = 0.; } - virtual TimerJac get_timers_jacobian() const final + virtual TimerJac get_timers_jacobian() const override final { TimerJac res; res.timer_Fx_ = timer_Fx_; @@ -68,7 +68,7 @@ class BaseDCAlgo final: public BaseAlgo return res; } - virtual TimerPTDFLODFType get_timers_ptdf_lodf() const final + virtual TimerPTDFLODFType get_timers_ptdf_lodf() const override final { TimerPTDFLODFType res = { timer_ptdf_, @@ -89,14 +89,14 @@ class BaseDCAlgo final: public BaseAlgo Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq - ) final; + ) override final; - virtual RealMat get_ptdf() final; + virtual RealMat get_ptdf() override final; virtual RealMat get_lodf(const IntVect & from_bus, - const IntVect & to_bus) final; - virtual Eigen::SparseMatrix get_bsdf() final; // TODO BSDF - - virtual void update_internal_Ybus(const Coeff & coeff, bool add) final{ + const IntVect & to_bus) override final; + virtual Eigen::SparseMatrix get_bsdf() override final; // TODO BSDF + + virtual void update_internal_Ybus(const Coeff & coeff, bool add) override final{ int row_res = static_cast(coeff.row_id); row_res = mat_bus_id_(row_res); if(row_res == -1) return; @@ -118,8 +118,8 @@ class BaseDCAlgo final: public BaseAlgo // a working copy of dcYbus_noslack_, so the (incrementally maintained) // persistent matrix and the symbolic factorization are left untouched (only a // numeric refactorize is needed). See compute_pf_dc. - virtual bool supports_bus_masking() const final { return true; } - virtual void set_masked_buses(const std::vector & solver_bus_ids) final{ + virtual bool supports_bus_masking() const override final { return true; } + virtual void set_masked_buses(const std::vector & solver_bus_ids) override final{ masked_buses_ = solver_bus_ids; } diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 21bacaf5..12a3678a 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -17,7 +17,7 @@ namespace ls2g { Base class for Fast Decoupled Powerflow based solver **/ template -class BaseFDPFAlgo: public BaseAlgo +class BaseFDPFAlgo final: public BaseAlgo { public: BaseFDPFAlgo() noexcept :BaseAlgo(true), need_factorize_(true) {} @@ -77,7 +77,6 @@ class BaseFDPFAlgo: public BaseAlgo void fillBp_Bpp(Eigen::SparseMatrix & Bp, Eigen::SparseMatrix & Bpp) const; // defined in Solvers.cpp ! - virtual void initialize() { auto timer = CustTimer(); err_ = ErrorType::NoError; // reset error message @@ -111,7 +110,6 @@ class BaseFDPFAlgo: public BaseAlgo timer_initialize_ += timer.duration(); } - virtual void solve(LinearSolver& linear_solver, RealVect & b){ auto timer = CustTimer(); diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp index 5e7a2a9a..b7c66cd5 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp @@ -29,7 +29,7 @@ class LS2G_API GaussSeidelSynchAlgo final: public GaussSeidelAlgo const Eigen::SparseMatrix & Ybus, Eigen::Ref pv, Eigen::Ref pq - ); + ) override; private: // no copy allowed diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index f9a84405..800b3ea7 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -26,7 +26,7 @@ namespace ls2g { * not template parameters, so the same binary can switch strategy at run time. */ template -class NRAlgo : public BaseAlgo +class NRAlgo final : public BaseAlgo { public: NRAlgo() noexcept : @@ -61,7 +61,6 @@ class NRAlgo : public BaseAlgo return _system.J(); } - virtual Eigen::SparseMatrix get_J_python() const { Eigen::SparseMatrix res = get_J(); return res; diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 3b4f5a70..a44d96d8 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -877,9 +877,9 @@ class NRSystem // ----- NR iteration primitives ----------------------------------------------- - virtual RealVect mismatch() const; - virtual void apply_step(const RealVect& dx); - virtual real_type mismatch_sq_norm_at(const RealVect& dx) const; + RealVect mismatch() const; + void apply_step(const RealVect& dx); + real_type mismatch_sq_norm_at(const RealVect& dx) const; // ----- Housekeeping ---------------------------------------------------------- diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index d50b9b31..4ab8e2bd 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -51,8 +51,8 @@ template class LS2G_API NoScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const final {return ScalingPolicyType::NoScaling;} - virtual real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) final + virtual ScalingPolicyType type() const override final {return ScalingPolicyType::NoScaling;} + virtual real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) override final { return 1.; } @@ -63,8 +63,8 @@ template class LS2G_API MaxVoltageChangeScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const final {return ScalingPolicyType::MaxVoltageChange;} - virtual real_type scale(const NRSystem& system, const RealVect & F) final + virtual ScalingPolicyType type() const override final {return ScalingPolicyType::MaxVoltageChange;} + virtual real_type scale(const NRSystem& system, const RealVect & F) override final { real_type alpha = static_cast(1.0); // max_abs_dtheta / max_abs_dvm account for both the base block and any @@ -96,9 +96,9 @@ template class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const final {return ScalingPolicyType::LineSearch;} + virtual ScalingPolicyType type() const override final {return ScalingPolicyType::LineSearch;} - virtual real_type scale(const NRSystem& system, const RealVect & F) final + virtual real_type scale(const NRSystem& system, const RealVect & F) override final { // Current merit (||mismatch(x)||^2 before the step). By the time scale() // runs, F has already been overwritten in place by the linear solve @@ -151,9 +151,9 @@ template class IwamotoScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const final {return ScalingPolicyType::Iwamoto;} + virtual ScalingPolicyType type() const override final {return ScalingPolicyType::Iwamoto;} - virtual real_type scale(const NRSystem& system, const RealVect & F) final + virtual real_type scale(const NRSystem& system, const RealVect & F) override final { // g0 = ||mismatch(x)||^2 (current state, BEFORE the step) -- see the // identical note in LineSearchScalingPolicy::scale: F is already dx by From 960cc6d1f59a1ce63c1b2b42548373e6f6c51e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:39:30 +0000 Subject: [PATCH 109/166] const-correctness: getters, and Eigen::Ref on read-only setters LSGrid trivial getters (get_turnedoff_gen_pv, get_init_vm_pu, get_sn_mva, get_bus_load/gen/svc/shunt/sgen/storage) read state without mutating it; mark them const. Same for HvdcLineContainer's get_qmin_or/ex, get_qmax_or/ex, which forward to ConverterStationContainer's already-const get_qmin/get_qmax. GeneratorContainer::get_qmin/get_qmax were an unused, non-const duplicate of the already-const get_min_q/get_max_q (identical body, no callers anywhere in src/ or the Python bindings); removed rather than just const-qualified, since keeping dead duplicate code isn't the goal here. get_slack_weights_solver looked like a candidate too, but it actually writes bus_slack_weight_ as a side effect consumed later by set_p_slack, so it stays non-const. LSGrid::update_gens_p/update_sgens_p/update_gens_v/update_loads_p/ update_loads_q/update_storages_p/update_slack_weights and the shared update_continuous_values helper only read their Eigen::Ref parameters; switch them to Eigen::Ref, matching the idiom update_topo already uses in the same file. Same fix for GeneratorContainer::update_slack_weights's could_be_slack parameter. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- src/core/LSGrid.cpp | 24 ++++----- src/core/LSGrid.hpp | 50 +++++++++---------- .../element_container/GeneratorContainer.cpp | 2 +- .../element_container/GeneratorContainer.hpp | 5 +- .../element_container/HvdcLineContainer.hpp | 8 +-- 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 9a5ba7bf..ecc9eb14 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -1592,38 +1592,38 @@ void LSGrid::remove_gen_slackbus(int gen_id){ } /** GRID2OP SPECIFIC REPRESENTATION **/ -void LSGrid::update_gens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_gens_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_gen); } -void LSGrid::update_sgens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_sgens_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_sgen); } -void LSGrid::update_gens_v(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_gens_v(Eigen::Ref > has_changed, + Eigen::Ref > new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_v_gen); } -void LSGrid::update_loads_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_loads_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_load); } -void LSGrid::update_loads_q(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_loads_q(Eigen::Ref > has_changed, + Eigen::Ref > new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_q_load); } -void LSGrid::update_storages_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_storages_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_storage); } diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 2affb39f..2e648fca 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -196,8 +196,8 @@ class LS2G_API LSGrid final algo_controler_.dc_algo_controler().has_pv_changed(); generators_.turnedoff_pv(algo_controler_); } - bool get_turnedoff_gen_pv() {return generators_.get_turnedoff_gen_pv();} - void update_slack_weights(Eigen::Ref > could_be_slack){ + bool get_turnedoff_gen_pv() const {return generators_.get_turnedoff_gen_pv();} + void update_slack_weights(Eigen::Ref > could_be_slack){ generators_.update_slack_weights(could_be_slack, algo_controler_); } void update_slack_weights_by_id(Eigen::Ref slack_ids){ @@ -302,9 +302,9 @@ class LS2G_API LSGrid final // All methods to init this data model, all need to be pair unit when applicable void init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, const Eigen::Ref & bus_vn_kv, int nb_line, int nb_trafo); void set_init_vm_pu(real_type init_vm_pu) {init_vm_pu_ = init_vm_pu; } - real_type get_init_vm_pu() {return init_vm_pu_;} + real_type get_init_vm_pu() const {return init_vm_pu_;} void set_sn_mva(real_type sn_mva) {sn_mva_ = sn_mva; } - real_type get_sn_mva() {return sn_mva_;} + real_type get_sn_mva() const {return sn_mva_;} void init_powerlines(const Eigen::Ref & branch_r, const Eigen::Ref & branch_x, @@ -869,7 +869,7 @@ class LS2G_API LSGrid final } void change_p_load(int load_id, real_type new_p) {loads_.change_p_nothrow(load_id, new_p, algo_controler_); } void change_q_load(int load_id, real_type new_q) {loads_.change_q_nothrow(load_id, new_q, algo_controler_); } - int get_bus_load(int load_id) {return loads_.get_bus(load_id).cast_int();} + int get_bus_load(int load_id) const {return loads_.get_bus(load_id).cast_int();} //generator void deactivate_gen(int gen_id) {generators_.deactivate(gen_id, algo_controler_); } @@ -889,7 +889,7 @@ class LS2G_API LSGrid final void change_p_gen(int gen_id, real_type new_p) {generators_.change_p_nothrow(gen_id, new_p, algo_controler_); } void change_q_gen(int gen_id, real_type new_q) {generators_.change_q_nothrow(gen_id, new_q, algo_controler_); } void change_v_gen(int gen_id, real_type new_v_pu) {generators_.change_v_nothrow(gen_id, new_v_pu, algo_controler_); } - int get_bus_gen(int gen_id) {return generators_.get_bus(gen_id).cast_int();} + int get_bus_gen(int gen_id) const {return generators_.get_bus(gen_id).cast_int();} //static var compensator (SVC) void deactivate_svc(int svc_id) {svcs_.deactivate(svc_id, algo_controler_); } @@ -900,7 +900,7 @@ class LS2G_API LSGrid final void change_bus_svc_python(int svc_id, int new_gridmodel_bus_id) { change_bus_svc(svc_id, GridModelBusId(new_gridmodel_bus_id)); } - int get_bus_svc(int svc_id) {return svcs_.get_bus(svc_id).cast_int();} + int get_bus_svc(int svc_id) const {return svcs_.get_bus(svc_id).cast_int();} void set_svc_names(const std::vector & names){ GenericContainer::check_size(names, svcs_.nb(), "set_svc_names"); svcs_.set_names(names); @@ -922,7 +922,7 @@ class LS2G_API LSGrid final } void change_p_shunt(int shunt_id, real_type new_p) {shunts_.change_p_nothrow(shunt_id, new_p, algo_controler_); } void change_q_shunt(int shunt_id, real_type new_q) {shunts_.change_q_nothrow(shunt_id, new_q, algo_controler_); } - int get_bus_shunt(int shunt_id) {return shunts_.get_bus(shunt_id).cast_int();} + int get_bus_shunt(int shunt_id) const {return shunts_.get_bus(shunt_id).cast_int();} //static gen void deactivate_sgen(int sgen_id) {sgens_.deactivate(sgen_id, algo_controler_); } @@ -940,7 +940,7 @@ class LS2G_API LSGrid final } void change_p_sgen(int sgen_id, real_type new_p) {sgens_.change_p_nothrow(sgen_id, new_p, algo_controler_); } void change_q_sgen(int sgen_id, real_type new_q) {sgens_.change_q_nothrow(sgen_id, new_q, algo_controler_); } - int get_bus_sgen(int sgen_id) {return sgens_.get_bus(sgen_id).cast_int();} + int get_bus_sgen(int sgen_id) const {return sgens_.get_bus(sgen_id).cast_int();} //storage units void deactivate_storage(int storage_id) {storages_.deactivate(storage_id, algo_controler_); } @@ -960,7 +960,7 @@ class LS2G_API LSGrid final storages_.change_p_nothrow(storage_id, new_p, algo_controler_); } void change_q_storage(int storage_id, real_type new_q) {storages_.change_q_nothrow(storage_id, new_q, algo_controler_); } - int get_bus_storage(int storage_id) {return storages_.get_bus(storage_id).cast_int();} + int get_bus_storage(int storage_id) const {return storages_.get_bus(storage_id).cast_int();} //deactivate a powerline (disconnect it) void deactivate_dcline(int dcline_id) {hvdc_lines_.deactivate(dcline_id, algo_controler_); } @@ -1563,26 +1563,26 @@ class LS2G_API LSGrid final // part dedicated to grid2op backend, optimized for grid2op data representation (for speed) // this is not recommended to use it outside of its intended usage within grid2op ! - void update_gens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_sgens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_gens_v(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_loads_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_loads_q(Eigen::Ref > has_changed, - Eigen::Ref > new_values); + void update_gens_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values); + void update_sgens_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values); + void update_gens_v(Eigen::Ref > has_changed, + Eigen::Ref > new_values); + void update_loads_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values); + void update_loads_q(Eigen::Ref > has_changed, + Eigen::Ref > new_values); /** * Update the topology based on the topology vector id. - * + * * The new_values are given in LocalBusId (-1, 1, 2 etc.) and not in * SolverBusId nor GridModelBusId */ void update_topo(Eigen::Ref > has_changed, Eigen::Ref > new_values); - void update_storages_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); + void update_storages_p(Eigen::Ref > has_changed, + Eigen::Ref > new_values); void set_load_pos_topo_vect(Eigen::Ref load_pos_topo_vect) { @@ -1940,8 +1940,8 @@ class LS2G_API LSGrid final optimization for grid2op **/ template - void update_continuous_values(Eigen::Ref > & has_changed, - Eigen::Ref > & new_values, + void update_continuous_values(Eigen::Ref > has_changed, + Eigen::Ref > new_values, T fun) { // new_values is indexed by has_changed's length below; a shorter new_values diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 46799939..b6595ae3 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -539,7 +539,7 @@ void GeneratorContainer::set_q( } void GeneratorContainer::update_slack_weights( - Eigen::Ref > could_be_slack, + Eigen::Ref > could_be_slack, DualAlgoControl & solver_control) { const int nb_gen = nb(); diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 3b2b2980..cef7ec79 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -198,14 +198,11 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter turnedoff_gen_pv_=true; // turned off generators are pv. This is the default. } bool get_turnedoff_gen_pv() const {return turnedoff_gen_pv_;} - void update_slack_weights(Eigen::Ref > could_be_slack, + void update_slack_weights(Eigen::Ref > could_be_slack, DualAlgoControl & solver_control); void update_slack_weights_by_id(Eigen::Ref gen_slack_id, DualAlgoControl & solver_control); - real_type get_qmin(int gen_id) {return min_q_.coeff(gen_id);} - real_type get_qmax(int gen_id) {return max_q_.coeff(gen_id);} - // ---- remote voltage control -------------------------------------------- // grid bus id whose voltage this generator regulates (== its own bus for // ordinary local control). A remote-regulating gen does NOT join the PV diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index 61174ce1..bb6e5170 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -266,10 +266,10 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer Date: Tue, 14 Jul 2026 20:18:14 +0000 Subject: [PATCH 110/166] Drop redundant virtual alongside override/final; enable modernize-use-override virtual is implied by override, and by final on an actual override, so keeping it was pure noise once every override/final gap was filled -- codebase-wide sweep drops it wherever override or final is already present on the same declaration (root/first-declaration virtuals that use final without overriding anything correctly keep virtual, since final alone does not make a function virtual). Also fixed 5 more genuine override gaps this surfaced via -Wsuggest-override (temporarily enabled to audit): OneSideContainer::reconnect_connected_buses, OneSideContainer_PQ::gen_p_per_bus, TwoSidesContainer::reconnect_connected_buses, and TwoSidesContainer_rxh_A::fillBf_for_PTDF/reconnect_connected_buses were all overriding a GenericContainer virtual without the virtual keyword at all, which is legal C++ but made them invisible to the earlier manual sweep (which started matching from occurrences of the word "virtual"). CI/build changes, now that the above makes them low-noise: - .clang-tidy: add modernize-use-override (report-only, same job) and misc-const-correctness (~5 min over src/core, real const-correctness findings). modernize-use-override went from ~80% "virtual is redundant" noise to ~40 residual warnings (destructors not annotated, and a few intentional override+final pairs kept for defensive compile-time checking) once the redundant virtual was gone. - src/core/CMakeLists.txt: add -Wsuggest-override -Wnon-virtual-dtor as real (gating, not report-only) warnings on lightsim2grid_core -- both supported identically by GCC and Clang, clean as of this commit, so a future regression fails the build instead of waiting for the advisory clang-tidy report to be read. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- .clang-tidy | 27 +++++++++- src/core/CMakeLists.txt | 10 ++++ .../batch_algorithm/ContingencyAnalysis.hpp | 2 +- src/core/batch_algorithm/TimeSeries.hpp | 2 +- .../ConverterStationContainer.hpp | 4 +- .../element_container/GeneratorContainer.hpp | 8 +-- .../element_container/HvdcLineContainer.hpp | 12 ++--- src/core/element_container/LoadContainer.hpp | 4 +- .../element_container/OneSideContainer.hpp | 4 +- .../element_container/OneSideContainer_PQ.hpp | 16 +++--- .../OneSideContainer_forBranch.hpp | 10 ++-- src/core/element_container/SGenContainer.hpp | 4 +- src/core/element_container/ShuntContainer.hpp | 18 +++---- .../element_container/StorageContainer.hpp | 4 +- src/core/element_container/SvcContainer.hpp | 4 +- src/core/element_container/TrafoContainer.hpp | 20 ++++---- .../element_container/TwoSidesContainer.hpp | 6 +-- .../TwoSidesContainer_rxh_A.hpp | 20 ++++---- src/core/powerflow_algorithm/BaseDCAlgo.hpp | 21 ++++---- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 5 +- .../powerflow_algorithm/GaussSeidelAlgo.hpp | 3 +- src/core/powerflow_algorithm/NRAlgo.hpp | 51 +++++++++---------- .../powerflow_algorithm/ScalingPolicies.hpp | 20 ++++---- 23 files changed, 151 insertions(+), 124 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index c6901330..908ddc88 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -3,7 +3,28 @@ # family targets exactly the class of bug found in the Eigen unnecessary-copy # audit (by-value Eigen/STL params, const-ref-to-value copies, ...); # readability-const-return-type catches `const T foo()` return types that -# block move semantics on the caller side. +# block move semantics on the caller side. misc-const-correctness covers the +# same ground as the manual const-correctness sweep (see the element_container +# / LSGrid commit it landed alongside): local variables that are never +# reassigned but aren't declared const. Runs in ~5 minutes over src/core as of +# this writing (own measurement, GCC 13 / clang-tidy 18) -- noticeably slower +# than the other checks here, but still well within the report-only job's +# budget. +# +# modernize-use-override: this codebase used to keep the `virtual` keyword on +# overrides alongside `override`/`final`, which this check flags as +# redundant on every single one of them -- ~80% pure noise against that +# style in an initial run. Rather than suppress the check, the redundant +# `virtual` was dropped codebase-wide (virtual is implied by `override`, and +# by `final` on an actual override) as part of the same final/override sweep, +# bringing this down to ~40 residual warnings, all in two explainable +# buckets: (1) destructors not annotated `override`/`final` -- valid C++ to +# add, but this codebase doesn't follow that particular style and it wasn't +# worth changing here; (2) `override final` used together for defensive +# compile-time checking (marks BOTH "this really overrides something" and +# "no further overrides") -- the check prefers `final` alone since it +# implies `override`, but the pair is intentional in a few places and left +# as-is. # # Known false-positive class: performance-unnecessary-value-param flags every # `Eigen::Ref<...>` parameter passed by value (the idiom used throughout this @@ -14,7 +35,9 @@ Checks: > -*, performance-*, - readability-const-return-type + readability-const-return-type, + misc-const-correctness, + modernize-use-override WarningsAsErrors: '' # Only report on our own headers (src/core, src/bindings) -- clang-tidy's # default (empty) header filter would otherwise only ever look at the diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 38302e0c..21ca0dc4 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -374,6 +374,16 @@ if(USE_O3_OPTIM) endif() if(NOT MSVC) + # Real (non-report-only) warnings, unlike the clang-tidy job: both are + # supported identically by GCC and Clang, cheap to check, and (as of this + # writing) clean on lightsim2grid_core -- so any future regression fails + # the build instead of waiting to be noticed in the advisory clang-tidy + # report. -Wsuggest-override catches a virtual override missing the + # `override`/`final` keyword; -Wnon-virtual-dtor catches a base class + # with virtual functions but a non-virtual (hence unsafe-to-delete-through- + # base-pointer) destructor. + target_compile_options(lightsim2grid_core PRIVATE -Wsuggest-override -Wnon-virtual-dtor) + if(USE_O3_OPTIM) target_compile_options(lightsim2grid_core PRIVATE -O3) endif() diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 3e9cb542..b60395bf 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -84,7 +84,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch } // utilities to remove defaults to simulate (TODO) - virtual void clear() override { + void clear() override { BaseBatchSolverSynch::clear(); _li_defaults.clear(); _li_coeffs.clear(); diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index 4b4b18c8..54c1663c 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -58,7 +58,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch const int max_iter, const real_type tol); - virtual void clear() override { + void clear() override { BaseBatchSolverSynch::clear(); _Sbuses = CplxMat(); _status = 1; diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index 3c7ac3fc..4b3beb23 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -148,7 +148,7 @@ class LS2G_API ConverterStationContainer final : public OneSideContainer_PQ, pub const SolverBusIdVect & id_grid_to_solver, bool ac, const std::vector & skip_p) const; - virtual void fillpv(std::vector& bus_pv, + void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, const SolverBusIdVect & slack_bus_id_solver, const SolverBusIdVect & id_grid_to_solver) const override; @@ -166,7 +166,7 @@ class LS2G_API ConverterStationContainer final : public OneSideContainer_PQ, pub void set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const; protected: - virtual void _compute_results( + void _compute_results( const Eigen::Ref & Va, const Eigen::Ref & Vm, const Eigen::Ref & V, diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index cef7ec79..7f7a5838 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -151,7 +151,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter return res_gen_id; } - virtual void _compute_results( + void _compute_results( const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, @@ -184,7 +184,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter RealVect get_slack_weights_solver(size_t nb_bus_solver, const SolverBusIdVect & id_grid_to_solver); GlobalBusIdVect get_slack_bus_id() const; - virtual void set_p_slack(const RealVect& node_mismatch, const SolverBusIdVect & id_grid_to_solver) override; + void set_p_slack(const RealVect& node_mismatch, const SolverBusIdVect & id_grid_to_solver) override; // modification void turnedoff_no_pv(DualAlgoControl & solver_control){ @@ -254,8 +254,8 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter void change_v(int gen_id, real_type new_v_pu, DualAlgoControl & solver_control); void change_v_nothrow(int gen_id, real_type new_v_pu, DualAlgoControl & solver_control); - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; - virtual void fillpv(std::vector& bus_pv, + void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, const SolverBusIdVect & slack_bus_id_solver, const SolverBusIdVect & id_grid_to_solver) const override; diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index bb6e5170..efd721e4 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -211,12 +211,12 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer > & /*res*/) const override { + void get_graph(std::vector > & /*res*/) const override { // for buses only connected through a hvdc line, i don't add them // they are not in the same "connected component" } @@ -232,7 +232,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & busbar_in_main_component) override { + void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override { const int nb_el = nb(); DualAlgoControl unused_solver_control; const GlobalBusIdVect & bus_side_1_id_ = get_buses_side_1(); @@ -345,9 +345,9 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer& bus_pv, + void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, const SolverBusIdVect & slack_bus_id_solver, const SolverBusIdVect & id_grid_to_solver) const override { @@ -355,7 +355,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer > & /*Bp*/, + void fillBp_Bpp(std::vector > & /*Bp*/, std::vector > & /*Bpp*/, const SolverBusIdVect & /*id_grid_to_solver*/, real_type /*sn_mva*/, diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 64169182..280edc98 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -80,10 +80,10 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA reset_results(); } - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: - virtual void _compute_results(const Eigen::Ref & /*Va*/, + void _compute_results(const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 4ea43909..93c377ab 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -175,7 +175,7 @@ class OneSideContainer : public GenericContainer return bus_id_.as_eigen(); } - void reconnect_connected_buses(SubstationContainer & substation) const{ + void reconnect_connected_buses(SubstationContainer & substation) const override{ const int nb_els = nb(); for(int el_id = 0; el_id < nb_els; ++el_id) { @@ -193,7 +193,7 @@ class OneSideContainer : public GenericContainer } } - virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override final { + void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override final { const int nb_el = nb(); DualAlgoControl unused_solver_control; for(int el_id = 0; el_id < nb_el; ++el_id) diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 9ffd5b16..ad84ddd1 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -62,7 +62,7 @@ class OneSideContainer_PQ : public OneSideContainer Eigen::Ref get_target_p() const {return target_p_mw_;} // base function that can be called - void gen_p_per_bus(std::vector & res) const + void gen_p_per_bus(std::vector & res) const override { const int nb_gen = nb(); for(int sgen_id = 0; sgen_id < nb_gen; ++sgen_id) @@ -196,11 +196,11 @@ class OneSideContainer_PQ : public OneSideContainer } protected: - virtual void _reset_results() override { + void _reset_results() override { // nothing to do by default, as this class should be used as template for "one side" (eg loads or generators) // elements }; - virtual void _compute_results(const Eigen::Ref & /*Va*/, + void _compute_results(const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, @@ -211,7 +211,7 @@ class OneSideContainer_PQ : public OneSideContainer // elements }; - virtual bool _deactivate(int el_id, DualAlgoControl & solver_control) override { + bool _deactivate(int el_id, DualAlgoControl & solver_control) override { if(status_[el_id]){ solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -219,7 +219,7 @@ class OneSideContainer_PQ : public OneSideContainer } return false; }; - virtual bool _reactivate(int el_id, DualAlgoControl & solver_control) override { + bool _reactivate(int el_id, DualAlgoControl & solver_control) override { if(!status_[el_id]){ solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -227,7 +227,7 @@ class OneSideContainer_PQ : public OneSideContainer } return false; }; - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { + bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { if(bus_id_(el_id) != new_bus_id){ solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -235,12 +235,12 @@ class OneSideContainer_PQ : public OneSideContainer } return false; }; - virtual void _change_p(int el_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override { + void _change_p(int el_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override { if (abs(target_p_mw_(el_id) - new_p) > _tol_equal_float) { solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); } }; - virtual void _change_q(int el_id, real_type new_q, bool /*my_status*/,DualAlgoControl & solver_control) override { + void _change_q(int el_id, real_type new_q, bool /*my_status*/,DualAlgoControl & solver_control) override { if (abs(target_q_mvar_(el_id) - new_q) > _tol_equal_float) { solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); } diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index e055f0d4..9d9ef3ab 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -108,11 +108,11 @@ class OneSideContainer_ForBranch : public OneSideContainer } protected: - virtual void _reset_results() override { + void _reset_results() override { // nothing to do by default, as this class should be used as template for "branch" (eg lines or trafos) // elements }; - virtual void _compute_results(const Eigen::Ref & /*Va*/, + void _compute_results(const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, @@ -124,7 +124,7 @@ class OneSideContainer_ForBranch : public OneSideContainer // elements }; - virtual bool _deactivate(int el_id, DualAlgoControl & solver_control) override { + bool _deactivate(int el_id, DualAlgoControl & solver_control) override { if(status_[el_id]){ solver_control.ac_algo_controler().tell_ybus_some_coeffs_zero(); solver_control.dc_algo_controler().tell_ybus_some_coeffs_zero(); solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); @@ -134,7 +134,7 @@ class OneSideContainer_ForBranch : public OneSideContainer } return false; }; - virtual bool _reactivate(int el_id, DualAlgoControl & solver_control) override { + bool _reactivate(int el_id, DualAlgoControl & solver_control) override { if(!status_[el_id]){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); // solver_control.ac_algo_controler().tell_recompute_sbus(); solver_control.dc_algo_controler().tell_recompute_sbus(); // only for trafo in DC @@ -144,7 +144,7 @@ class OneSideContainer_ForBranch : public OneSideContainer } return false; }; - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { + bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { const GridModelBusId & bus_me_id = bus_id_(el_id); if(bus_me_id != new_bus_id) { diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index 4cbedad8..c395fe74 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -86,10 +86,10 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA const Eigen::Ref & sgen_bus_id ); - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: - virtual void _compute_results( + void _compute_results( const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index 67f467f1..e2fe19d5 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -71,19 +71,19 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator static ShuntContainer load_binary(const std::string & path); static const char * binary_type_tag() { return "ShuntContainer"; } // written into / checked against the binary file header - virtual void fillYbus(std::vector > & res, + void fillYbus(std::vector > & res, bool ac, const SolverBusIdVect & id_grid_to_solver, real_type sn_mva) const override; - virtual void fillBp_Bpp(std::vector > & Bp, + void fillBp_Bpp(std::vector > & Bp, std::vector > & Bpp, const SolverBusIdVect & id_grid_to_solver, real_type sn_mva, FDPFMethod xb_or_bx) const override; - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; // in DC i need that + void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; // in DC i need that protected: - virtual void _change_p(int shunt_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override + void _change_p(int shunt_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override { if(abs(target_p_mw_(shunt_id) - new_p) > _tol_equal_float){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); @@ -91,14 +91,14 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator } } - virtual void _change_q(int shunt_id, real_type new_q, bool /*my_status*/, DualAlgoControl & solver_control) override + void _change_q(int shunt_id, real_type new_q, bool /*my_status*/, DualAlgoControl & solver_control) override { if(abs(target_q_mvar_(shunt_id) - new_q) > _tol_equal_float){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); } } - virtual bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { + bool _change_bus(int el_id, GridModelBusId new_bus_id, DualAlgoControl & solver_control, int /*nb_bus*/) override { if(bus_id_(el_id) != new_bus_id){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -107,7 +107,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator } return false; }; - virtual bool _deactivate(int el_id, DualAlgoControl & solver_control) override { + bool _deactivate(int el_id, DualAlgoControl & solver_control) override { if(status_[el_id]){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -116,7 +116,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator } return false; }; - virtual bool _reactivate(int el_id, DualAlgoControl & solver_control) override { + bool _reactivate(int el_id, DualAlgoControl & solver_control) override { if(!status_[el_id]){ solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); solver_control.ac_algo_controler().tell_one_el_changed_bus(); solver_control.dc_algo_controler().tell_one_el_changed_bus(); @@ -126,7 +126,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator return false; }; - virtual void _compute_results( + void _compute_results( const Eigen::Ref & Va, const Eigen::Ref & Vm, const Eigen::Ref & V, diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index a855cf67..63a98138 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -82,10 +82,10 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat reset_results(); } - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: - virtual void _compute_results(const Eigen::Ref & /*Va*/, + void _compute_results(const Eigen::Ref & /*Va*/, const Eigen::Ref & /*Vm*/, const Eigen::Ref & /*V*/, const SolverBusIdVect & /*id_grid_to_solver*/, diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index c236388a..2750a628 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -136,12 +136,12 @@ class LS2G_API SvcContainer final : public OneSideContainer_PQ, public IteratorA void set_voltage_control_q(int svc_id, real_type q_mvar) {res_q_(svc_id) = q_mvar;} // solver interface - virtual void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; void get_vm_for_dc(RealVect & Vm); void set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const; protected: - virtual void _compute_results( + void _compute_results( const Eigen::Ref & Va, const Eigen::Ref & Vm, const Eigen::Ref & V, diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 879c85b6..22df07b2 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -213,12 +213,12 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A:: _deactivate(el_id, solver_control); if(has_been_changed){ // TODO speed: only when dc_x_tau_shift_ is not 0, but be carefull, dc_x_tau_shift_ can be changed later @@ -227,7 +227,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A:: _reactivate(el_id, solver_control); if(has_been_changed){ // TODO speed: only when dc_x_tau_shift_ is not 0, but be carefull, dc_x_tau_shift_ can be changed later @@ -236,7 +236,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A & side1_changed, @@ -332,17 +332,17 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A get_bus_id_side_1_numpy() const {return side_1_.get_bus_id_numpy();} Eigen::Ref get_bus_id_side_2_numpy() const {return side_2_.get_bus_id_numpy();} - void reconnect_connected_buses(SubstationContainer & substation) const{ + void reconnect_connected_buses(SubstationContainer & substation) const override{ side_1_.reconnect_connected_buses(substation); side_2_.reconnect_connected_buses(substation); // TODO think about status here ! @@ -177,7 +177,7 @@ class TwoSidesContainer : public GenericContainer // (in this case this can do nothing if side_1 or side_2 is not connected) } - virtual void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override { + void disconnect_if_not_in_main_component(std::vector & busbar_in_main_component) override { const int nb_el = nb(); DualAlgoControl unused_solver_control; const GlobalBusIdVect & bus_side_1_id_ = get_buses_side_1(); @@ -207,7 +207,7 @@ class TwoSidesContainer : public GenericContainer } } } - virtual void nb_line_end(std::vector & res) const override final { + void nb_line_end(std::vector & res) const override final { const int nb_el = nb(); for(int el_id = 0; el_id < nb_el; ++el_id){ // don't do anything if the element is disconnected diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 10953eb3..3764eb52 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -406,7 +406,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer compute_amps_after_all_set(); } - virtual void get_graph(std::vector > & res) const override + void get_graph(std::vector > & res) const override { const auto my_size = nb(); for(size_t el_id = 0; el_id < my_size; ++el_id){ @@ -445,7 +445,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer const std::vector& get_status_global() const { return status_global_; } // solver interface - virtual void fillYbus( + void fillYbus( std::vector > & res, bool ac, const SolverBusIdVect & id_grid_to_solver, @@ -532,7 +532,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer // Real DC equivalent of fillYbus (DC branch): pushes the real susceptance coefficients // directly into a real triplet list (no complex temporary). - virtual void fillBdc( + void fillBdc( std::vector > & res, const SolverBusIdVect & id_grid_to_solver, real_type /*sn_mva*/) const override @@ -571,7 +571,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer } } - virtual void fillBp_Bpp(std::vector > & Bp, + void fillBp_Bpp(std::vector > & Bp, std::vector > & Bpp, const SolverBusIdVect & id_grid_to_solver, real_type /*sn_mva*/, @@ -673,7 +673,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer const SolverBusIdVect & id_grid_to_solver, real_type /*sn_mva*/, int nb_powerline, - bool transpose) const + bool transpose) const override { const size_t nb_line = nb(); const std::vector & side1_conn = side_1_.get_status(); @@ -683,7 +683,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer if(!status_global_[line_id]) continue; if(!side1_conn[line_id]) continue; if(!side2_conn[line_id]) continue; - + // get the from / to bus id GlobalBusId bus_or_id_me = get_bus_side_1(line_id); if(bus_or_id_me.cast_int() == _deactivated_bus_id){ @@ -735,7 +735,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer } // gridmodel utilities - void reconnect_connected_buses(SubstationContainer & substation) const{ + void reconnect_connected_buses(SubstationContainer & substation) const override{ const size_t nb_els = nb(); const std::vector& status_side_1_ = get_status_side_1(); const std::vector& status_side_2_ = get_status_side_2(); @@ -875,7 +875,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer res_a_side_2_ = RealVect(nb()); // in kA } - virtual bool _deactivate(int el_id, DualAlgoControl & solver_control) override { + bool _deactivate(int el_id, DualAlgoControl & solver_control) override { if(status_global_[el_id]){ // update solver control solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); @@ -886,7 +886,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer } return false; } - virtual bool _reactivate(int el_id, DualAlgoControl & solver_control) override { + bool _reactivate(int el_id, DualAlgoControl & solver_control) override { if(!status_global_[el_id]){ // update solver control solver_control.ac_algo_controler().tell_recompute_ybus(); solver_control.dc_algo_controler().tell_recompute_ybus(); @@ -985,7 +985,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer * This requires that status_global, status of side 1 and status of side 2 * are set correctly */ - virtual void _update_effective_coeffs_one_el(int el_id) override { + void _update_effective_coeffs_one_el(int el_id) override { const bool s1 = side_1_.get_status(el_id); const bool s2 = side_2_.get_status(el_id); if (!status_global_[el_id] || (!s1 && !s2)) { diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index 0f60210b..a1990585 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -40,8 +40,8 @@ class BaseDCAlgo final: public BaseAlgo virtual ~BaseDCAlgo() noexcept = default; - virtual void reset() override final; - virtual void reset_timer() override final{ + void reset() override final; + void reset_timer() override final{ BaseAlgo::reset_timer(); timer_refactor_ = 0.; timer_factor_ = 0.; @@ -53,7 +53,7 @@ class BaseDCAlgo final: public BaseAlgo timer_lodf_ = 0.; } - virtual TimerJac get_timers_jacobian() const override final + TimerJac get_timers_jacobian() const override final { TimerJac res; res.timer_Fx_ = timer_Fx_; @@ -68,7 +68,7 @@ class BaseDCAlgo final: public BaseAlgo return res; } - virtual TimerPTDFLODFType get_timers_ptdf_lodf() const override final + TimerPTDFLODFType get_timers_ptdf_lodf() const override final { TimerPTDFLODFType res = { timer_ptdf_, @@ -81,7 +81,6 @@ class BaseDCAlgo final: public BaseAlgo // Native real-valued DC power flow: solves `Bbus . theta = Pbus`. // (the DC solver does not implement the complex `compute_pf`: it inherits the throwing // default from BaseAlgo, since every DC code path goes through `compute_pf_dc`) - virtual bool compute_pf_dc(const Eigen::SparseMatrix & Bbus, CplxVect & V, Eigen::Ref Pbus, @@ -91,12 +90,12 @@ class BaseDCAlgo final: public BaseAlgo Eigen::Ref pq ) override final; - virtual RealMat get_ptdf() override final; - virtual RealMat get_lodf(const IntVect & from_bus, + RealMat get_ptdf() override final; + RealMat get_lodf(const IntVect & from_bus, const IntVect & to_bus) override final; - virtual Eigen::SparseMatrix get_bsdf() override final; // TODO BSDF + Eigen::SparseMatrix get_bsdf() override final; // TODO BSDF - virtual void update_internal_Ybus(const Coeff & coeff, bool add) override final{ + void update_internal_Ybus(const Coeff & coeff, bool add) override final{ int row_res = static_cast(coeff.row_id); row_res = mat_bus_id_(row_res); if(row_res == -1) return; @@ -118,8 +117,8 @@ class BaseDCAlgo final: public BaseAlgo // a working copy of dcYbus_noslack_, so the (incrementally maintained) // persistent matrix and the symbolic factorization are left untouched (only a // numeric refactorize is needed). See compute_pf_dc. - virtual bool supports_bus_masking() const override final { return true; } - virtual void set_masked_buses(const std::vector & solver_bus_ids) override final{ + bool supports_bus_masking() const override final { return true; } + void set_masked_buses(const std::vector & solver_bus_ids) override final{ masked_buses_ = solver_bus_ids; } diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 12a3678a..97b42d2d 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -23,7 +23,6 @@ class BaseFDPFAlgo final: public BaseAlgo BaseFDPFAlgo() noexcept :BaseAlgo(true), need_factorize_(true) {} virtual ~BaseFDPFAlgo() noexcept = default; - virtual bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, Eigen::Ref Sbus, @@ -35,7 +34,7 @@ class BaseFDPFAlgo final: public BaseAlgo real_type tol ) override; // requires a gridmodel ! - virtual void reset() override + void reset() override { BaseAlgo::reset(); // solution of the problem @@ -58,7 +57,7 @@ class BaseFDPFAlgo final: public BaseAlgo Eigen::SparseMatrix debug_get_Bpp_python() { return Bpp_;} protected: - virtual void reset_timer() override { + void reset_timer() override { BaseAlgo::reset_timer(); } diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp index 018ee4d1..05d66701 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp @@ -21,12 +21,11 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo virtual ~GaussSeidelAlgo() noexcept = default; // todo can be factorized - virtual Eigen::Ref > get_J() const override { + Eigen::Ref > get_J() const override { throw std::runtime_error("get_J: There is no jacobian in the Gauss Seidel method"); } // todo change the name! - virtual bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, Eigen::Ref Sbus, diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 800b3ea7..d2bbf01f 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -56,7 +56,6 @@ class NRAlgo final : public BaseAlgo // ----- Jacobian accessor --------------------------------------------------- - virtual Eigen::Ref> get_J() const override { return _system.J(); } @@ -70,38 +69,37 @@ class NRAlgo final : public BaseAlgo // bus_id -> Jacobian column of that bus' theta / vm / q unknown (-1 if none). // Only meaningful after a topology has been initialised (i.e. after at least // one compute_pf), same lifetime as get_J. - virtual IntVect get_theta_to_J_col_python() const override { return _to_intvect(_system.theta_to_J_col()); } - virtual IntVect get_vm_to_J_col_python() const override { return _to_intvect(_system.vm_to_J_col()); } - virtual IntVect get_q_to_J_col_python() const override { return _to_intvect(_system.q_to_J_col()); } + IntVect get_theta_to_J_col_python() const override { return _to_intvect(_system.theta_to_J_col()); } + IntVect get_vm_to_J_col_python() const override { return _to_intvect(_system.vm_to_J_col()); } + IntVect get_q_to_J_col_python() const override { return _to_intvect(_system.q_to_J_col()); } // ----- row (equation) -> bus-id converters (NR-only) ----------------------- // bus_id -> Jacobian row of that bus' P / Q mismatch equation (-1 if none). // Same lifetime / semantics as the *_to_J_col converters above. - virtual IntVect get_p_to_J_row_python() const override { return _to_intvect(_system.p_to_J_row()); } - virtual IntVect get_q_to_J_row_python() const override { return _to_intvect(_system.q_to_J_row()); } + IntVect get_p_to_J_row_python() const override { return _to_intvect(_system.p_to_J_row()); } + IntVect get_q_to_J_row_python() const override { return _to_intvect(_system.q_to_J_row()); } // ----- compact (bus, row/col) registration pair lists (NR-only) ------------ // Row/col counterpart of the *_to_J_col / *_to_J_row bus-keyed maps above, // but preserving every registration (see NRSystem::p_buses() etc.). - virtual IntVect get_p_buses_python() const override { return _to_intvect(_system.p_buses()); } - virtual IntVect get_p_rows_python() const override { return _to_intvect(_system.p_rows()); } - virtual IntVect get_q_buses_python() const override { return _to_intvect(_system.q_buses()); } - virtual IntVect get_q_rows_python() const override { return _to_intvect(_system.q_rows()); } - virtual IntVect get_theta_buses_python() const override { return _to_intvect(_system.theta_buses()); } - virtual IntVect get_theta_cols_python() const override { return _to_intvect(_system.theta_cols()); } - virtual IntVect get_vm_buses_python() const override { return _to_intvect(_system.vm_buses()); } - virtual IntVect get_vm_cols_python() const override { return _to_intvect(_system.vm_cols()); } + IntVect get_p_buses_python() const override { return _to_intvect(_system.p_buses()); } + IntVect get_p_rows_python() const override { return _to_intvect(_system.p_rows()); } + IntVect get_q_buses_python() const override { return _to_intvect(_system.q_buses()); } + IntVect get_q_rows_python() const override { return _to_intvect(_system.q_rows()); } + IntVect get_theta_buses_python() const override { return _to_intvect(_system.theta_buses()); } + IntVect get_theta_cols_python() const override { return _to_intvect(_system.theta_cols()); } + IntVect get_vm_buses_python() const override { return _to_intvect(_system.vm_buses()); } + IntVect get_vm_cols_python() const override { return _to_intvect(_system.vm_cols()); } // ----- VoltageControl (remote gen + SVC) converged results ----------------- - virtual RealVect get_controller_q() const override { return _system.controller_q(); } - virtual IntVect get_controller_kind() const override { return _system.controller_kind(); } - virtual IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } - virtual int get_slack_col() const override { return _system.slack_col(); } - virtual real_type get_slack_absorbed() const override { return _system.slack_absorbed(); } + RealVect get_controller_q() const override { return _system.controller_q(); } + IntVect get_controller_kind() const override { return _system.controller_kind(); } + IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } + int get_slack_col() const override { return _system.slack_col(); } + real_type get_slack_absorbed() const override { return _system.slack_absorbed(); } // ----- timers -------------------------------------------------------------- - virtual TimerJac get_timers_jacobian() const override { TimerJac res; @@ -123,7 +121,6 @@ class NRAlgo final : public BaseAlgo // ----- powerflow ----------------------------------------------------------- - virtual bool compute_pf(const Eigen::SparseMatrix& Ybus, CplxVect& V, Eigen::Ref Sbus, @@ -135,11 +132,11 @@ class NRAlgo final : public BaseAlgo real_type tol ) override; - virtual void reset() override; + void reset() override; // ----- bus masking --------------------------------------------------------- - virtual bool supports_bus_masking() const override { return true; } - virtual void set_masked_buses(const std::vector & solver_bus_ids) override { + bool supports_bus_masking() const override { return true; } + void set_masked_buses(const std::vector & solver_bus_ids) override { _system.set_masked_buses(solver_bus_ids); } @@ -186,7 +183,7 @@ class NRAlgo final : public BaseAlgo // ----- AlgoConfig serialization ------------------------------------------- - virtual AlgoConfig get_config() const override { + AlgoConfig get_config() const override { AlgoConfig cfg; cfg.int_params = { static_cast(scaling_policy_->type()), static_cast(refactor_policy_), @@ -201,7 +198,7 @@ class NRAlgo final : public BaseAlgo return cfg; } - virtual void set_config(const AlgoConfig& cfg) override { + void set_config(const AlgoConfig& cfg) override { if (cfg.int_params.size() < 4) throw std::runtime_error("NRAlgo::set_config: int_params must have at least 4 elements"); if (cfg.real_params.size() < 6) throw std::runtime_error("NRAlgo::set_config: real_params must have at least 6 elements"); max_dVa_ = static_cast(cfg.real_params[0]); @@ -243,7 +240,7 @@ class NRAlgo final : public BaseAlgo // } protected: - virtual void reset_timer() override { + void reset_timer() override { BaseAlgo::reset_timer(); timer_factor_ = 0.; timer_refactor_ = 0.; diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index 4ab8e2bd..f031c31c 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -51,8 +51,8 @@ template class LS2G_API NoScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const override final {return ScalingPolicyType::NoScaling;} - virtual real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) override final + ScalingPolicyType type() const override final {return ScalingPolicyType::NoScaling;} + real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) override final { return 1.; } @@ -63,8 +63,8 @@ template class LS2G_API MaxVoltageChangeScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const override final {return ScalingPolicyType::MaxVoltageChange;} - virtual real_type scale(const NRSystem& system, const RealVect & F) override final + ScalingPolicyType type() const override final {return ScalingPolicyType::MaxVoltageChange;} + real_type scale(const NRSystem& system, const RealVect & F) override final { real_type alpha = static_cast(1.0); // max_abs_dtheta / max_abs_dvm account for both the base block and any @@ -96,9 +96,9 @@ template class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const override final {return ScalingPolicyType::LineSearch;} + ScalingPolicyType type() const override final {return ScalingPolicyType::LineSearch;} - virtual real_type scale(const NRSystem& system, const RealVect & F) override final + real_type scale(const NRSystem& system, const RealVect & F) override final { // Current merit (||mismatch(x)||^2 before the step). By the time scale() // runs, F has already been overwritten in place by the linear solve @@ -121,7 +121,7 @@ class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy return alpha; } - // virtual void update(const NRSystem& system, const RealVect & F) final { + // void update(const NRSystem& system, const RealVect & F) final { // F_norm_sq_0_ = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); // } @@ -151,9 +151,9 @@ template class IwamotoScalingPolicy final : public ScalingPolicy { public: - virtual ScalingPolicyType type() const override final {return ScalingPolicyType::Iwamoto;} + ScalingPolicyType type() const override final {return ScalingPolicyType::Iwamoto;} - virtual real_type scale(const NRSystem& system, const RealVect & F) override final + real_type scale(const NRSystem& system, const RealVect & F) override final { // g0 = ||mismatch(x)||^2 (current state, BEFORE the step) -- see the // identical note in LineSearchScalingPolicy::scale: F is already dx by @@ -173,7 +173,7 @@ class IwamotoScalingPolicy final : public ScalingPolicy return mu; } - // virtual void update(const NRSystem& system, const RealVect & F) final { + // void update(const NRSystem& system, const RealVect & F) final { // g0_ = system.mismatch_sq_norm_at(RealVect::Zero(F.size())); // g1_ = system.mismatch_sq_norm_at(F); // } From a7ef12de801c342413488b561a5ee9999c9aed2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:21:33 +0000 Subject: [PATCH 111/166] LSGrid: [[nodiscard]] on public getters Attach [[nodiscard]] to LSGrid's single-line get_* accessors -- pure compile-time documentation that a caller ignoring the return value is almost certainly a bug, at zero runtime cost. Scoped to LSGrid.hpp (the primary public API surface) for this pass rather than also sweeping element_container/ and powerflow_algorithm/: LSGrid's getters are non-virtual, so nodiscard needs no cross-hierarchy consideration, whereas many getters in the other two are virtual overrides where checking every override chain individually would have made this a much larger, slower review for the same category of finding. Left as a natural follow-up. noexcept was evaluated for this same pass and intentionally skipped: unlike nodiscard, a noexcept base virtual forces every override to also be noexcept (the reverse -- a stricter override -- is fine, but the base constrains the whole hierarchy), so auditing it correctly across BaseAlgo/NRAlgo and the element_container hierarchy needs its own, more careful pass rather than a mechanical sweep. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- src/core/LSGrid.hpp | 314 ++++++++++++++++++++++---------------------- 1 file changed, 157 insertions(+), 157 deletions(-) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 2e648fca..8b94fd53 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -150,8 +150,8 @@ class LS2G_API LSGrid final void set_ls_to_orig(const IntVect & ls_to_orig); // set both _ls_to_orig and _orig_to_ls void set_orig_to_ls(const IntVect & orig_to_ls); // set both _orig_to_ls and _ls_to_orig - const IntVect & get_ls_to_orig(void) const {return _ls_to_orig;} - const IntVect & get_orig_to_ls(void) const {return _orig_to_ls;} + [[nodiscard]] const IntVect & get_ls_to_orig(void) const {return _ls_to_orig;} + [[nodiscard]] const IntVect & get_orig_to_ls(void) const {return _orig_to_ls;} double timer_last_ac_pf() const {return timer_last_ac_pf_;} double timer_last_dc_pf() const {return timer_last_dc_pf_;} @@ -183,7 +183,7 @@ class LS2G_API LSGrid final } // retrieve the underlying data (raw class) - const GeneratorContainer & get_generators_as_data() const {return generators_;} + [[nodiscard]] const GeneratorContainer & get_generators_as_data() const {return generators_;} // turned off generators are not pv void turnedoff_no_pv(){ algo_controler_.ac_algo_controler().has_pv_changed(); @@ -196,7 +196,7 @@ class LS2G_API LSGrid final algo_controler_.dc_algo_controler().has_pv_changed(); generators_.turnedoff_pv(algo_controler_); } - bool get_turnedoff_gen_pv() const {return generators_.get_turnedoff_gen_pv();} + [[nodiscard]] bool get_turnedoff_gen_pv() const {return generators_.get_turnedoff_gen_pv();} void update_slack_weights(Eigen::Ref > could_be_slack){ generators_.update_slack_weights(could_be_slack, algo_controler_); } @@ -216,21 +216,21 @@ class LS2G_API LSGrid final algo_controler_.ac_algo_controler().tell_slack_participate_changed(); algo_controler_.dc_algo_controler().tell_slack_participate_changed(); } - int get_reference_slack_bus() const {return _forced_ref_slack_bus_id;} + [[nodiscard]] int get_reference_slack_bus() const {return _forced_ref_slack_bus_id;} - const SGenContainer & get_static_generators_as_data() const {return sgens_;} - const LoadContainer & get_loads_as_data() const {return loads_;} - const LineContainer & get_powerlines_as_data() const {return powerlines_;} - const TrafoContainer & get_trafos_as_data() const {return trafos_;} - const HvdcLineContainer & get_dclines_as_data() const {return hvdc_lines_;} - Eigen::Ref get_bus_vn_kv() const {return substations_.get_bus_vn_kv();} + [[nodiscard]] const SGenContainer & get_static_generators_as_data() const {return sgens_;} + [[nodiscard]] const LoadContainer & get_loads_as_data() const {return loads_;} + [[nodiscard]] const LineContainer & get_powerlines_as_data() const {return powerlines_;} + [[nodiscard]] const TrafoContainer & get_trafos_as_data() const {return trafos_;} + [[nodiscard]] const HvdcLineContainer & get_dclines_as_data() const {return hvdc_lines_;} + [[nodiscard]] Eigen::Ref get_bus_vn_kv() const {return substations_.get_bus_vn_kv();} // per-bus min/max operating voltage (kV), optional: empty if never set void set_bus_voltage_limits(const RealVect & bus_vmin_kv, const RealVect & bus_vmax_kv){ substations_.init_bus_voltage_limits(bus_vmin_kv, bus_vmax_kv); } - Eigen::Ref get_bus_vmin_kv() const {return substations_.get_bus_vmin_kv();} - Eigen::Ref get_bus_vmax_kv() const {return substations_.get_bus_vmax_kv();} + [[nodiscard]] Eigen::Ref get_bus_vmin_kv() const {return substations_.get_bus_vmin_kv();} + [[nodiscard]] Eigen::Ref get_bus_vmax_kv() const {return substations_.get_bus_vmax_kv();} std::tuple assign_slack_to_most_connected(); void consider_only_main_component(); @@ -242,7 +242,7 @@ class LS2G_API LSGrid final powerlines_.set_ignore_status_global(ignore_status_global); trafos_.set_ignore_status_global(ignore_status_global); } - bool get_ignore_status_global() const{ + [[nodiscard]] bool get_ignore_status_global() const{ return powerlines_.get_ignore_status_global(); } /** @@ -253,7 +253,7 @@ class LS2G_API LSGrid final powerlines_.set_synch_status_both_side(synch_status_both_side); trafos_.set_synch_status_both_side(synch_status_both_side); } - bool get_synch_status_both_side() const{ + [[nodiscard]] bool get_synch_status_both_side() const{ return powerlines_.get_synch_status_both_side(); } @@ -290,10 +290,10 @@ class LS2G_API LSGrid final std::vector available_algorithm_names() const { return AlgorithmRegistry::instance().available_algorithm_names(); } - AlgorithmType get_algo_type() const {return _algo.get_type(); } - AlgorithmType get_dc_algo_type() const {return _dc_algo.get_type(); } - const AlgorithmSelector & get_algo() const {return _algo;} - const AlgorithmSelector & get_dc_algo() const {return _dc_algo;} + [[nodiscard]] AlgorithmType get_algo_type() const {return _algo.get_type(); } + [[nodiscard]] AlgorithmType get_dc_algo_type() const {return _dc_algo.get_type(); } + [[nodiscard]] const AlgorithmSelector & get_algo() const {return _algo;} + [[nodiscard]] const AlgorithmSelector & get_dc_algo() const {return _dc_algo;} // do i compute the results (in terms of P,Q,V or loads, generators and flows on lines void deactivate_result_computation(){compute_results_=false;} @@ -302,9 +302,9 @@ class LS2G_API LSGrid final // All methods to init this data model, all need to be pair unit when applicable void init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, const Eigen::Ref & bus_vn_kv, int nb_line, int nb_trafo); void set_init_vm_pu(real_type init_vm_pu) {init_vm_pu_ = init_vm_pu; } - real_type get_init_vm_pu() const {return init_vm_pu_;} + [[nodiscard]] real_type get_init_vm_pu() const {return init_vm_pu_;} void set_sn_mva(real_type sn_mva) {sn_mva_ = sn_mva; } - real_type get_sn_mva() const {return sn_mva_;} + [[nodiscard]] real_type get_sn_mva() const {return sn_mva_;} void init_powerlines(const Eigen::Ref & branch_r, const Eigen::Ref & branch_x, @@ -498,7 +498,7 @@ class LS2G_API LSGrid final void set_substation_names(const std::vector & sub_names){ substations_.init_sub_names(sub_names); } - const std::vector & get_substation_names()const { + [[nodiscard]] const std::vector & get_substation_names()const { return substations_.get_sub_names(); } @@ -524,9 +524,9 @@ class LS2G_API LSGrid final // algo config (scaling/refactor policy params) — also part of StateRes, // see LSGrid::get_state()/set_state() - AlgoConfig get_ac_algo_config() const { return _algo.get_config(); } + [[nodiscard]] AlgoConfig get_ac_algo_config() const { return _algo.get_config(); } void set_ac_algo_config(const AlgoConfig& cfg) { _algo.set_config(cfg); } - AlgoConfig get_dc_algo_config() const { return _dc_algo.get_config(); } + [[nodiscard]] AlgoConfig get_dc_algo_config() const { return _dc_algo.get_config(); } void set_dc_algo_config(const AlgoConfig& cfg) { _dc_algo.set_config(cfg); } template void check_size(const T& my_state) @@ -565,8 +565,8 @@ class LS2G_API LSGrid final algo_controler_.dc_algo_controler().tell_solver_need_reset(); } void tell_ybus_change_sparsity_pattern(){algo_controler_.ac_algo_controler().tell_ybus_change_sparsity_pattern(); algo_controler_.dc_algo_controler().tell_ybus_change_sparsity_pattern();} - const AlgoControl & get_ac_algo_controler() const {return algo_controler_.ac_algo_controler();} - const AlgoControl & get_dc_algo_controler() const {return algo_controler_.dc_algo_controler();} + [[nodiscard]] const AlgoControl & get_ac_algo_controler() const {return algo_controler_.ac_algo_controler();} + [[nodiscard]] const AlgoControl & get_dc_algo_controler() const {return algo_controler_.dc_algo_controler();} // dc powerflow CplxVect dc_pf(const Eigen::Ref & Vinit, @@ -668,23 +668,23 @@ class LS2G_API LSGrid final size_t nb_trafo() const {return trafos_.nb();} // read only data accessor - const SubstationContainer & get_substations() const {return substations_;} - const LineContainer & get_lines() const {return powerlines_;} - const HvdcLineContainer & get_dclines() const {return hvdc_lines_;} - const TrafoContainer & get_trafos() const {return trafos_;} - const GeneratorContainer & get_generators() const {return generators_;} - const LoadContainer & get_loads() const {return loads_;} - const StorageContainer & get_storages() const {return storages_;} - const SGenContainer & get_static_generators() const {return sgens_;} - const SvcContainer & get_svcs() const {return svcs_;} - const ShuntContainer & get_shunts() const {return shunts_;} - const std::vector & get_bus_status() const {return substations_.get_bus_status();} + [[nodiscard]] const SubstationContainer & get_substations() const {return substations_;} + [[nodiscard]] const LineContainer & get_lines() const {return powerlines_;} + [[nodiscard]] const HvdcLineContainer & get_dclines() const {return hvdc_lines_;} + [[nodiscard]] const TrafoContainer & get_trafos() const {return trafos_;} + [[nodiscard]] const GeneratorContainer & get_generators() const {return generators_;} + [[nodiscard]] const LoadContainer & get_loads() const {return loads_;} + [[nodiscard]] const StorageContainer & get_storages() const {return storages_;} + [[nodiscard]] const SGenContainer & get_static_generators() const {return sgens_;} + [[nodiscard]] const SvcContainer & get_svcs() const {return svcs_;} + [[nodiscard]] const ShuntContainer & get_shunts() const {return shunts_;} + [[nodiscard]] const std::vector & get_bus_status() const {return substations_.get_bus_status();} void set_line_names(const std::vector & names){ GenericContainer::check_size(names, powerlines_.nb(), "set_line_names"); powerlines_.set_names(names); } - const std::vector & get_line_names() const {return powerlines_.get_names();} + [[nodiscard]] const std::vector & get_line_names() const {return powerlines_.get_names();} void set_dcline_names(const std::vector & names){ GenericContainer::check_size(names, hvdc_lines_.nb(), "set_dcline_names"); hvdc_lines_.set_names(names); @@ -693,7 +693,7 @@ class LS2G_API LSGrid final GenericContainer::check_size(names, trafos_.nb(), "set_trafo_names"); trafos_.set_names(names); } - const std::vector & get_trafo_names() const {return trafos_.get_names();} + [[nodiscard]] const std::vector & get_trafo_names() const {return trafos_.get_names();} // per-side current limit, in kA, optional: empty if never set void set_line_current_limit_side1(const RealVect & limit_a1_ka){ GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_current_limit_side1"); @@ -869,7 +869,7 @@ class LS2G_API LSGrid final } void change_p_load(int load_id, real_type new_p) {loads_.change_p_nothrow(load_id, new_p, algo_controler_); } void change_q_load(int load_id, real_type new_q) {loads_.change_q_nothrow(load_id, new_q, algo_controler_); } - int get_bus_load(int load_id) const {return loads_.get_bus(load_id).cast_int();} + [[nodiscard]] int get_bus_load(int load_id) const {return loads_.get_bus(load_id).cast_int();} //generator void deactivate_gen(int gen_id) {generators_.deactivate(gen_id, algo_controler_); } @@ -889,7 +889,7 @@ class LS2G_API LSGrid final void change_p_gen(int gen_id, real_type new_p) {generators_.change_p_nothrow(gen_id, new_p, algo_controler_); } void change_q_gen(int gen_id, real_type new_q) {generators_.change_q_nothrow(gen_id, new_q, algo_controler_); } void change_v_gen(int gen_id, real_type new_v_pu) {generators_.change_v_nothrow(gen_id, new_v_pu, algo_controler_); } - int get_bus_gen(int gen_id) const {return generators_.get_bus(gen_id).cast_int();} + [[nodiscard]] int get_bus_gen(int gen_id) const {return generators_.get_bus(gen_id).cast_int();} //static var compensator (SVC) void deactivate_svc(int svc_id) {svcs_.deactivate(svc_id, algo_controler_); } @@ -900,7 +900,7 @@ class LS2G_API LSGrid final void change_bus_svc_python(int svc_id, int new_gridmodel_bus_id) { change_bus_svc(svc_id, GridModelBusId(new_gridmodel_bus_id)); } - int get_bus_svc(int svc_id) const {return svcs_.get_bus(svc_id).cast_int();} + [[nodiscard]] int get_bus_svc(int svc_id) const {return svcs_.get_bus(svc_id).cast_int();} void set_svc_names(const std::vector & names){ GenericContainer::check_size(names, svcs_.nb(), "set_svc_names"); svcs_.set_names(names); @@ -922,7 +922,7 @@ class LS2G_API LSGrid final } void change_p_shunt(int shunt_id, real_type new_p) {shunts_.change_p_nothrow(shunt_id, new_p, algo_controler_); } void change_q_shunt(int shunt_id, real_type new_q) {shunts_.change_q_nothrow(shunt_id, new_q, algo_controler_); } - int get_bus_shunt(int shunt_id) const {return shunts_.get_bus(shunt_id).cast_int();} + [[nodiscard]] int get_bus_shunt(int shunt_id) const {return shunts_.get_bus(shunt_id).cast_int();} //static gen void deactivate_sgen(int sgen_id) {sgens_.deactivate(sgen_id, algo_controler_); } @@ -940,7 +940,7 @@ class LS2G_API LSGrid final } void change_p_sgen(int sgen_id, real_type new_p) {sgens_.change_p_nothrow(sgen_id, new_p, algo_controler_); } void change_q_sgen(int sgen_id, real_type new_q) {sgens_.change_q_nothrow(sgen_id, new_q, algo_controler_); } - int get_bus_sgen(int sgen_id) const {return sgens_.get_bus(sgen_id).cast_int();} + [[nodiscard]] int get_bus_sgen(int sgen_id) const {return sgens_.get_bus(sgen_id).cast_int();} //storage units void deactivate_storage(int storage_id) {storages_.deactivate(storage_id, algo_controler_); } @@ -960,7 +960,7 @@ class LS2G_API LSGrid final storages_.change_p_nothrow(storage_id, new_p, algo_controler_); } void change_q_storage(int storage_id, real_type new_q) {storages_.change_q_nothrow(storage_id, new_q, algo_controler_); } - int get_bus_storage(int storage_id) const {return storages_.get_bus(storage_id).cast_int();} + [[nodiscard]] int get_bus_storage(int storage_id) const {return storages_.get_bus(storage_id).cast_int();} //deactivate a powerline (disconnect it) void deactivate_dcline(int dcline_id) {hvdc_lines_.deactivate(dcline_id, algo_controler_); } @@ -989,8 +989,8 @@ class LS2G_API LSGrid final * pmax_2to1) is meant to be run between two solves, in Python. */ void set_status_droop_hvdc(int dcline_id, int status) {hvdc_lines_.set_status_droop(dcline_id, status, algo_controler_); } - int get_status_droop_hvdc(int dcline_id) const {return hvdc_lines_.get_status_droop(dcline_id);} - std::vector get_status_droop_hvdc_vect() const {return hvdc_lines_.get_status_droop_vect();} + [[nodiscard]] int get_status_droop_hvdc(int dcline_id) const {return hvdc_lines_.get_status_droop(dcline_id);} + [[nodiscard]] std::vector get_status_droop_hvdc_vect() const {return hvdc_lines_.get_status_droop_vect();} /** * Per-solve data of the connected angle-droop (AC emulation) hvdc * lines, in solver bus labelling and per-unit. Consumed by the Hvdc @@ -1071,69 +1071,69 @@ class LS2G_API LSGrid final void change_bus2_dcline_python(int dcline_id, int new_gridmodel_bus_id) { change_bus2_dcline(dcline_id, GridModelBusId(new_gridmodel_bus_id)); } - int get_bus1_dcline(int dcline_id) const {return hvdc_lines_.get_bus_side_1(dcline_id).cast_int();} - int get_bus2_dcline(int dcline_id) const {return hvdc_lines_.get_bus_side_2(dcline_id).cast_int();} + [[nodiscard]] int get_bus1_dcline(int dcline_id) const {return hvdc_lines_.get_bus_side_1(dcline_id).cast_int();} + [[nodiscard]] int get_bus2_dcline(int dcline_id) const {return hvdc_lines_.get_bus_side_2(dcline_id).cast_int();} // All results access - tuple3d get_loads_res() const {return loads_.get_res();} - const std::vector& get_loads_status() const { return loads_.get_status();} - tuple3d get_shunts_res() const {return shunts_.get_res();} - const std::vector& get_shunts_status() const { return shunts_.get_status();} - tuple3d get_gen_res() const {return generators_.get_res();} - const std::vector& get_gen_status() const { return generators_.get_status();} - tuple4d get_line_res1() const {return powerlines_.get_res_side_1();} - tuple4d get_line_res2() const {return powerlines_.get_res_side_2();} - const std::vector& get_lines_status() const { return powerlines_.get_status_global();} + [[nodiscard]] tuple3d get_loads_res() const {return loads_.get_res();} + [[nodiscard]] const std::vector& get_loads_status() const { return loads_.get_status();} + [[nodiscard]] tuple3d get_shunts_res() const {return shunts_.get_res();} + [[nodiscard]] const std::vector& get_shunts_status() const { return shunts_.get_status();} + [[nodiscard]] tuple3d get_gen_res() const {return generators_.get_res();} + [[nodiscard]] const std::vector& get_gen_status() const { return generators_.get_status();} + [[nodiscard]] tuple4d get_line_res1() const {return powerlines_.get_res_side_1();} + [[nodiscard]] tuple4d get_line_res2() const {return powerlines_.get_res_side_2();} + [[nodiscard]] const std::vector& get_lines_status() const { return powerlines_.get_status_global();} // per-side status (relevant for half-open lines, see `set_synch_status_both_side` / // `keep_half_open_lines`): `get_lines_status()` is the *global* status (both sides // disconnected), these report each side independently. - const std::vector& get_lines_status_side1() const { return powerlines_.get_status_side_1();} - const std::vector& get_lines_status_side2() const { return powerlines_.get_status_side_2();} - tuple4d get_trafo_res1() const {return trafos_.get_res_side_1();} - tuple4d get_trafo_res2() const {return trafos_.get_res_side_2();} - const std::vector& get_trafo_status() const { return trafos_.get_status_global();} - const std::vector& get_trafo_status_side1() const { return trafos_.get_status_side_1();} - const std::vector& get_trafo_status_side2() const { return trafos_.get_status_side_2();} - tuple3d get_storages_res() const {return storages_.get_res();} - const std::vector& get_storages_status() const { return storages_.get_status();} - tuple3d get_sgens_res() const {return sgens_.get_res();} - const std::vector& get_sgens_status() const { return sgens_.get_status();} - tuple3d get_dcline_res1() const {return hvdc_lines_.get_res_side_1();} - tuple3d get_dcline_res2() const {return hvdc_lines_.get_res_side_2();} - const std::vector& get_dclines_status() const { return hvdc_lines_.get_status_global();} - - Eigen::Ref get_gen_theta() const {return generators_.get_theta();} - Eigen::Ref get_load_theta() const {return loads_.get_theta();} - Eigen::Ref get_shunt_theta() const {return shunts_.get_theta();} - Eigen::Ref get_storage_theta() const {return storages_.get_theta();} - Eigen::Ref get_line_theta1() const {return powerlines_.get_theta_side_1();} - Eigen::Ref get_line_theta2() const {return powerlines_.get_theta_side_2();} - Eigen::Ref get_trafo_theta1() const {return trafos_.get_theta_side_1();} - Eigen::Ref get_trafo_theta2() const {return trafos_.get_theta_side_2();} - Eigen::Ref get_dcline_theta1() const {return hvdc_lines_.get_theta_side_1();} - Eigen::Ref get_dcline_theta2() const {return hvdc_lines_.get_theta_side_2();} - - const GlobalBusIdVect & get_all_shunt_buses() const {return shunts_.get_buses();} - Eigen::Ref get_all_shunt_buses_numpy() const {return shunts_.get_bus_id_numpy();} - - Eigen::Ref get_shunt_target_p() const {return shunts_.get_target_p();} - Eigen::Ref get_load_target_p() const {return loads_.get_target_p();} - Eigen::Ref get_gen_target_p() const {return generators_.get_target_p();} - Eigen::Ref get_sgen_target_p() const {return sgens_.get_target_p();} - Eigen::Ref get_storage_target_p() const {return storages_.get_target_p();} + [[nodiscard]] const std::vector& get_lines_status_side1() const { return powerlines_.get_status_side_1();} + [[nodiscard]] const std::vector& get_lines_status_side2() const { return powerlines_.get_status_side_2();} + [[nodiscard]] tuple4d get_trafo_res1() const {return trafos_.get_res_side_1();} + [[nodiscard]] tuple4d get_trafo_res2() const {return trafos_.get_res_side_2();} + [[nodiscard]] const std::vector& get_trafo_status() const { return trafos_.get_status_global();} + [[nodiscard]] const std::vector& get_trafo_status_side1() const { return trafos_.get_status_side_1();} + [[nodiscard]] const std::vector& get_trafo_status_side2() const { return trafos_.get_status_side_2();} + [[nodiscard]] tuple3d get_storages_res() const {return storages_.get_res();} + [[nodiscard]] const std::vector& get_storages_status() const { return storages_.get_status();} + [[nodiscard]] tuple3d get_sgens_res() const {return sgens_.get_res();} + [[nodiscard]] const std::vector& get_sgens_status() const { return sgens_.get_status();} + [[nodiscard]] tuple3d get_dcline_res1() const {return hvdc_lines_.get_res_side_1();} + [[nodiscard]] tuple3d get_dcline_res2() const {return hvdc_lines_.get_res_side_2();} + [[nodiscard]] const std::vector& get_dclines_status() const { return hvdc_lines_.get_status_global();} + + [[nodiscard]] Eigen::Ref get_gen_theta() const {return generators_.get_theta();} + [[nodiscard]] Eigen::Ref get_load_theta() const {return loads_.get_theta();} + [[nodiscard]] Eigen::Ref get_shunt_theta() const {return shunts_.get_theta();} + [[nodiscard]] Eigen::Ref get_storage_theta() const {return storages_.get_theta();} + [[nodiscard]] Eigen::Ref get_line_theta1() const {return powerlines_.get_theta_side_1();} + [[nodiscard]] Eigen::Ref get_line_theta2() const {return powerlines_.get_theta_side_2();} + [[nodiscard]] Eigen::Ref get_trafo_theta1() const {return trafos_.get_theta_side_1();} + [[nodiscard]] Eigen::Ref get_trafo_theta2() const {return trafos_.get_theta_side_2();} + [[nodiscard]] Eigen::Ref get_dcline_theta1() const {return hvdc_lines_.get_theta_side_1();} + [[nodiscard]] Eigen::Ref get_dcline_theta2() const {return hvdc_lines_.get_theta_side_2();} + + [[nodiscard]] const GlobalBusIdVect & get_all_shunt_buses() const {return shunts_.get_buses();} + [[nodiscard]] Eigen::Ref get_all_shunt_buses_numpy() const {return shunts_.get_bus_id_numpy();} + + [[nodiscard]] Eigen::Ref get_shunt_target_p() const {return shunts_.get_target_p();} + [[nodiscard]] Eigen::Ref get_load_target_p() const {return loads_.get_target_p();} + [[nodiscard]] Eigen::Ref get_gen_target_p() const {return generators_.get_target_p();} + [[nodiscard]] Eigen::Ref get_sgen_target_p() const {return sgens_.get_target_p();} + [[nodiscard]] Eigen::Ref get_storage_target_p() const {return storages_.get_target_p();} // complete results (with theta) - tuple4d get_loads_res_full() const {return loads_.get_res_full();} - tuple4d get_shunts_res_full() const {return shunts_.get_res_full();} - tuple4d get_gen_res_full() const {return generators_.get_res_full();} - tuple5d get_line_res1_full() const {return powerlines_.get_res_full_side_1();} - tuple5d get_line_res2_full() const {return powerlines_.get_res_full_side_2();} - tuple5d get_trafo_res1_full() const {return trafos_.get_res_full_side_1();} - tuple5d get_trafo_res2_full() const {return trafos_.get_res_full_side_2();} - tuple4d get_storages_res_full() const {return storages_.get_res_full();} - tuple4d get_sgens_res_full() const {return sgens_.get_res_full();} - tuple4d get_dcline_res1_full() const {return hvdc_lines_.get_res_full_side_1();} - tuple4d get_dcline_res2_full() const {return hvdc_lines_.get_res_full_side_2();} + [[nodiscard]] tuple4d get_loads_res_full() const {return loads_.get_res_full();} + [[nodiscard]] tuple4d get_shunts_res_full() const {return shunts_.get_res_full();} + [[nodiscard]] tuple4d get_gen_res_full() const {return generators_.get_res_full();} + [[nodiscard]] tuple5d get_line_res1_full() const {return powerlines_.get_res_full_side_1();} + [[nodiscard]] tuple5d get_line_res2_full() const {return powerlines_.get_res_full_side_2();} + [[nodiscard]] tuple5d get_trafo_res1_full() const {return trafos_.get_res_full_side_1();} + [[nodiscard]] tuple5d get_trafo_res2_full() const {return trafos_.get_res_full_side_2();} + [[nodiscard]] tuple4d get_storages_res_full() const {return storages_.get_res_full();} + [[nodiscard]] tuple4d get_sgens_res_full() const {return sgens_.get_res_full();} + [[nodiscard]] tuple4d get_dcline_res1_full() const {return hvdc_lines_.get_res_full_side_1();} + [[nodiscard]] tuple4d get_dcline_res2_full() const {return hvdc_lines_.get_res_full_side_2();} /** * @brief Get the Ybus solver object (AC) @@ -1185,7 +1185,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_Sbus_solver() const{ + [[nodiscard]] Eigen::Ref get_Sbus_solver() const{ return acSbus_; } @@ -1203,7 +1203,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_dcSbus_solver() const{ + [[nodiscard]] Eigen::Ref get_dcSbus_solver() const{ return dcPbus_; // DC power injection is real (active power P) } @@ -1223,7 +1223,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const Eigen::SparseMatrix get_Ybus() const { + [[nodiscard]] const Eigen::SparseMatrix get_Ybus() const { return _relabel_matrix(Ybus_ac_, id_ac_solver_to_me_); } @@ -1243,7 +1243,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const Eigen::SparseMatrix get_dcYbus() const { + [[nodiscard]] const Eigen::SparseMatrix get_dcYbus() const { return _relabel_matrix(Bbus_dc_, id_dc_solver_to_me_); } @@ -1261,7 +1261,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const CplxVect get_Sbus() const { + [[nodiscard]] const CplxVect get_Sbus() const { return _relabel_vector(acSbus_, id_ac_solver_to_me_); } @@ -1279,7 +1279,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const RealVect get_dcSbus() const { + [[nodiscard]] const RealVect get_dcSbus() const { return _relabel_vector(dcPbus_, id_dc_solver_to_me_); } @@ -1290,7 +1290,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const SolverBusIdVect & get_pv_solver() const{ + [[nodiscard]] const SolverBusIdVect & get_pv_solver() const{ return bus_pv_; } /** @@ -1300,7 +1300,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_pv_solver_numpy() const{ + [[nodiscard]] Eigen::Ref get_pv_solver_numpy() const{ return bus_pv_.as_eigen(); // was _to_intvect() } @@ -1311,12 +1311,12 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - const GlobalBusIdVect get_pv() const{ + [[nodiscard]] const GlobalBusIdVect get_pv() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector2(get_pv_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector2(get_pv_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_pv: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); } - const IntVect get_pv_numpy() const{ + [[nodiscard]] const IntVect get_pv_numpy() const{ return get_pv().as_eigen(); // was _to_intvect() } @@ -1327,7 +1327,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const SolverBusIdVect & get_pq_solver() const{ + [[nodiscard]] const SolverBusIdVect & get_pq_solver() const{ return bus_pq_; } /** @@ -1337,7 +1337,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const Eigen::Ref get_pq_solver_numpy() const{ + [[nodiscard]] const Eigen::Ref get_pq_solver_numpy() const{ return bus_pq_.as_eigen(); // was _to_intvect() } @@ -1348,12 +1348,12 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - const GlobalBusIdVect get_pq() const{ + [[nodiscard]] const GlobalBusIdVect get_pq() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector2(get_pq_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector2(get_pq_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_pq: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); } - const IntVect get_pq_numpy() const{ + [[nodiscard]] const IntVect get_pq_numpy() const{ return get_pq().as_eigen(); // was _to_intvect() } @@ -1362,7 +1362,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const SolverBusIdVect & get_slack_ids_solver() const{ + [[nodiscard]] const SolverBusIdVect & get_slack_ids_solver() const{ return slack_bus_id_ac_solver_; } /** @@ -1372,7 +1372,7 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - Eigen::Ref get_slack_ids_solver_numpy() const{ + [[nodiscard]] Eigen::Ref get_slack_ids_solver_numpy() const{ return slack_bus_id_ac_solver_.as_eigen(); // was _to_intvect() } @@ -1381,10 +1381,10 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - const GlobalBusIdVect get_slack_ids() const { + [[nodiscard]] const GlobalBusIdVect get_slack_ids() const { return _relabel_vector2(slack_bus_id_ac_solver_, id_ac_solver_to_me_); } - const IntVect get_slack_ids_numpy() const { + [[nodiscard]] const IntVect get_slack_ids_numpy() const { return get_slack_ids().as_eigen(); // was _to_intvect() } @@ -1393,7 +1393,7 @@ class LS2G_API LSGrid final * * @return const SolverBusIdVect & */ - const SolverBusIdVect & get_slack_ids_dc_solver() const{ + [[nodiscard]] const SolverBusIdVect & get_slack_ids_dc_solver() const{ return slack_bus_id_dc_solver_; } /** @@ -1401,11 +1401,11 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_slack_ids_dc_solver_numpy() const{ + [[nodiscard]] Eigen::Ref get_slack_ids_dc_solver_numpy() const{ return slack_bus_id_dc_solver_.as_eigen(); // was _to_intvect() } - const GlobalBusIdVect get_slack_ids_dc() const{ + [[nodiscard]] const GlobalBusIdVect get_slack_ids_dc() const{ return _relabel_vector2( slack_bus_id_dc_solver_, id_dc_solver_to_me_); @@ -1415,7 +1415,7 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - const IntVect get_slack_ids_dc_numpy() const{ + [[nodiscard]] const IntVect get_slack_ids_dc_numpy() const{ return get_slack_ids_dc().as_eigen(); // was _to_intvect() } @@ -1426,7 +1426,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_slack_weights_solver() const{ + [[nodiscard]] Eigen::Ref get_slack_weights_solver() const{ return slack_weights_; } @@ -1437,7 +1437,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - const RealVect get_slack_weights() const{ + [[nodiscard]] const RealVect get_slack_weights() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_slack_weights_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_slack_weights_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_slack_weights: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); @@ -1448,7 +1448,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_V_solver() const{ + [[nodiscard]] Eigen::Ref get_V_solver() const{ return _algo.get_V(); } @@ -1457,7 +1457,7 @@ class LS2G_API LSGrid final * * @return CplxVect */ - const CplxVect get_V() const{ + [[nodiscard]] const CplxVect get_V() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_V_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_V_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_V: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); @@ -1468,7 +1468,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_Va_solver() const{ + [[nodiscard]] Eigen::Ref get_Va_solver() const{ return _algo.get_Va(); } @@ -1477,7 +1477,7 @@ class LS2G_API LSGrid final * * @return const RealVect */ - const RealVect get_Va() const{ + [[nodiscard]] const RealVect get_Va() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_Va_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_Va_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_Va: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); @@ -1488,7 +1488,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - Eigen::Ref get_Vm_solver() const{ + [[nodiscard]] Eigen::Ref get_Vm_solver() const{ return _algo.get_Vm(); } @@ -1497,13 +1497,13 @@ class LS2G_API LSGrid final * * @return const RealVect */ - const RealVect get_Vm() const{ + [[nodiscard]] const RealVect get_Vm() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_Vm_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_Vm_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_Vm: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); } - Eigen::Ref > get_J_solver() const{ + [[nodiscard]] Eigen::Ref > get_J_solver() const{ return _algo.get_J(); } @@ -1512,7 +1512,7 @@ class LS2G_API LSGrid final * * @return Eigen::SparseMatrix */ - Eigen::SparseMatrix get_J_python_solver() const{ + [[nodiscard]] Eigen::SparseMatrix get_J_python_solver() const{ return _algo.get_J_python(); // This is copied to python } @@ -1522,11 +1522,11 @@ class LS2G_API LSGrid final // gpusim2grid) rebuild the dS scatter / residual layout without // re-deriving it from Ybus. Only valid for Newton-Raphson algorithms // after a solve; throw otherwise (via the active algo). - IntVect get_theta_to_J_col_solver() const { return _algo.get_theta_to_J_col_python(); } - IntVect get_vm_to_J_col_solver() const { return _algo.get_vm_to_J_col_python(); } - IntVect get_q_to_J_col_solver() const { return _algo.get_q_to_J_col_python(); } - IntVect get_p_to_J_row_solver() const { return _algo.get_p_to_J_row_python(); } - IntVect get_q_to_J_row_solver() const { return _algo.get_q_to_J_row_python(); } + [[nodiscard]] IntVect get_theta_to_J_col_solver() const { return _algo.get_theta_to_J_col_python(); } + [[nodiscard]] IntVect get_vm_to_J_col_solver() const { return _algo.get_vm_to_J_col_python(); } + [[nodiscard]] IntVect get_q_to_J_col_solver() const { return _algo.get_q_to_J_col_python(); } + [[nodiscard]] IntVect get_p_to_J_row_solver() const { return _algo.get_p_to_J_row_python(); } + [[nodiscard]] IntVect get_q_to_J_row_solver() const { return _algo.get_q_to_J_row_python(); } // Compact (bus, row/col) registration pair lists -- the row/col // counterpart of the *_to_J_col / *_to_J_row maps above, but preserving @@ -1536,30 +1536,30 @@ class LS2G_API LSGrid final // rules"). NRSystem::_residual() itself iterates these, not the // bus-keyed maps; external batched solvers must do the same to // reproduce every contribution. - IntVect get_p_buses_solver() const { return _algo.get_p_buses_python(); } - IntVect get_p_rows_solver() const { return _algo.get_p_rows_python(); } - IntVect get_q_buses_solver() const { return _algo.get_q_buses_python(); } - IntVect get_q_rows_solver() const { return _algo.get_q_rows_python(); } - IntVect get_theta_buses_solver() const { return _algo.get_theta_buses_python(); } - IntVect get_theta_cols_solver() const { return _algo.get_theta_cols_python(); } - IntVect get_vm_buses_solver() const { return _algo.get_vm_buses_python(); } - IntVect get_vm_cols_solver() const { return _algo.get_vm_cols_python(); } + [[nodiscard]] IntVect get_p_buses_solver() const { return _algo.get_p_buses_python(); } + [[nodiscard]] IntVect get_p_rows_solver() const { return _algo.get_p_rows_python(); } + [[nodiscard]] IntVect get_q_buses_solver() const { return _algo.get_q_buses_python(); } + [[nodiscard]] IntVect get_q_rows_solver() const { return _algo.get_q_rows_python(); } + [[nodiscard]] IntVect get_theta_buses_solver() const { return _algo.get_theta_buses_python(); } + [[nodiscard]] IntVect get_theta_cols_solver() const { return _algo.get_theta_cols_python(); } + [[nodiscard]] IntVect get_vm_buses_solver() const { return _algo.get_vm_buses_python(); } + [[nodiscard]] IntVect get_vm_cols_solver() const { return _algo.get_vm_cols_python(); } // MultiSlack slack_absorbed J column (-1 when distributed slack inactive). - int get_slack_col_solver() const { return _algo.get_slack_col(); } + [[nodiscard]] int get_slack_col_solver() const { return _algo.get_slack_col(); } // MultiSlack converged slack_absorbed VALUE (pu; 0 when inactive). This // is the ground-truth state after convergence -- NOT the 0 initial // guess an external solver's own linearized derivation starts from. - real_type get_slack_absorbed_solver() const { return _algo.get_slack_absorbed(); } + [[nodiscard]] real_type get_slack_absorbed_solver() const { return _algo.get_slack_absorbed(); } // VoltageControl (remote gen + SVC) converged reactive injection per // controller (pu), + its (kind: 0=GEN,1=SVC; element id) identity, in // controller registration order (empty when the extension is inactive). // Ground truth for external solvers deriving their own controller_q. - RealVect get_controller_q_solver() const { return _algo.get_controller_q(); } - IntVect get_controller_kind_solver() const { return _algo.get_controller_kind(); } - IntVect get_controller_elem_id_solver() const { return _algo.get_controller_elem_id(); } + [[nodiscard]] RealVect get_controller_q_solver() const { return _algo.get_controller_q(); } + [[nodiscard]] IntVect get_controller_kind_solver() const { return _algo.get_controller_kind(); } + [[nodiscard]] IntVect get_controller_elem_id_solver() const { return _algo.get_controller_elem_id(); } - real_type get_computation_time() const{ return _algo.get_computation_time();} - real_type get_dc_computation_time() const{ return _dc_algo.get_computation_time();} + [[nodiscard]] real_type get_computation_time() const{ return _algo.get_computation_time();} + [[nodiscard]] real_type get_dc_computation_time() const{ return _dc_algo.get_computation_time();} // part dedicated to grid2op backend, optimized for grid2op data representation (for speed) // this is not recommended to use it outside of its intended usage within grid2op ! @@ -1667,7 +1667,7 @@ class LS2G_API LSGrid final } max_nb_bus_per_sub_ = max_nb_bus_per_sub; } - int get_max_nb_bus_per_sub() const { return max_nb_bus_per_sub_;} + [[nodiscard]] int get_max_nb_bus_per_sub() const { return max_nb_bus_per_sub_;} /** * Relevant kwargs the grid was built with (eg by `init_from_pypowsybl`), as a @@ -1678,7 +1678,7 @@ class LS2G_API LSGrid final * pypowsybl-shaped result view) to recover conversion-time settings that are * otherwise plain Python arguments lost after `init()` returns. */ - const std::map & get_init_kwargs() const { return init_kwargs_;} + [[nodiscard]] const std::map & get_init_kwargs() const { return init_kwargs_;} void set_init_kwargs(const std::map & init_kwargs) { init_kwargs_ = init_kwargs;} void fillSbus_other(CplxVect & res, bool ac, const SolverBusIdVect& id_me_to_solver){ From 8c7609c33204b6d7ba28629f1cc28393e776cbc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 07:15:30 +0000 Subject: [PATCH 112/166] Address review: Eigen::Ref& parameter style, drop redundant final in already-final classes, override destructors Eigen::Ref parameters (update_gens_p/sgens_p/gens_v/loads_p/ loads_q/storages_p, update_continuous_values, update_slack_weights in both LSGrid and GeneratorContainer) switch from by-value Eigen::Ref to const Eigen::Ref &, matching the codebase's dominant convention (GeneratorContainer::init and others) and Eigen's own documented idiom for read-only Ref parameters. update_topo (pre-existing, not touched by this PR) still uses the by-value style; left as a possible follow-up rather than touched here. BaseDCAlgo and the ScalingPolicy derived policies (NoScalingPolicy/MaxVoltageChangeScalingPolicy/LineSearchScalingPolicy/ IwamotoScalingPolicy) drop the per-method `final` this PR had added: the class itself is already `final`, so no further override is possible regardless of whether individual methods repeat it -- keeping just `override` is sufficient and less noisy. Every destructor that overrides a base class's virtual destructor now says so explicitly (`override`, dropping the now-redundant `virtual`), closing the last modernize-use-override gap from the previous commit: down from ~40 residual warnings to 2, both the same intentional override+final pairing on a non-final class explained in .clang-tidy. Two of these (ContingencyAnalysis, TimeSeries) had neither virtual nor override at all before this commit. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- .clang-tidy | 16 +++++----- src/core/LSGrid.cpp | 24 +++++++-------- src/core/LSGrid.hpp | 30 +++++++++---------- .../batch_algorithm/ContingencyAnalysis.hpp | 2 +- src/core/batch_algorithm/TimeSeries.hpp | 2 +- .../ConverterStationContainer.hpp | 2 +- .../element_container/GeneratorContainer.cpp | 2 +- .../element_container/GeneratorContainer.hpp | 4 +-- .../element_container/HvdcLineContainer.hpp | 2 +- src/core/element_container/LineContainer.hpp | 2 +- src/core/element_container/LoadContainer.hpp | 2 +- .../element_container/OneSideContainer.hpp | 2 +- .../element_container/OneSideContainer_PQ.hpp | 2 +- .../OneSideContainer_forBranch.hpp | 2 +- src/core/element_container/SGenContainer.hpp | 2 +- src/core/element_container/ShuntContainer.hpp | 2 +- .../element_container/StorageContainer.hpp | 2 +- src/core/element_container/SvcContainer.hpp | 2 +- src/core/element_container/TrafoContainer.hpp | 2 +- .../element_container/TwoSidesContainer.hpp | 2 +- .../TwoSidesContainer_rxh_A.hpp | 2 +- src/core/powerflow_algorithm/BaseDCAlgo.hpp | 24 +++++++-------- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 2 +- .../powerflow_algorithm/GaussSeidelAlgo.hpp | 2 +- .../GaussSeidelSynchAlgo.hpp | 2 +- src/core/powerflow_algorithm/NRAlgo.hpp | 2 +- .../powerflow_algorithm/ScalingPolicies.hpp | 16 +++++----- 27 files changed, 79 insertions(+), 77 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 908ddc88..78931c07 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -17,14 +17,16 @@ # style in an initial run. Rather than suppress the check, the redundant # `virtual` was dropped codebase-wide (virtual is implied by `override`, and # by `final` on an actual override) as part of the same final/override sweep, -# bringing this down to ~40 residual warnings, all in two explainable -# buckets: (1) destructors not annotated `override`/`final` -- valid C++ to -# add, but this codebase doesn't follow that particular style and it wasn't -# worth changing here; (2) `override final` used together for defensive +# and every destructor overriding a base's virtual destructor was annotated +# `override` too, bringing this down to 2 residual warnings, both the same +# explainable case: `override final` used together for defensive # compile-time checking (marks BOTH "this really overrides something" and -# "no further overrides") -- the check prefers `final` alone since it -# implies `override`, but the pair is intentional in a few places and left -# as-is. +# "no further overrides") on a method in a class that ISN'T itself `final` +# (OneSideContainer::disconnect_if_not_in_main_component, +# TwoSidesContainer::nb_line_end) -- the check prefers `final` alone since it +# implies `override`, but the pair is intentional there and left as-is. +# (Classes that are themselves `final` don't repeat `final` on their +# methods -- redundant, since the class already can't be subclassed at all.) # # Known false-positive class: performance-unnecessary-value-param flags every # `Eigen::Ref<...>` parameter passed by value (the idiom used throughout this diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index ecc9eb14..3889d443 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -1592,38 +1592,38 @@ void LSGrid::remove_gen_slackbus(int gen_id){ } /** GRID2OP SPECIFIC REPRESENTATION **/ -void LSGrid::update_gens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_gens_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_gen); } -void LSGrid::update_sgens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_sgens_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_sgen); } -void LSGrid::update_gens_v(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_gens_v(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_v_gen); } -void LSGrid::update_loads_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_loads_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_load); } -void LSGrid::update_loads_q(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_loads_q(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_q_load); } -void LSGrid::update_storages_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_storages_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { update_continuous_values(has_changed, new_values, &LSGrid::change_p_storage); } diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 8b94fd53..5163c462 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -197,7 +197,7 @@ class LS2G_API LSGrid final generators_.turnedoff_pv(algo_controler_); } [[nodiscard]] bool get_turnedoff_gen_pv() const {return generators_.get_turnedoff_gen_pv();} - void update_slack_weights(Eigen::Ref > could_be_slack){ + void update_slack_weights(const Eigen::Ref > & could_be_slack){ generators_.update_slack_weights(could_be_slack, algo_controler_); } void update_slack_weights_by_id(Eigen::Ref slack_ids){ @@ -1563,16 +1563,16 @@ class LS2G_API LSGrid final // part dedicated to grid2op backend, optimized for grid2op data representation (for speed) // this is not recommended to use it outside of its intended usage within grid2op ! - void update_gens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_sgens_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_gens_v(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_loads_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); - void update_loads_q(Eigen::Ref > has_changed, - Eigen::Ref > new_values); + void update_gens_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); + void update_sgens_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); + void update_gens_v(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); + void update_loads_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); + void update_loads_q(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); /** * Update the topology based on the topology vector id. * @@ -1581,8 +1581,8 @@ class LS2G_API LSGrid final */ void update_topo(Eigen::Ref > has_changed, Eigen::Ref > new_values); - void update_storages_p(Eigen::Ref > has_changed, - Eigen::Ref > new_values); + void update_storages_p(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); void set_load_pos_topo_vect(Eigen::Ref load_pos_topo_vect) { @@ -1940,8 +1940,8 @@ class LS2G_API LSGrid final optimization for grid2op **/ template - void update_continuous_values(Eigen::Ref > has_changed, - Eigen::Ref > new_values, + void update_continuous_values(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values, T fun) { // new_values is indexed by has_changed's length below; a shorter new_values diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index b60395bf..9a5627af 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -48,7 +48,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch clear(); } - ~ContingencyAnalysis() noexcept = default; + ~ContingencyAnalysis() noexcept override = default; ContingencyAnalysis(const ContingencyAnalysis&) = delete; ContingencyAnalysis(ContingencyAnalysis&&) = delete; ContingencyAnalysis & operator=(ContingencyAnalysis&&) = delete; diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index 54c1663c..6fc5b472 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -26,7 +26,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch _status(1), // 1: success, 0: failure _compute_flows(true) {} - ~TimeSeries() noexcept = default; + ~TimeSeries() noexcept override = default; TimeSeries(const TimeSeries&) = delete; TimeSeries(TimeSeries&&) = delete; diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index 4b3beb23..5ab1234e 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -101,7 +101,7 @@ class LS2G_API ConverterStationContainer final : public OneSideContainer_PQ, pub "ConverterStationContainer::StateRes and StateResIdx do not match"); ConverterStationContainer() noexcept = default; - virtual ~ConverterStationContainer() noexcept = default; + ~ConverterStationContainer() noexcept override = default; void init(const std::vector & type, const Eigen::Ref & loss_factor, diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index b6595ae3..6f10d5c2 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -539,7 +539,7 @@ void GeneratorContainer::set_q( } void GeneratorContainer::update_slack_weights( - Eigen::Ref > could_be_slack, + const Eigen::Ref > & could_be_slack, DualAlgoControl & solver_control) { const int nb_gen = nb(); diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index 7f7a5838..e891e7cd 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -70,7 +70,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter GeneratorContainer() noexcept :OneSideContainer_PQ(), turnedoff_gen_pv_(true){}; explicit GeneratorContainer(bool turnedoff_gen_pv) noexcept :OneSideContainer_PQ(), turnedoff_gen_pv_(turnedoff_gen_pv) {}; - virtual ~GeneratorContainer() noexcept = default; + ~GeneratorContainer() noexcept override = default; // TODO add pmin and pmax here ! void init(const Eigen::Ref & generators_p, @@ -198,7 +198,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter turnedoff_gen_pv_=true; // turned off generators are pv. This is the default. } bool get_turnedoff_gen_pv() const {return turnedoff_gen_pv_;} - void update_slack_weights(Eigen::Ref > could_be_slack, + void update_slack_weights(const Eigen::Ref > & could_be_slack, DualAlgoControl & solver_control); void update_slack_weights_by_id(Eigen::Ref gen_slack_id, DualAlgoControl & solver_control); diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index efd721e4..c0c19afa 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -110,7 +110,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer; LineContainer() noexcept = default; - virtual ~LineContainer() noexcept = default; + ~LineContainer() noexcept override = default; void init(const Eigen::Ref & branch_r, const Eigen::Ref & branch_x, diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 280edc98..966a041f 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -57,7 +57,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA > ; LoadContainer() noexcept = default; - virtual ~LoadContainer() noexcept = default; + ~LoadContainer() noexcept override = default; // pickle (python) LoadContainer::StateRes get_state() const; diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 93c377ab..afd50eba 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -156,7 +156,7 @@ class OneSideContainer : public GenericContainer public: OneSideContainer() noexcept = default; - virtual ~OneSideContainer() noexcept = default; + ~OneSideContainer() noexcept override = default; // OneSideInfo get_osc_info(int id_) {return OneSideInfo(*this, id_);} // public generic API diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index ad84ddd1..5e9065cc 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -55,7 +55,7 @@ class OneSideContainer_PQ : public OneSideContainer // regular implementation public: OneSideContainer_PQ() noexcept = default; - virtual ~OneSideContainer_PQ() noexcept = default; + ~OneSideContainer_PQ() noexcept override = default; // public generic API diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index 9d9ef3ab..08983a1e 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -63,7 +63,7 @@ class OneSideContainer_ForBranch : public OneSideContainer public: OneSideContainer_ForBranch() noexcept = default; explicit OneSideContainer_ForBranch(bool /*is_trafo*/) noexcept{}; - virtual ~OneSideContainer_ForBranch() noexcept = default; + ~OneSideContainer_ForBranch() noexcept override = default; // public generic API diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index c395fe74..f67a0807 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -65,7 +65,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA >; SGenContainer() noexcept = default; - virtual ~SGenContainer() noexcept = default; + ~SGenContainer() noexcept override = default; // pickle (python) SGenContainer::StateRes get_state() const; diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index e2fe19d5..a0d22bc6 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -47,7 +47,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator using StateRes = std::tuple; ShuntContainer() noexcept = default; - virtual ~ShuntContainer() noexcept = default; + ~ShuntContainer() noexcept override = default; void init(const Eigen::Ref & shunt_p_mw, diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index 63a98138..546e140c 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -59,7 +59,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat > ; StorageContainer() noexcept = default; - virtual ~StorageContainer() noexcept = default; + ~StorageContainer() noexcept override = default; // pickle (python) StorageContainer::StateRes get_state() const; diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 2750a628..9f836983 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -94,7 +94,7 @@ class LS2G_API SvcContainer final : public OneSideContainer_PQ, public IteratorA "SvcContainer::StateRes and StateResIdx do not match"); SvcContainer() noexcept = default; - virtual ~SvcContainer() noexcept = default; + ~SvcContainer() noexcept override = default; void init(const std::vector & regulation_mode, const Eigen::Ref & target_vm_pu, diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 22df07b2..9d2d64e2 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -82,7 +82,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A; TrafoContainer() noexcept = default; - virtual ~TrafoContainer() noexcept = default; + ~TrafoContainer() noexcept override = default; void init(const Eigen::Ref & trafo_r, const Eigen::Ref & trafo_x, diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index 1d381de4..d7c7d939 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -116,7 +116,7 @@ class TwoSidesContainer : public GenericContainer public: TwoSidesContainer() noexcept :ignore_status_global_(false), synch_status_both_side_(true){} - virtual ~TwoSidesContainer() noexcept = default; + ~TwoSidesContainer() noexcept override = default; // public generic API size_t nb() const { return side_1_.nb(); } diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 3764eb52..78a3c761 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -157,7 +157,7 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer public: TwoSidesContainer_rxh_A() noexcept = default; - virtual ~TwoSidesContainer_rxh_A() noexcept = default; + ~TwoSidesContainer_rxh_A() noexcept override = default; // pickle // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index a1990585..93adf648 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -38,10 +38,10 @@ class BaseDCAlgo final: public BaseAlgo sizeYbus_with_slack_(0), sizeYbus_without_slack_(0){}; - virtual ~BaseDCAlgo() noexcept = default; + ~BaseDCAlgo() noexcept override = default; - void reset() override final; - void reset_timer() override final{ + void reset() override; + void reset_timer() override{ BaseAlgo::reset_timer(); timer_refactor_ = 0.; timer_factor_ = 0.; @@ -53,7 +53,7 @@ class BaseDCAlgo final: public BaseAlgo timer_lodf_ = 0.; } - TimerJac get_timers_jacobian() const override final + TimerJac get_timers_jacobian() const override { TimerJac res; res.timer_Fx_ = timer_Fx_; @@ -68,7 +68,7 @@ class BaseDCAlgo final: public BaseAlgo return res; } - TimerPTDFLODFType get_timers_ptdf_lodf() const override final + TimerPTDFLODFType get_timers_ptdf_lodf() const override { TimerPTDFLODFType res = { timer_ptdf_, @@ -88,14 +88,14 @@ class BaseDCAlgo final: public BaseAlgo Eigen::Ref slack_weights, Eigen::Ref pv, Eigen::Ref pq - ) override final; + ) override; - RealMat get_ptdf() override final; + RealMat get_ptdf() override; RealMat get_lodf(const IntVect & from_bus, - const IntVect & to_bus) override final; - Eigen::SparseMatrix get_bsdf() override final; // TODO BSDF + const IntVect & to_bus) override; + Eigen::SparseMatrix get_bsdf() override; // TODO BSDF - void update_internal_Ybus(const Coeff & coeff, bool add) override final{ + void update_internal_Ybus(const Coeff & coeff, bool add) override{ int row_res = static_cast(coeff.row_id); row_res = mat_bus_id_(row_res); if(row_res == -1) return; @@ -117,8 +117,8 @@ class BaseDCAlgo final: public BaseAlgo // a working copy of dcYbus_noslack_, so the (incrementally maintained) // persistent matrix and the symbolic factorization are left untouched (only a // numeric refactorize is needed). See compute_pf_dc. - bool supports_bus_masking() const override final { return true; } - void set_masked_buses(const std::vector & solver_bus_ids) override final{ + bool supports_bus_masking() const override { return true; } + void set_masked_buses(const std::vector & solver_bus_ids) override{ masked_buses_ = solver_bus_ids; } diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 97b42d2d..3a766cda 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -21,7 +21,7 @@ class BaseFDPFAlgo final: public BaseAlgo { public: BaseFDPFAlgo() noexcept :BaseAlgo(true), need_factorize_(true) {} - virtual ~BaseFDPFAlgo() noexcept = default; + ~BaseFDPFAlgo() noexcept override = default; bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp index 05d66701..f4bd075c 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp @@ -18,7 +18,7 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo public: GaussSeidelAlgo() noexcept :BaseAlgo(true) {}; - virtual ~GaussSeidelAlgo() noexcept = default; + ~GaussSeidelAlgo() noexcept override = default; // todo can be factorized Eigen::Ref > get_J() const override { diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp index b7c66cd5..8a91d440 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp @@ -22,7 +22,7 @@ class LS2G_API GaussSeidelSynchAlgo final: public GaussSeidelAlgo public: GaussSeidelSynchAlgo() noexcept : GaussSeidelAlgo() {}; - virtual ~GaussSeidelSynchAlgo() noexcept = default; + ~GaussSeidelSynchAlgo() noexcept override = default; protected: void one_iter(CplxVect & tmp_Sbus, diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index d2bbf01f..5597aafe 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -52,7 +52,7 @@ class NRAlgo final : public BaseAlgo timer_scale_(0.), timer_mismatch_(0.) {} - virtual ~NRAlgo() noexcept = default; + ~NRAlgo() noexcept override = default; // ----- Jacobian accessor --------------------------------------------------- diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index f031c31c..c84fe14a 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -51,8 +51,8 @@ template class LS2G_API NoScalingPolicy final : public ScalingPolicy { public: - ScalingPolicyType type() const override final {return ScalingPolicyType::NoScaling;} - real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) override final + ScalingPolicyType type() const override {return ScalingPolicyType::NoScaling;} + real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) override { return 1.; } @@ -63,8 +63,8 @@ template class LS2G_API MaxVoltageChangeScalingPolicy final : public ScalingPolicy { public: - ScalingPolicyType type() const override final {return ScalingPolicyType::MaxVoltageChange;} - real_type scale(const NRSystem& system, const RealVect & F) override final + ScalingPolicyType type() const override {return ScalingPolicyType::MaxVoltageChange;} + real_type scale(const NRSystem& system, const RealVect & F) override { real_type alpha = static_cast(1.0); // max_abs_dtheta / max_abs_dvm account for both the base block and any @@ -96,9 +96,9 @@ template class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy { public: - ScalingPolicyType type() const override final {return ScalingPolicyType::LineSearch;} + ScalingPolicyType type() const override {return ScalingPolicyType::LineSearch;} - real_type scale(const NRSystem& system, const RealVect & F) override final + real_type scale(const NRSystem& system, const RealVect & F) override { // Current merit (||mismatch(x)||^2 before the step). By the time scale() // runs, F has already been overwritten in place by the linear solve @@ -151,9 +151,9 @@ template class IwamotoScalingPolicy final : public ScalingPolicy { public: - ScalingPolicyType type() const override final {return ScalingPolicyType::Iwamoto;} + ScalingPolicyType type() const override {return ScalingPolicyType::Iwamoto;} - real_type scale(const NRSystem& system, const RealVect & F) override final + real_type scale(const NRSystem& system, const RealVect & F) override { // g0 = ||mismatch(x)||^2 (current state, BEFORE the step) -- see the // identical note in LineSearchScalingPolicy::scale: F is already dx by From b09ac445211ed9e3845ff4c147f24d059dffd44d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 07:56:29 +0000 Subject: [PATCH 113/166] get_status_droop_vect/get_status_droop_hvdc_vect: return Eigen::Ref Was copying status_droop_ (an Eigen::VectorXi) element-by-element into a freshly built std::vector on every call. Since status_droop_ is a real member, not a temporary, returning Eigen::Ref over it directly is safe and matches the Ref-returning idiom already used elsewhere in LSGrid (get_bus_vn_kv and friends) -- avoids the C++-side copy entirely. This does flip the Python-facing return type of get_status_droop_hvdc_vect from list[int] to a numpy array (int32), confirmed and requested by the maintainer. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- src/core/LSGrid.hpp | 2 +- src/core/element_container/HvdcLineContainer.hpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 5163c462..403c9176 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -990,7 +990,7 @@ class LS2G_API LSGrid final */ void set_status_droop_hvdc(int dcline_id, int status) {hvdc_lines_.set_status_droop(dcline_id, status, algo_controler_); } [[nodiscard]] int get_status_droop_hvdc(int dcline_id) const {return hvdc_lines_.get_status_droop(dcline_id);} - [[nodiscard]] std::vector get_status_droop_hvdc_vect() const {return hvdc_lines_.get_status_droop_vect();} + [[nodiscard]] Eigen::Ref get_status_droop_hvdc_vect() const {return hvdc_lines_.get_status_droop_vect();} /** * Per-solve data of the connected angle-droop (AC emulation) hvdc * lines, in solver bus labelling and per-unit. Consumed by the Hvdc diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index c0c19afa..371202bb 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -308,8 +308,8 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer get_status_droop_vect() const { - return std::vector(status_droop_.begin(), status_droop_.end()); + Eigen::Ref get_status_droop_vect() const { + return status_droop_; } const std::vector & get_droop_enabled() const {return droop_enabled_;} real_type get_droop_p0_mw(int hvdc_id) const {return p0_mw_(hvdc_id);} From 36388e0bb5ae8d7412361ad97fdf5fd0841e108a Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 12:13:54 +0200 Subject: [PATCH 114/166] fix a bug introduced by fused branch in original grid Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_from_pypowsybl.py | 19 ++ .../network/from_pypowsybl/_result_network.py | 193 +++++++++++++++++- ...format2.lsb => case14_sandbox_format3.lsb} | Bin 4747 -> 4755 bytes .../tests/test_binary_serialization.py | 4 +- .../tests/test_bus_fusion_pypowsybl.py | 148 +++++++++++++- src/bindings/python/binding_lsgrid.cpp | 10 + src/core/BinaryArchive.hpp | 2 +- src/core/LSGrid.cpp | 11 +- src/core/LSGrid.hpp | 29 ++- 9 files changed, 405 insertions(+), 11 deletions(-) rename lightsim2grid/tests/binary_format_fixture/{case14_sandbox_format2.lsb => case14_sandbox_format3.lsb} (98%) diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 8f1118ac..ce1eddbc 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -904,6 +904,25 @@ def _uf_union(a, b): rep = np.array([_uf_find(i) for i in range(parent.shape[0])]) bus_df["bus_global_id"] = rep[bus_df["bus_global_id"].values] + # a fused-away ("loser") bus keeps its own row in the lightsim2grid model + # (fixed-size bus containers) but loses every element to its representative, + # so it ends up disconnected with no solved voltage of its own. Stash the + # representative lookup so a downstream result view (eg + # `LightsimResultNetwork`) can report that bus's voltage as its + # representative's -- the two are electrically the same node -- instead of + # the disconnected bus's stale/zero value. See `LSGrid._bus_fusion_rep`. + model._bus_fusion_rep = rep.astype(int) + if fused_line_ids or fused_trafo_ids: + # ids of the fused-away branches themselves, so a downstream result view + # (eg `LightsimResultNetwork`) can tell "deactivated because fused" apart + # from "deactivated because genuinely out of service" and, where the + # physics allow it, reconstruct the flow through it (see + # `LightsimResultNetwork._reconstruct_fused_branches`). "\x1f" (ASCII unit + # separator) is used as delimiter: pypowsybl element ids never contain it. + kwargs = dict(model._init_kwargs) + kwargs["fused_line_ids"] = "\x1f".join(sorted(str(i) for i in fused_line_ids)) + kwargs["fused_trafo_ids"] = "\x1f".join(sorted(str(i) for i in fused_trafo_ids)) + model._init_kwargs = kwargs # do the generators gen_attrs = [ diff --git a/lightsim2grid/network/from_pypowsybl/_result_network.py b/lightsim2grid/network/from_pypowsybl/_result_network.py index 8a7ad234..b67f71ec 100644 --- a/lightsim2grid/network/from_pypowsybl/_result_network.py +++ b/lightsim2grid/network/from_pypowsybl/_result_network.py @@ -74,6 +74,7 @@ def __init__(self, ls_grid: LSGrid, net: "pypo.network.Network"): self._cache: Dict[str, pd.DataFrame] = {} self._bus_id_lookup: Optional[np.ndarray] = None # see _ls_bus_to_pypo self._sub_names: Optional[List[str]] = None # see _ls_sub_to_vl + self._fused_ids_cache = None # see _fused_ids def _bus_df(self) -> pd.DataFrame: net = self._net @@ -150,7 +151,18 @@ def _build_buses(self) -> pd.DataFrame: # `net.get_buses()`, so they are dropped here -- same guard as # `_olf_compare.py::lightsim_bus_to_iidm`. orig_to_ls = np.asarray(grid._orig_to_ls)[:n_bus] - v_cplx = np.asarray(grid.get_V())[orig_to_ls] + + # a bus fused away by `fuse_zero_impedance_branches` (see `_from_pypowsybl.py`) + # keeps its own row here (fixed-size bus containers) but lost every element to + # its representative, so it is disconnected with no solved voltage of its own + # -- redirect it to the representative bus, which is electrically the same node + # and carries the real solved voltage. `_bus_fusion_rep` is empty for a grid + # built without fusion (or not through `init_from_pypowsybl` at all), in which + # case this is a no-op. + fusion_rep = np.asarray(grid._bus_fusion_rep) + orig_to_ls_v = fusion_rep[orig_to_ls] if fusion_rep.shape[0] else orig_to_ls + + v_cplx = np.asarray(grid.get_V())[orig_to_ls_v] vn_kv = np.asarray(grid.get_bus_vn_kv())[orig_to_ls] res = pd.DataFrame(index=bus_df.index) @@ -176,13 +188,11 @@ def _build_buses(self) -> pd.DataFrame: # lines / transformers # ------------------------------------------------------------------ # def get_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: - if "lines" not in self._cache: - self._cache["lines"] = self._build_two_sided(self._grid.get_lines()) + self._ensure_lines_trafos() return self._maybe_select(self._cache["lines"], attributes) def get_2_windings_transformers(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: - if "trafos" not in self._cache: - self._cache["trafos"] = self._build_two_sided(self._grid.get_trafos()) + self._ensure_lines_trafos() return self._maybe_select(self._cache["trafos"], attributes) _TWO_SIDED_COLUMNS = ["id", "p1", "q1", "i1", "p2", "q2", "i2", @@ -203,6 +213,179 @@ def _build_two_sided(self, container) -> pd.DataFrame: }) return self._records_to_frame(records, self._TWO_SIDED_COLUMNS) + def _ensure_lines_trafos(self) -> None: + """Build (and cache) `lines`/`trafos` together, then patch in the flow + through any *fused* (near-zero-impedance) branch where recoverable -- + see :meth:`_reconstruct_fused_branches`. Built jointly (rather than each + lazily on its own first call) because reconstructing a fused line may + need an already-built *trafos* frame to sum up a shared bus's other + elements, and vice versa; done as a raw build + a patch pass, rather than + interleaving, to avoid the two triggering each other recursively. + """ + if "lines" in self._cache and "trafos" in self._cache: + return + lines_df = self._build_two_sided(self._grid.get_lines()) + trafos_df = self._build_two_sided(self._grid.get_trafos()) + fused_line_ids, fused_trafo_ids = self._fused_ids() + if fused_line_ids or fused_trafo_ids: + self._reconstruct_fused_branches(lines_df, trafos_df, fused_line_ids, fused_trafo_ids) + self._cache["lines"] = lines_df + self._cache["trafos"] = trafos_df + + def _fused_ids(self): + """(fused_line_ids, fused_trafo_ids): ids of the branches + `fuse_zero_impedance_branches` fused away when the grid was built (see + `_from_pypowsybl.py`), stashed by that function into `_init_kwargs` as + `"\\x1f"`-joined strings. Empty frozensets for a grid built without + fusion (or not through `init_from_pypowsybl` at all). + """ + if self._fused_ids_cache is None: + kwargs = self._grid._init_kwargs + line_ids = frozenset(kwargs.get("fused_line_ids", "").split("\x1f")) - {""} + trafo_ids = frozenset(kwargs.get("fused_trafo_ids", "").split("\x1f")) - {""} + self._fused_ids_cache = (line_ids, trafo_ids) + return self._fused_ids_cache + + def _bus_balance(self, bus_id, one_sided_topo, two_sided_topo, exclude): + """Sum, in the common load-convention sign (positive = power flows + *from* the bus *into* the element -- matching every `get_*` method here, + see the module docstring), of every element attached to `bus_id`, + excluding the branch id `exclude` on either of its sides. By Kirchhoff's + current law this equals the negative of `exclude`'s own flow at this + bus once every *other* element is accounted for -- see + :meth:`_reconstruct_fused_branches`. + + ``one_sided_topo``/``two_sided_topo`` (built once by + :meth:`_reconstruct_fused_branches`, not per bus) pair each element + type's *true, pre-fusion* bus assignment -- read from ``self._net``'s + own static topology -- with this view's own already-computed result + frame. The true assignment must come from ``self._net``, NOT from this + view's own ``bus_id``/``bus1_id``/``bus2_id`` columns: an element + originally on a bus fused away by `fuse_zero_impedance_branches` + reports the *fusion representative's* pypowsybl id there instead (see + `_ls_bus_to_pypo`), which would silently hide it from this sum. + """ + p = q = 0.0 + for orig_bus, df in one_sided_topo: + common = df.index.intersection(orig_bus.index[orig_bus == bus_id]) + if len(common): + p += df.loc[common, "p"].sum() + q += df.loc[common, "q"].sum() + for orig_bus1, orig_bus2, df in two_sided_topo: + c1 = df.index.intersection(orig_bus1.index[(orig_bus1 == bus_id) & (orig_bus1.index != exclude)]) + c2 = df.index.intersection(orig_bus2.index[(orig_bus2 == bus_id) & (orig_bus2.index != exclude)]) + if len(c1): + p += df.loc[c1, "p1"].sum() + q += df.loc[c1, "q1"].sum() + if len(c2): + p += df.loc[c2, "p2"].sum() + q += df.loc[c2, "q2"].sum() + return p, q + + def _reconstruct_fused_branches(self, lines_df, trafos_df, fused_line_ids, fused_trafo_ids) -> None: + """Recover the flow through a fused (near-)zero-impedance branch (see + `_from_pypowsybl.py`'s `fuse_zero_impedance_branches`) wherever the + physics make it unambiguous, and patch `lines_df`/`trafos_df` in place. + + A fused branch is deactivated in the lightsim2grid model (its two + terminal buses were merged into one, see `_build_buses`), so + `lines_df`/`trafos_df` initially carry it as disconnected with 0 flow -- + correct for the *reduced* network lightsim2grid actually solved, but not + what a caller comparing against the original (unfused) topology expects. + + The true flow is recoverable by Kirchhoff's current law at an original + endpoint bus that has EXACTLY ONE fused branch attached (a "leaf" of the + fused sub-graph): every other element at that bus already has its + correct flow (see :meth:`_bus_balance`), and their sum must be exactly + zero, so the fused branch's own flow at that side is minus that sum. + + A bus where 2+ fused branches meet (an internal node of a longer fused + chain/star, eg two zero-impedance lines in series) has no such single + equation -- how the combined flow splits between those branches is + genuinely indeterminate from the solved state alone, so it is left + as-is (disconnected, 0 flow) rather than guessed at. + + Since these branches are (near-)zero-impedance by construction (the + fusion precondition), a side reconstructed this way is mirrored, + lossless, to the other side (p/q negated, current magnitude unchanged) + when that other side is not itself independently reconstructable. + """ + orig_lines = self._net.get_lines( + attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"] + ) + orig_trafos = self._net.get_2_windings_transformers( + attributes=["bus1_id", "bus2_id", "voltage_level1_id", "voltage_level2_id"] + ) + + # degree, in the fused sub-graph, of every original bus touched by ANY + # fused branch (lines and trafos together) + touches = [] + for ids, orig in ((fused_line_ids, orig_lines), (fused_trafo_ids, orig_trafos)): + if not ids: + continue + sub = orig.loc[orig.index.intersection(list(ids))] + touches.append(sub["bus1_id"]) + touches.append(sub["bus2_id"]) + if not touches: + return + degree = pd.concat(touches).value_counts() + + bus_v = self.get_buses(attributes=["v_mag"])["v_mag"] + + # true (pre-fusion) bus assignment of every element, straight from the + # original network's own static topology -- built once here, not per bus, + # see :meth:`_bus_balance` for why this must be `self._net`, not this + # view's own bus_id/bus1_id/bus2_id columns. + net = self._net + one_sided_topo = [ + (net.get_loads(attributes=["bus_id"])["bus_id"], self.get_loads()), + (net.get_generators(attributes=["bus_id"])["bus_id"], self.get_generators()), + (net.get_shunt_compensators(attributes=["bus_id"])["bus_id"], self.get_shunt_compensators()), + (net.get_static_var_compensators(attributes=["bus_id"])["bus_id"], self.get_static_var_compensators()), + (net.get_batteries(attributes=["bus_id"])["bus_id"], self.get_batteries()), + (net.get_vsc_converter_stations(attributes=["bus_id"])["bus_id"], self.get_vsc_converter_stations()), + (net.get_lcc_converter_stations(attributes=["bus_id"])["bus_id"], self.get_lcc_converter_stations()), + ] + two_sided_topo = [ + (orig_lines["bus1_id"], orig_lines["bus2_id"], lines_df), + (orig_trafos["bus1_id"], orig_trafos["bus2_id"], trafos_df), + ] + + for ids, orig, df in ((fused_line_ids, orig_lines, lines_df), + (fused_trafo_ids, orig_trafos, trafos_df)): + for br_id in ids: + if br_id not in orig.index: + continue + bus1, bus2 = orig.loc[br_id, "bus1_id"], orig.loc[br_id, "bus2_id"] + vl1, vl2 = orig.loc[br_id, "voltage_level1_id"], orig.loc[br_id, "voltage_level2_id"] + leaf1 = degree.get(bus1, 0) == 1 + leaf2 = degree.get(bus2, 0) == 1 + if not leaf1 and not leaf2: + continue # genuinely indeterminate, leave as-is + + p1 = q1 = p2 = q2 = None + if leaf1: + other_p, other_q = self._bus_balance(bus1, one_sided_topo, two_sided_topo, exclude=br_id) + p1, q1 = -other_p, -other_q + if leaf2: + other_p, other_q = self._bus_balance(bus2, one_sided_topo, two_sided_topo, exclude=br_id) + p2, q2 = -other_p, -other_q + if p1 is None: + p1, q1 = -p2, -q2 + if p2 is None: + p2, q2 = -p1, -q1 + + v1 = bus_v.get(bus1, np.nan) + v2 = bus_v.get(bus2, np.nan) + i1 = np.hypot(p1, q1) / (np.sqrt(3) * v1) * 1000. if v1 else np.nan + i2 = np.hypot(p2, q2) / (np.sqrt(3) * v2) * 1000. if v2 else np.nan + + df.loc[br_id, "p1"], df.loc[br_id, "q1"], df.loc[br_id, "i1"] = p1, q1, i1 + df.loc[br_id, "p2"], df.loc[br_id, "q2"], df.loc[br_id, "i2"] = p2, q2, i2 + df.loc[br_id, "bus1_id"], df.loc[br_id, "bus2_id"] = bus1, bus2 + df.loc[br_id, "voltage_level1_id"], df.loc[br_id, "voltage_level2_id"] = vl1, vl2 + df.loc[br_id, "connected1"], df.loc[br_id, "connected2"] = True, True + # ------------------------------------------------------------------ # # generators / loads / shunts / svc / batteries: one-sided elements # ------------------------------------------------------------------ # diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format3.lsb similarity index 98% rename from lightsim2grid/tests/binary_format_fixture/case14_sandbox_format2.lsb rename to lightsim2grid/tests/binary_format_fixture/case14_sandbox_format3.lsb index 1fd0aa645a98a747897cf42298217338c93805ce..6379853c86e2aeab024eedf72abbc95e96c3387b 100644 GIT binary patch delta 18 ZcmeBHovh006YONfypgp{XtDtt2LLdv1fu`| delta 14 VcmbQN+O5j!6YONfw2`$<2ml^`1JM8g diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 97921f27..e8f93c97 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -538,13 +538,13 @@ def test_converter_station_type(self): {"VSC": 0, "LCC": 1}) -# reference file saved with binary format 2 (see BINARY_FORMAT_VERSION in +# reference file saved with binary format 3 (see BINARY_FORMAT_VERSION in # src/core/BinaryArchive.hpp) + a few values it is known to contain, used by # TestBinaryLayoutUnchanged below. Regenerate (only after a deliberate format # bump) with: python -m lightsim2grid.tests.test_binary_serialization regen FIXTURE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "binary_format_fixture", - "case14_sandbox_format2.lsb") + "case14_sandbox_format3.lsb") FIXTURE_ENV_NAME = "l2rpn_case14_sandbox" FIXTURE_N_SUB = 14 FIXTURE_N_LOAD = 11 diff --git a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py index 7a25063d..af8dfd09 100644 --- a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py +++ b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py @@ -25,7 +25,7 @@ try: import pypowsybl.network as pn - from lightsim2grid.network import init_from_pypowsybl + from lightsim2grid.network import init_from_pypowsybl, LightsimResultNetwork HAS_PYPOWSYBL = True except ImportError: HAS_PYPOWSYBL = False @@ -255,5 +255,151 @@ def test_default_is_false_and_unchanged(self): np.testing.assert_array_almost_equal(Vdc1, Vdc2) +class TestBusFusionResultNetwork(unittest.TestCase): + """`LightsimResultNetwork` (`_result_network.py`) built on top of a fused + grid: a fused-away bus must report the representative's real solved + voltage (not 0.0, see `_build_buses`), and the flow through the fused + branch itself must be recoverable by Kirchhoff's current law wherever a + leaf endpoint makes it unambiguous (see `_reconstruct_fused_branches`). + + Found on a real RTE grid (`PtFige-20241018-0355`): a zero-impedance line + (`CPNIEL61ZSINA`) merged bus `ZSINAP6_0` into `CPNIEP6_0`; the fused-away + bus read `v_mag=0.0` and the fused line itself read 0 flow instead of the + ~250 MW / 634 A OpenLoadFlow (with outer loops disabled by `outerLoopNames: + DistributedSlack`) actually carries through it. + """ + + def setUp(self): + if not HAS_PYPOWSYBL: + self.skipTest("pypowsybl is required") + + def _build_leaf_chain(self): + # B1 --(zero-Z, fused)-- B2 --(real line)-- B3 + # G1 on B1 (slack-ish), LD1 on B3: B1 and B2 are each a "leaf" of the + # fused sub-graph (exactly one fused branch touches them), so both + # sides of L12 are independently reconstructable and must agree. + net = pn.create_empty() + net.create_substations(id="S1") + for vl in ("VL1", "VL2", "VL3"): + net.create_voltage_levels(substation_id="S1", id=vl, nominal_v=225.0, + topology_kind="BUS_BREAKER") + net.create_buses(voltage_level_id="VL1", id="B1") + net.create_buses(voltage_level_id="VL2", id="B2") + net.create_buses(voltage_level_id="VL3", id="B3") + net.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", + target_p=50.0, target_v=225.0, + min_p=0.0, max_p=200.0, voltage_regulator_on=True) + net.create_loads(id="LD1", voltage_level_id="VL3", bus_id="B3", p0=20.0, q0=5.0) + net.create_lines(id="L12", voltage_level1_id="VL1", bus1_id="B1", + voltage_level2_id="VL2", bus2_id="B2", + r=0.0, x=0.0, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + net.create_lines(id="L23", voltage_level1_id="VL2", bus1_id="B2", + voltage_level2_id="VL3", bus2_id="B3", + r=0.01, x=0.1, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + return net + + def test_leaf_branch_flow_and_voltage_reconstructed(self): + net = self._build_leaf_chain() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=True) + Vdc, ok = _dc_pf_ok(model) + self.assertTrue(ok) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + # `net.get_buses()` (the *bus view*, used throughout `_from_pypowsybl.py`/ + # `_result_network.py`) computes its own ids from the voltage level for a + # BUS_BREAKER-topology network -- "VL1_0", not the bus-breaker-view id + # ("B1") passed to `create_buses`/`create_lines(bus1_id=...)` above. + bus1, bus2 = "VL1_0", "VL2_0" + + result = LightsimResultNetwork(model, net) + buses = result.get_buses() + self.assertGreater(buses.loc[bus1, "v_mag"], 1.0, + "sanity: not the pre-fix bug where a fused-away bus read 0.0") + self.assertAlmostEqual( + buses.loc[bus1, "v_mag"], buses.loc[bus2, "v_mag"], places=6, + msg="B1 and B2 are the same electrical node (fused by a zero-impedance " + "line): their reported voltage must match exactly") + + lines = result.get_lines() + fused = lines.loc["L12"] + other = lines.loc["L23"] + self.assertTrue(bool(fused["connected1"])) + self.assertTrue(bool(fused["connected2"])) + self.assertEqual(fused["bus1_id"], bus1) + self.assertEqual(fused["bus2_id"], bus2) + # sanity: not the pre-fix bug where a fused (deactivated) branch read 0 flow + self.assertGreater(abs(fused["p1"]), 1.0) + # lossless (zero-impedance) branch: side 1 exactly mirrors side 2 + self.assertAlmostEqual(fused["p1"], -fused["p2"], places=6) + self.assertAlmostEqual(fused["q1"], -fused["q2"], places=6) + self.assertAlmostEqual(fused["i1"], fused["i2"], places=6) + # Kirchhoff's current law at B2: the fused line's inflow plus L23's + # outflow (its bus1 side, since bus1(L23) == B2) must sum to zero -- + # B2 carries no other element. + self.assertAlmostEqual(fused["p2"] + other["p1"], 0.0, places=4) + self.assertAlmostEqual(fused["q2"] + other["q1"], 0.0, places=4) + + def test_internal_hub_branch_left_unreconstructed(self): + # B1 --(zero-Z)-- B2 --(zero-Z)-- B3 --(zero-Z)-- B4 --(real line)-- B5, + # G1 on B1, LD1 on B5. B2 and B3 each carry TWO fused branches (an + # internal node of the fused chain, not a leaf): the middle branch L23 + # has neither endpoint a leaf, so its flow is genuinely indeterminate + # from the solved state alone and must be left disconnected/0 rather + # than guessed -- unlike L12/L34, whose far endpoint (B1/B2's other + # neighbour) IS a leaf. The load sits behind a real (non-fused) line + # on its own bus B5, not directly on B4: fusing B1..B4 together AND + # putting the load on that same merged bus would leave the reduced + # network with zero non-slack buses, a separate, pre-existing crash + # (see CHANGELOG TODO / `project_dc_pf_sigfpe_trivial_network`) + # unrelated to what this test is checking. + net = pn.create_empty() + net.create_substations(id="S1") + for vl in ("VL1", "VL2", "VL3", "VL4", "VL5"): + net.create_voltage_levels(substation_id="S1", id=vl, nominal_v=225.0, + topology_kind="BUS_BREAKER") + for b, vl in (("B1", "VL1"), ("B2", "VL2"), ("B3", "VL3"), ("B4", "VL4"), ("B5", "VL5")): + net.create_buses(voltage_level_id=vl, id=b) + net.create_generators(id="G1", voltage_level_id="VL1", bus_id="B1", + target_p=50.0, target_v=225.0, + min_p=0.0, max_p=200.0, voltage_regulator_on=True) + net.create_loads(id="LD1", voltage_level_id="VL5", bus_id="B5", p0=20.0, q0=5.0) + for lid, (vl1, b1, vl2, b2, r, x) in { + "L12": ("VL1", "B1", "VL2", "B2", 0.0, 0.0), + "L23": ("VL2", "B2", "VL3", "B3", 0.0, 0.0), + "L34": ("VL3", "B3", "VL4", "B4", 0.0, 0.0), + "L45": ("VL4", "B4", "VL5", "B5", 0.01, 0.1), + }.items(): + net.create_lines(id=lid, voltage_level1_id=vl1, bus1_id=b1, + voltage_level2_id=vl2, bus2_id=b2, + r=r, x=x, g1=0.0, b1=0.0, g2=0.0, b2=0.0) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + model = init_from_pypowsybl(net, sort_index=False, + fuse_zero_impedance_branches=True) + Vdc, ok = _dc_pf_ok(model) + self.assertTrue(ok) + V = model.ac_pf(Vdc, 30, 1e-8) + self.assertGreater(V.shape[0], 0) + + result = LightsimResultNetwork(model, net) + lines = result.get_lines() + self.assertGreater(abs(lines.loc["L12", "p1"]), 1.0, + "L12 has a leaf endpoint (B1) and should be reconstructed") + self.assertGreater(abs(lines.loc["L34", "p2"]), 1.0, + "L34 has a leaf endpoint (B4) and should be reconstructed") + hub = lines.loc["L23"] + self.assertFalse(bool(hub["connected1"]), + "L23 has no leaf endpoint (both B2 and B3 carry 2 fused " + "branches): its split is indeterminate and must be left as-is") + self.assertFalse(bool(hub["connected2"])) + self.assertEqual(hub["p1"], 0.0) + self.assertEqual(hub["p2"], 0.0) + + if __name__ == "__main__": unittest.main() diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index a6a4f15e..cb073078 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -51,6 +51,16 @@ between 0 and `n_sub_ * max_nb_bus_per_sub_` dict (str -> str) of the relevant kwargs this grid was built with (eg by `init_from_pypowsybl`), for example {"sort_index": "True", "buses_for_sub": "False"}. Empty for a grid not built that way, or a default-constructed one. +)mydelimiter") + .def_property("_bus_fusion_rep", + &LSGrid::get_bus_fusion_rep, + &LSGrid::set_bus_fusion_rep, + R"mydelimiter( +Fused-bus lookup, size `total_bus()` (empty if unset / not built with bus fusion). +For each ls bus id, gives the ls bus id of the "representative" bus it was merged +into by `fuse_zero_impedance_branches` (identity for a bus not involved in any +fusion). Set by `init_from_pypowsybl`; only ever read by downstream Python result +views (eg `LightsimResultNetwork`), never by any C++ powerflow logic. )mydelimiter") .def_property_readonly("timer_last_ac_pf", &LSGrid::timer_last_ac_pf, "TODO") .def_property_readonly("timer_last_dc_pf", &LSGrid::timer_last_dc_pf, "TODO"); diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 34999bbb..5488057e 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -73,7 +73,7 @@ namespace ls2g { // of being silently mis-read. If a future version wants to keep reading an // older format, add that format number to SUPPORTED_BINARY_FORMATS (in // BinaryArchive.cpp) together with the required migration code. -constexpr std::uint32_t BINARY_FORMAT_VERSION = 2; +constexpr std::uint32_t BINARY_FORMAT_VERSION = 3; class LS2G_API BinaryArchive { diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 3889d443..24539c7c 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -21,6 +21,7 @@ LSGrid::LSGrid(const LSGrid & other) sn_mva_ = other.sn_mva_; compute_results_ = other.compute_results_; init_kwargs_ = other.init_kwargs_; + _bus_fusion_rep = other._bus_fusion_rep; // copy the powersystem representation // 1. bus @@ -101,6 +102,7 @@ LSGrid::StateRes LSGrid::get_state() const init_kwargs_keys.push_back(kv.first); init_kwargs_values.push_back(kv.second); } + std::vector bus_fusion_rep(_bus_fusion_rep.begin(), _bus_fusion_rep.end()); LSGrid::StateRes res(version_major, version_medium, @@ -124,7 +126,8 @@ LSGrid::StateRes LSGrid::get_state() const res_ac_algo_cfg, res_dc_algo_cfg, init_kwargs_keys, - init_kwargs_values + init_kwargs_values, + bus_fusion_rep ); return res; }; @@ -183,6 +186,8 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // relevant kwargs the grid was built with (eg by init_from_pypowsybl) const std::vector & init_kwargs_keys = std::get(my_state); const std::vector & init_kwargs_values = std::get(my_state); + // fused-bus representative lookup (see get_bus_fusion_rep()) + const std::vector & bus_fusion_rep = std::get(my_state); // substations last_bus_status_saved_ = last_bus_status_saved; @@ -241,6 +246,10 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) for (std::size_t i = 0; i < init_kwargs_keys.size(); ++i) { init_kwargs_[init_kwargs_keys[i]] = init_kwargs_values[i]; } + + // fused-bus representative lookup -- must run after substations_ is restored + // above, same reasoning as set_ls_to_orig() (validates against total_bus()). + set_bus_fusion_rep(IntVect::Map(bus_fusion_rep.data(), bus_fusion_rep.size())); }; void LSGrid::save_binary(const std::string & path, bool atomic) const { diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 403c9176..8bc972bb 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -95,7 +95,10 @@ class LS2G_API LSGrid final // a string->string map flattened into parallel key/value vectors (appended; // old pickles are version-gated). See get_init_kwargs()/set_init_kwargs(). std::vector, // init_kwargs keys - std::vector // init_kwargs values + std::vector, // init_kwargs values + // fused-bus representative lookup (appended; old pickles/binary + // formats are version-gated). See get_bus_fusion_rep(). + std::vector // bus_fusion_rep >; // named indices into the StateRes tuple above (get_state()/set_state() @@ -124,6 +127,7 @@ class LS2G_API LSGrid final static const std::size_t DC_ALGO_CONFIG_ID = 20; static const std::size_t INIT_KWARGS_KEYS_ID = 21; static const std::size_t INIT_KWARGS_VALUES_ID = 22; + static const std::size_t BUS_FUSION_REP_ID = 23; LSGrid(): timer_last_ac_pf_(0.), @@ -1681,6 +1685,28 @@ class LS2G_API LSGrid final [[nodiscard]] const std::map & get_init_kwargs() const { return init_kwargs_;} void set_init_kwargs(const std::map & init_kwargs) { init_kwargs_ = init_kwargs;} + /** + * Fused-bus lookup, size `total_bus()` (empty if unset / not built with bus + * fusion). For each ls bus id, gives the ls bus id of the "representative" bus + * it was merged into by `fuse_zero_impedance_branches` (identity for a bus not + * involved in any fusion). Set by the Python-side converter (`init_from_pypowsybl`, + * see `_from_pypowsybl.py`); never read by any C++ powerflow logic -- purely so a + * downstream Python consumer (eg `LightsimResultNetwork`) can recover the voltage + * of a bus whose own lightsim bus ended up with no elements (and so disconnected) + * after fusion, by redirecting to its representative, which carries the real + * solved voltage. + */ + [[nodiscard]] const IntVect & get_bus_fusion_rep() const { return _bus_fusion_rep;} + void set_bus_fusion_rep(const IntVect & bus_fusion_rep){ + if(bus_fusion_rep.size() != 0 && static_cast(bus_fusion_rep.size()) != total_bus()){ + std::ostringstream exc_; + exc_ << "LSGrid::set_bus_fusion_rep: the provided vector has size "; + exc_ << bus_fusion_rep.size() << " but this grid counts " << total_bus() << " buses."; + throw std::runtime_error(exc_.str()); + } + _bus_fusion_rep = bus_fusion_rep; + } + void fillSbus_other(CplxVect & res, bool ac, const SolverBusIdVect& id_me_to_solver){ fillSbus_me(res, ac, id_me_to_solver); } @@ -1991,6 +2017,7 @@ class LS2G_API LSGrid final * between 0 and `n_sub_ * max_nb_bus_per_sub_` */ IntVect _orig_to_ls; + IntVect _bus_fusion_rep; // see get_bus_fusion_rep() // member of the grid double timer_last_ac_pf_; From ab72dfe8ec26d15237669af783bd7d9aa4842f98 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 12:16:00 +0200 Subject: [PATCH 115/166] update CHANGELOG Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b719649e..74af4053 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,23 @@ Change Log bake_outer_loops`` (see ``_bake_generator_not_started`` in ``_olf_bake.py``), which rewrites ``voltage_regulator_on=False`` in the IIDM network for such generators before conversion, rather than relying on this C++ mechanism. +- ``fuse_zero_impedance_branches`` (``from_pypowsybl.init()``) deactivates a fused + branch and leaves its "loser" bus with no elements, so the *reduced* network + lightsim2grid actually solves no longer knows that bus's voltage or that + branch's flow. This is invisible to the C++ solve itself (the reduced problem + is solved correctly), but it means ``LSGrid.get_V()``/``get_lines()`` alone + cannot answer "what is bus X's voltage" / "what flows through branch Y" for a + fused-away bus or branch -- only ``LightsimResultNetwork`` + (``_result_network.py``) currently reconstructs these (fused-bus voltage via + the new ``LSGrid._bus_fusion_rep``; a leaf-endpoint branch's flow via + Kirchhoff's current law, see ``_reconstruct_fused_branches``), so anyone + reading the grid directly through the C++ API (or another Python wrapper) + still sees ``v_mag=0`` / 0 flow. What's implemented is fine for now, but at + some point this should be pushed down into the C++ core itself (eg have + ``LSGrid`` report a fused-away bus's voltage as its representative's + directly, and/or not deactivate a fused branch attached to only one other + fused branch) so every consumer benefits, not just callers that go through + ``LightsimResultNetwork``. TODO: speed directly update the pv, pq, Sbus and Ybus part when updating the elements (less error prone and faster to recompute). Then what is passed to the solver @@ -117,6 +134,24 @@ TODO: speed: `BaseBatchSolverSynch::compute_amps_flows` / `compute_active_power_ - [BREAKING] (pickle) the HVDC / DC lines are now stored in a dedicated, richer `HvdcLineContainer` (with converter stations and droop control). Grids pickled with a previous version of lightsim2grid can no longer be unpickled. +- [ADDED] `LSGrid._bus_fusion_rep`: a property (get/set, persisted through pickle and + ``save_binary``/``load_binary``) set by ``init_from_pypowsybl(..., + fuse_zero_impedance_branches=True)``, giving for each lightsim2grid bus id the id of + the "representative" bus it was fused into (identity for a bus not involved in any + fusion). Used by ``LightsimResultNetwork`` to fix the bug below. +- [FIXED] `LightsimResultNetwork` (`lightsim2grid.network.LightsimResultNetwork`, built + on a grid loaded with `fuse_zero_impedance_branches=True`) reported `v_mag=0` for a + bus fused away by a (near-)zero-impedance branch: that bus's own lightsim2grid bus + ends up with no elements after fusion (they were all repointed to the fusion + representative), hence disconnected with no solved voltage of its own. It is now + reported as its representative's voltage (the two are electrically the same node). + The fused branch itself similarly read 0 flow / disconnected; its flow is now + reconstructed via Kirchhoff's current law wherever an endpoint bus has exactly one + fused branch attached (see `_reconstruct_fused_branches` in `_result_network.py`); + a bus where 2+ fused branches meet is left as-is, the split being genuinely + indeterminate from the solved state alone. Found on a real RTE grid + (`PtFige-20241018-0355`, see the `[TODO]` entry at the top of this file for the + follow-up C++ work this points at). - [FIXED] `LSGrid.id_me_to_ac_solver()` returned the AC *solver -> gridmodel* mapping (a duplicate of `id_ac_solver_to_me()`) instead of the *gridmodel -> AC solver* mapping. It now returns the correct direction (the DC counterparts were already fine). From c20550af2eb53c912da955e19c2ff1806d68bad5 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 14:47:07 +0200 Subject: [PATCH 116/166] improve handling of const Eigen::Ref Signed-off-by: DONNOT Benjamin --- src/core/AlgorithmSelector.hpp | 26 ++--- src/core/LSGrid.cpp | 4 +- src/core/LSGrid.hpp | 41 ++++---- .../batch_algorithm/BaseBatchSolverSynch.cpp | 20 ++-- .../batch_algorithm/BaseBatchSolverSynch.hpp | 23 +++-- .../batch_algorithm/ContingencyAnalysis.cpp | 18 ++-- .../batch_algorithm/ContingencyAnalysis.hpp | 5 +- src/core/batch_algorithm/TimeSeries.cpp | 10 +- src/core/batch_algorithm/TimeSeries.hpp | 8 +- .../element_container/GeneratorContainer.cpp | 2 +- .../element_container/GeneratorContainer.hpp | 2 +- .../element_container/OneSideContainer.hpp | 8 +- .../element_container/TwoSidesContainer.hpp | 12 +-- src/core/linear_solvers/CKTSOSolver.cpp | 6 +- src/core/linear_solvers/CKTSOSolver.hpp | 6 +- src/core/linear_solvers/KLUSolver.cpp | 6 +- src/core/linear_solvers/KLUSolver.hpp | 6 +- src/core/linear_solvers/NICSLUSolver.cpp | 6 +- src/core/linear_solvers/NICSLUSolver.hpp | 6 +- src/core/linear_solvers/SparseLUSolver.cpp | 6 +- src/core/linear_solvers/SparseLUSolver.hpp | 6 +- src/core/powerflow_algorithm/BaseAlgo.cpp | 54 +++++------ src/core/powerflow_algorithm/BaseAlgo.hpp | 94 ++++++++++--------- src/core/powerflow_algorithm/BaseDCAlgo.hpp | 19 ++-- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 18 ++-- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 33 ++++--- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 18 ++-- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 16 ++-- .../powerflow_algorithm/GaussSeidelAlgo.hpp | 18 ++-- .../GaussSeidelSynchAlgo.cpp | 6 +- .../GaussSeidelSynchAlgo.hpp | 6 +- src/core/powerflow_algorithm/NRAlgo.hpp | 12 ++- src/core/powerflow_algorithm/NRAlgo.tpp | 10 +- src/core/powerflow_algorithm/NRSystem.hpp | 78 +++++++-------- src/core/powerflow_algorithm/NRSystem.tpp | 12 +-- src/core/powerflow_algorithm/NRSystemBase.cpp | 4 +- src/core/powerflow_algorithm/NRSystemHvdc.cpp | 4 +- .../NRSystemMultiSlack.cpp | 4 +- .../NRSystemVoltageControl.cpp | 4 +- .../powerflow_algorithm/SlackPolicies.hpp | 24 ++--- .../powerflow_algorithm/SlackPolicies.tpp | 24 ++--- 41 files changed, 360 insertions(+), 325 deletions(-) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 365744e7..19e3f525 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -151,13 +151,15 @@ class LS2G_API AlgorithmSelector final get_prt_solver("reset", false)->reset(); } - bool compute_pf(const Eigen::SparseMatrix& Ybus, + // Ybus stays a plain reference: it must pass through to the underlying + // solver's compute_pf unchanged (see BaseAlgo::compute_pf_with_input_validation). + bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol) { @@ -167,13 +169,13 @@ class LS2G_API AlgorithmSelector final } // Native real-valued DC entry point (only valid for DC solvers). - bool compute_pf_dc(const Eigen::SparseMatrix& Bbus, + bool compute_pf_dc(const Eigen::Ref> & Bbus, CplxVect& V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { _algo_type_used_for_nr = _algo_type; return get_prt_solver("compute_pf_dc", true)->compute_pf_dc( diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 24539c7c..ec913356 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -1637,8 +1637,8 @@ void LSGrid::update_storages_p(const Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_topo(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { loads_.update_topo(has_changed, new_values, algo_controler_, substations_); generators_.update_topo(has_changed, new_values, algo_controler_, substations_); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 8bc972bb..876d6ca6 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -204,7 +204,7 @@ class LS2G_API LSGrid final void update_slack_weights(const Eigen::Ref > & could_be_slack){ generators_.update_slack_weights(could_be_slack, algo_controler_); } - void update_slack_weights_by_id(Eigen::Ref slack_ids){ + void update_slack_weights_by_id(const Eigen::Ref & slack_ids){ generators_.update_slack_weights_by_id(slack_ids, algo_controler_); } @@ -1583,69 +1583,69 @@ class LS2G_API LSGrid final * The new_values are given in LocalBusId (-1, 1, 2 etc.) and not in * SolverBusId nor GridModelBusId */ - void update_topo(Eigen::Ref > has_changed, - Eigen::Ref > new_values); + void update_topo(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); void update_storages_p(const Eigen::Ref > & has_changed, const Eigen::Ref > & new_values); - void set_load_pos_topo_vect(Eigen::Ref load_pos_topo_vect) + void set_load_pos_topo_vect(const Eigen::Ref & load_pos_topo_vect) { loads_.set_pos_topo_vect(load_pos_topo_vect); } - void set_gen_pos_topo_vect(Eigen::Ref gen_pos_topo_vect) + void set_gen_pos_topo_vect(const Eigen::Ref & gen_pos_topo_vect) { generators_.set_pos_topo_vect(gen_pos_topo_vect); } - void set_storage_pos_topo_vect(Eigen::Ref sto_pos_topo_vect) + void set_storage_pos_topo_vect(const Eigen::Ref & sto_pos_topo_vect) { storages_.set_pos_topo_vect(sto_pos_topo_vect); } - void set_line_pos1_topo_vect(Eigen::Ref line_or_pos_topo_vect) + void set_line_pos1_topo_vect(const Eigen::Ref & line_or_pos_topo_vect) { powerlines_.set_pos_topo_vect_side_1(line_or_pos_topo_vect); } - void set_line_pos2_topo_vect(Eigen::Ref line_ex_pos_topo_vect) + void set_line_pos2_topo_vect(const Eigen::Ref & line_ex_pos_topo_vect) { powerlines_.set_pos_topo_vect_side_2(line_ex_pos_topo_vect); } - void set_trafo_pos1_topo_vect(Eigen::Ref trafo_hv_pos_topo_vect) + void set_trafo_pos1_topo_vect(const Eigen::Ref & trafo_hv_pos_topo_vect) { trafos_.set_pos_topo_vect_side_1(trafo_hv_pos_topo_vect); } - void set_trafo_pos2_topo_vect(Eigen::Ref trafo_lv_pos_topo_vect) + void set_trafo_pos2_topo_vect(const Eigen::Ref & trafo_lv_pos_topo_vect) { trafos_.set_pos_topo_vect_side_2(trafo_lv_pos_topo_vect); } - void set_load_to_subid(Eigen::Ref load_to_subid) + void set_load_to_subid(const Eigen::Ref & load_to_subid) { loads_.set_subid(load_to_subid); } - void set_gen_to_subid(Eigen::Ref gen_to_subid) + void set_gen_to_subid(const Eigen::Ref & gen_to_subid) { generators_.set_subid(gen_to_subid); } - void set_storage_to_subid(Eigen::Ref storage_to_subid) + void set_storage_to_subid(const Eigen::Ref & storage_to_subid) { storages_.set_subid(storage_to_subid); } - void set_shunt_to_subid(Eigen::Ref shunt_to_subid) + void set_shunt_to_subid(const Eigen::Ref & shunt_to_subid) { shunts_.set_subid(shunt_to_subid); } - void set_line_to_sub1_id(Eigen::Ref line_or_to_subid) + void set_line_to_sub1_id(const Eigen::Ref & line_or_to_subid) { powerlines_.set_subid_side_1(line_or_to_subid); } - void set_line_to_sub2_id(Eigen::Ref line_ex_to_subid) + void set_line_to_sub2_id(const Eigen::Ref & line_ex_to_subid) { powerlines_.set_subid_side_2(line_ex_to_subid); } - void set_trafo_to_sub1_id(Eigen::Ref trafo_hv_to_subid) + void set_trafo_to_sub1_id(const Eigen::Ref & trafo_hv_to_subid) { trafos_.set_subid_side_1(trafo_hv_to_subid); } - void set_trafo_to_sub2_id(Eigen::Ref trafo_lv_to_subid) + void set_trafo_to_sub2_id(const Eigen::Ref & trafo_lv_to_subid) { trafos_.set_subid_side_2(trafo_lv_to_subid); } @@ -1804,7 +1804,10 @@ class LS2G_API LSGrid final * @param relabel_row : whether to relabel also the row id * @return Eigen::SparseMatrix */ - template + // Ybus stays a plain reference: T is deduced from the caller's argument, and + // Eigen::Ref> is a non-deduced context (callers + // pass a concrete Eigen::SparseMatrix, so deduction would fail). + template Eigen::SparseMatrix _relabel_matrix(const Eigen::SparseMatrix & Ybus, const GlobalBusIdVect & id_solver_to_me, bool relabel_row=true) const { diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index 46bb9a1f..0587411d 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -17,11 +17,11 @@ namespace ls2g { bool BaseBatchSolverSynch::compute_one_powerflow( const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ) @@ -43,11 +43,11 @@ bool BaseBatchSolverSynch::compute_one_powerflow( double & timer_solver, const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ) diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index e9b814a6..c2b62fe6 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -295,13 +295,15 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // member version: forwards to the explicit overload below using the // member solver / control / accumulators (single-threaded path). + // Ybus stays a plain reference: it forwards into algo.compute_pf(Ybus, ...), + // whose Ybus must stay plain too (see BaseAlgo::compute_pf). bool compute_one_powerflow(const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ); @@ -310,17 +312,18 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // its book-keeping into the passed accumulators. This is what the // multi-threaded ContingencyAnalysis uses (one solver per thread). The // read-only member Bbus_ is only read here (safe to share across threads). + // Ybus stays a plain reference for the same reason as the overload above. bool compute_one_powerflow(AlgorithmSelector & algo, AlgoControl & control, int & nb_solved, double & timer_solver, const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ); diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 5ce14c35..239948ce 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -28,9 +28,9 @@ namespace { void check_bus_voltage_violations( const CplxVect & V, const SolverBusIdVect & id_me_to_solver, - Eigen::Ref bus_vmin_kv, - Eigen::Ref bus_vmax_kv, - Eigen::Ref bus_vn_kv, + const Eigen::Ref & bus_vmin_kv, + const Eigen::Ref & bus_vmax_kv, + const Eigen::Ref & bus_vn_kv, const std::vector * masked_solver_ids, std::vector & out) { @@ -79,11 +79,11 @@ void check_current_violations( ViolationElementType el_type, const CplxVect & V, const SolverBusIdVect & id_me_to_solver, - Eigen::Ref bus_vn_kv, + const Eigen::Ref & bus_vn_kv, bool ac_solver_used, real_type sn_mva, - Eigen::Ref limit1, - Eigen::Ref limit2, + const Eigen::Ref & limit1, + const Eigen::Ref & limit2, const std::vector & skip_ids, std::vector & out) { @@ -211,15 +211,15 @@ void check_current_violations( } // anonymous namespace -bool ContingencyAnalysis::check_invertible(const Eigen::SparseMatrix & Ybus) const{ - std::vector visited(Ybus.cols(), false); +bool ContingencyAnalysis::check_invertible(const Eigen::Ref> & Ybus) const{ + std::vector visited(Ybus.cols(), false); std::vector already_added(Ybus.cols(), false); std::queue neighborhood; size_t col_id = 0; // start by node 0, why not while (true) { visited[col_id] = true; - for (Eigen::SparseMatrix::InnerIterator it(Ybus, col_id); it; ++it) + for (Eigen::Ref>::InnerIterator it(Ybus, col_id); it; ++it) { // add in the queue all my neighbor (if the coefficient is big enough) if(!visited[it.row()] && !already_added[it.row()] && abs(it.value()) > 1e-8){ diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 9a5627af..b6f01759 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -265,13 +265,16 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch // sometimes, when i perform some disconnection, I make the graph non connexe // in this case, well, i don't use the results of the simulation - bool check_invertible(const Eigen::SparseMatrix & Ybus) const; + bool check_invertible(const Eigen::Ref> & Ybus) const; // ----- "handle disconnected grid" mode helpers (mask mode only) ----------- // connected-component labelling of the admittance matrix: returns the solver // bus ids that are NOT part of the largest connected component (empty if it is // connected). Templated on the scalar type so it works on both the AC (complex // Ybus_) and the DC (real Bbus_) matrices. Explicitly instantiated in the .cpp. + // mat stays a plain reference: T is deduced from the caller's argument, and + // Eigen::Ref> is a non-deduced context (callers + // pass a concrete Eigen::SparseMatrix, so deduction would fail). template std::vector disconnected_buses(const Eigen::SparseMatrix & mat) const; // pre-pass run before the (n-)powerflow: fills _li_masked (the masked bus set diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index 84ba72dd..9a75eba5 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -13,10 +13,10 @@ namespace ls2g { -int TimeSeries::compute_Vs(Eigen::Ref gen_p, - Eigen::Ref sgen_p, - Eigen::Ref load_p, - Eigen::Ref load_q, +int TimeSeries::compute_Vs(const Eigen::Ref & gen_p, + const Eigen::Ref & sgen_p, + const Eigen::Ref & load_p, + const Eigen::Ref & load_q, const Eigen::Ref & Vinit, const int max_iter, const real_type tol) @@ -48,7 +48,7 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, // temporal_data.col(el_id) (unchecked Eigen .col()), so a matrix with too few // columns over-reads; and _Sbuses is sized with gen_p.rows(), so mismatched row // counts turn the `Sbuses.col(...) += tmp` into an out-of-bounds read/write. - const auto check_mat = [nb_steps](Eigen::Ref mat, Eigen::Index nb_expected_cols, + const auto check_mat = [nb_steps](const Eigen::Ref & mat, Eigen::Index nb_expected_cols, const std::string & name){ if(static_cast(mat.rows()) != nb_steps){ std::ostringstream exc_; diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index 6fc5b472..55bf68fc 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -50,10 +50,10 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch Each line of `Sbuses` will be a time step, and each column with **/ - int compute_Vs(Eigen::Ref gen_p, - Eigen::Ref sgen_p, - Eigen::Ref load_p, - Eigen::Ref load_q, + int compute_Vs(const Eigen::Ref & gen_p, + const Eigen::Ref & sgen_p, + const Eigen::Ref & load_p, + const Eigen::Ref & load_q, const Eigen::Ref & Vinit, const int max_iter, const real_type tol); diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 6f10d5c2..082e453d 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -555,7 +555,7 @@ void GeneratorContainer::update_slack_weights( } void GeneratorContainer::update_slack_weights_by_id( - Eigen::Ref gen_slack_id, + const Eigen::Ref & gen_slack_id, DualAlgoControl & solver_control) { // TODO speed: the solver_control will always tell that the slacks changed diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index e891e7cd..e82d0bcd 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -200,7 +200,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter bool get_turnedoff_gen_pv() const {return turnedoff_gen_pv_;} void update_slack_weights(const Eigen::Ref > & could_be_slack, DualAlgoControl & solver_control); - void update_slack_weights_by_id(Eigen::Ref gen_slack_id, DualAlgoControl & solver_control); + void update_slack_weights_by_id(const Eigen::Ref & gen_slack_id, DualAlgoControl & solver_control); // ---- remote voltage control -------------------------------------------- diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index afd50eba..dcc53ca9 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -259,13 +259,13 @@ class OneSideContainer : public GenericContainer reset_osc_results(); } - void set_pos_topo_vect(Eigen::Ref pos_topo_vect) + void set_pos_topo_vect(const Eigen::Ref & pos_topo_vect) { check_size(pos_topo_vect, nb(), "pos_topo_vect"); pos_topo_vect_.array() = pos_topo_vect; } - void set_subid(Eigen::Ref subid) + void set_subid(const Eigen::Ref & subid) { check_size(subid, nb(), "subid"); subid_.array() = subid; @@ -277,8 +277,8 @@ class OneSideContainer : public GenericContainer * The bus labelling in "new_values" are local bus (between 1 and n_max_busbar_per_sub). */ virtual std::vector update_topo( - Eigen::Ref > & has_changed, - Eigen::Ref > & new_values, + const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values, DualAlgoControl & solver_control, SubstationContainer & substations ) final diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index d7c7d939..f10a4780 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -220,27 +220,27 @@ class TwoSidesContainer : public GenericContainer } } - void set_pos_topo_vect_side_1(Eigen::Ref pos_topo_vect) + void set_pos_topo_vect_side_1(const Eigen::Ref & pos_topo_vect) { side_1_.set_pos_topo_vect(pos_topo_vect); } - void set_pos_topo_vect_side_2(Eigen::Ref pos_topo_vect) + void set_pos_topo_vect_side_2(const Eigen::Ref & pos_topo_vect) { side_2_.set_pos_topo_vect(pos_topo_vect); } - void set_subid_side_1(Eigen::Ref subid) + void set_subid_side_1(const Eigen::Ref & subid) { side_1_.set_subid(subid); } - void set_subid_side_2(Eigen::Ref subid) + void set_subid_side_2(const Eigen::Ref & subid) { side_2_.set_subid(subid); } virtual void update_topo( - Eigen::Ref > & has_changed, - Eigen::Ref > & new_values, + const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values, DualAlgoControl & solver_control, SubstationContainer & substations ) final diff --git a/src/core/linear_solvers/CKTSOSolver.cpp b/src/core/linear_solvers/CKTSOSolver.cpp index e78aa758..f3ea64fa 100644 --- a/src/core/linear_solvers/CKTSOSolver.cpp +++ b/src/core/linear_solvers/CKTSOSolver.cpp @@ -34,7 +34,7 @@ ErrorType CKTSOLinearSolver::reset(){ return ErrorType::NoError; } -ErrorType CKTSOLinearSolver::analyze(const Eigen::SparseMatrix & J){ +ErrorType CKTSOLinearSolver::analyze(const Eigen::Ref> & J){ const long long *oparm = oparm_; int ret_ = CKTSO_CreateSolver(&solver_, &iparm_, &oparm); if (ret_ < 0){ @@ -75,7 +75,7 @@ ErrorType CKTSOLinearSolver::analyze(const Eigen::SparseMatrix & J){ return ErrorType::NoError; } -ErrorType CKTSOLinearSolver::factorize(const Eigen::SparseMatrix & J){ +ErrorType CKTSOLinearSolver::factorize(const Eigen::Ref> & J){ int ret = solver_->Factorize(J.valuePtr(), true // @fast: whether to use fast factorization ); @@ -86,7 +86,7 @@ ErrorType CKTSOLinearSolver::factorize(const Eigen::SparseMatrix & J) return ErrorType::NoError; } -ErrorType CKTSOLinearSolver::refactorize(const Eigen::SparseMatrix & J){ +ErrorType CKTSOLinearSolver::refactorize(const Eigen::Ref> & J){ int ret = solver_->Refactorize(J.valuePtr()); if(ret < 0){ // std::cout << "CKTSOLinearSolver::refactor solver_->Refactorize error: " << ret << std::endl; diff --git a/src/core/linear_solvers/CKTSOSolver.hpp b/src/core/linear_solvers/CKTSOSolver.hpp index da90d9ba..baaf65aa 100644 --- a/src/core/linear_solvers/CKTSOSolver.hpp +++ b/src/core/linear_solvers/CKTSOSolver.hpp @@ -83,9 +83,9 @@ class LS2G_API CKTSOLinearSolver final // public api ErrorType reset(); - ErrorType analyze(const Eigen::SparseMatrix & J); // creates solver + reordering + symbolic factorization - ErrorType factorize(const Eigen::SparseMatrix & J); // numeric factorization (requires values) - ErrorType refactorize(const Eigen::SparseMatrix & J); // re-numeric factorization, reuses symbolic + ErrorType analyze(const Eigen::Ref> & J); // creates solver + reordering + symbolic factorization + ErrorType factorize(const Eigen::Ref> & J); // numeric factorization (requires values) + ErrorType refactorize(const Eigen::Ref> & J); // re-numeric factorization, reuses symbolic ErrorType solve(RealVect & b); // can this linear solver solve problem where RHS is a matrix diff --git a/src/core/linear_solvers/KLUSolver.cpp b/src/core/linear_solvers/KLUSolver.cpp index 7ef7364b..35499024 100644 --- a/src/core/linear_solvers/KLUSolver.cpp +++ b/src/core/linear_solvers/KLUSolver.cpp @@ -22,7 +22,7 @@ ErrorType KLULinearSolver::reset(){ return ErrorType::NoError; } -ErrorType KLULinearSolver::analyze(const Eigen::SparseMatrix& J){ +ErrorType KLULinearSolver::analyze(const Eigen::Ref>& J){ // J is const here, but `klu_analyze` signature expects non-const arrays, so I const_cast const auto n = J.cols(); // free any previous factorization (using the current common_) before resetting it, @@ -38,7 +38,7 @@ ErrorType KLULinearSolver::analyze(const Eigen::SparseMatrix& J){ return ErrorType::NoError; } -ErrorType KLULinearSolver::factorize(const Eigen::SparseMatrix& J){ +ErrorType KLULinearSolver::factorize(const Eigen::Ref>& J){ // J is const here, but `klu_factor` signature expects non-const arrays, so I const_cast // reset() frees any previous numeric factorization before storing the new one numeric_.reset(klu_factor(const_cast::StorageIndex *>(J.outerIndexPtr()), @@ -49,7 +49,7 @@ ErrorType KLULinearSolver::factorize(const Eigen::SparseMatrix& J){ return ErrorType::NoError; } -ErrorType KLULinearSolver::refactorize(const Eigen::SparseMatrix& J){ +ErrorType KLULinearSolver::refactorize(const Eigen::Ref>& J){ // J is const here, but `klu_refactor` signature expects arrays and not const arrays // so I const_cast int ok = klu_refactor(const_cast::StorageIndex *>(J.outerIndexPtr()), diff --git a/src/core/linear_solvers/KLUSolver.hpp b/src/core/linear_solvers/KLUSolver.hpp index 78550d95..5499dcfc 100644 --- a/src/core/linear_solvers/KLUSolver.hpp +++ b/src/core/linear_solvers/KLUSolver.hpp @@ -51,9 +51,9 @@ class LS2G_API KLULinearSolver final // public api ErrorType reset(); - ErrorType analyze(const Eigen::SparseMatrix& J); // reordering + symbolic factorization (structure only) - ErrorType factorize(const Eigen::SparseMatrix& J); // numeric factorization (requires values) - ErrorType refactorize(const Eigen::SparseMatrix& J); // re-numeric factorization, reuses symbolic + ErrorType analyze(const Eigen::Ref>& J); // reordering + symbolic factorization (structure only) + ErrorType factorize(const Eigen::Ref>& J); // numeric factorization (requires values) + ErrorType refactorize(const Eigen::Ref>& J); // re-numeric factorization, reuses symbolic ErrorType solve(RealVect & b); // can this linear solver solve problem where RHS is a matrix diff --git a/src/core/linear_solvers/NICSLUSolver.cpp b/src/core/linear_solvers/NICSLUSolver.cpp index 0a108d82..0d982e59 100644 --- a/src/core/linear_solvers/NICSLUSolver.cpp +++ b/src/core/linear_solvers/NICSLUSolver.cpp @@ -36,7 +36,7 @@ ErrorType NICSLULinearSolver::reset(){ return ErrorType::NoError; } -ErrorType NICSLULinearSolver::analyze(const Eigen::SparseMatrix & J){ +ErrorType NICSLULinearSolver::analyze(const Eigen::Ref> & J){ // NICSLU Analyze may use values for MC64 value-based scaling/ordering const auto n = J.cols(); const unsigned int nnz = J.nonZeros(); @@ -64,7 +64,7 @@ ErrorType NICSLULinearSolver::analyze(const Eigen::SparseMatrix & J){ return ErrorType::NoError; } -ErrorType NICSLULinearSolver::factorize(const Eigen::SparseMatrix & J){ +ErrorType NICSLULinearSolver::factorize(const Eigen::Ref> & J){ // solver.FactorizeMatrix(ax, 0); //use all created threads int ret = solver_.FactorizeMatrix(J.valuePtr(), nb_thread_); if (ret < 0){ @@ -74,7 +74,7 @@ ErrorType NICSLULinearSolver::factorize(const Eigen::SparseMatrix & J return ErrorType::NoError; } -ErrorType NICSLULinearSolver::refactorize(const Eigen::SparseMatrix & J){ +ErrorType NICSLULinearSolver::refactorize(const Eigen::Ref> & J){ int ret = solver_.FactorizeMatrix(J.valuePtr(), nb_thread_); // TODO maybe 0 instead of nb_thread_ here, see https://github.com/chenxm1986/nicslu/blob/master/nicslu202110/demo/demo2.cpp if(ret < 0){ // std::cout << "NICSLULinearSolver::refactor solver_.FactorizeMatrix error: " << ret << std::endl; diff --git a/src/core/linear_solvers/NICSLUSolver.hpp b/src/core/linear_solvers/NICSLUSolver.hpp index b38e6569..9804c6b0 100644 --- a/src/core/linear_solvers/NICSLUSolver.hpp +++ b/src/core/linear_solvers/NICSLUSolver.hpp @@ -70,9 +70,9 @@ class LS2G_API NICSLULinearSolver final // public api ErrorType reset(); - ErrorType analyze(const Eigen::SparseMatrix & J); // reordering + symbolic factorization (may use values for MC64 scaling) - ErrorType factorize(const Eigen::SparseMatrix & J); // numeric factorization (requires values) - ErrorType refactorize(const Eigen::SparseMatrix & J); // re-numeric factorization, reuses symbolic + ErrorType analyze(const Eigen::Ref> & J); // reordering + symbolic factorization (may use values for MC64 scaling) + ErrorType factorize(const Eigen::Ref> & J); // numeric factorization (requires values) + ErrorType refactorize(const Eigen::Ref> & J); // re-numeric factorization, reuses symbolic ErrorType solve(RealVect & b); // can this linear solver solve problem where RHS is a matrix diff --git a/src/core/linear_solvers/SparseLUSolver.cpp b/src/core/linear_solvers/SparseLUSolver.cpp index 4dcfafe3..7e34a472 100644 --- a/src/core/linear_solvers/SparseLUSolver.cpp +++ b/src/core/linear_solvers/SparseLUSolver.cpp @@ -14,19 +14,19 @@ namespace ls2g { const bool SparseLULinearSolver::CAN_SOLVE_MAT = true; -ErrorType SparseLULinearSolver::analyze(const Eigen::SparseMatrix & J){ +ErrorType SparseLULinearSolver::analyze(const Eigen::Ref> & J){ solver_.analyzePattern(J); // analyzePattern does not set solver_.info() to Success, so no check here return ErrorType::NoError; } -ErrorType SparseLULinearSolver::factorize(const Eigen::SparseMatrix & J){ +ErrorType SparseLULinearSolver::factorize(const Eigen::Ref> & J){ solver_.factorize(J); if(solver_.info() != Eigen::Success) return ErrorType::SolverFactor; return ErrorType::NoError; } -ErrorType SparseLULinearSolver::refactorize(const Eigen::SparseMatrix & J){ +ErrorType SparseLULinearSolver::refactorize(const Eigen::Ref> & J){ solver_.factorize(J); if(solver_.info() != Eigen::Success) return ErrorType::SolverFactor; return ErrorType::NoError; diff --git a/src/core/linear_solvers/SparseLUSolver.hpp b/src/core/linear_solvers/SparseLUSolver.hpp index e82b22ce..5625150c 100644 --- a/src/core/linear_solvers/SparseLUSolver.hpp +++ b/src/core/linear_solvers/SparseLUSolver.hpp @@ -38,9 +38,9 @@ class LS2G_API SparseLULinearSolver final ~SparseLULinearSolver() noexcept = default; // public api - ErrorType analyze(const Eigen::SparseMatrix & J); // reordering + symbolic factorization (structure only) - ErrorType factorize(const Eigen::SparseMatrix & J); // numeric factorization (requires values) - ErrorType refactorize(const Eigen::SparseMatrix & J); // re-numeric factorization, reuses symbolic + ErrorType analyze(const Eigen::Ref> & J); // reordering + symbolic factorization (structure only) + ErrorType factorize(const Eigen::Ref> & J); // numeric factorization (requires values) + ErrorType refactorize(const Eigen::Ref> & J); // re-numeric factorization, reuses symbolic ErrorType solve(RealVect & b); ErrorType reset(){ return ErrorType::NoError; diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 28b2639b..e668c054 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -20,7 +20,7 @@ namespace { // `marks` holds, for each bus, 0 (unseen) or the 1-based index into // `array_names` of the array that already claimed it. void check_bus_id_array(const std::string & caller, - Eigen::Ref ids, + const Eigen::Ref & ids, Eigen::Index n, std::vector & marks, int array_code, @@ -53,10 +53,10 @@ void check_bus_id_array(const std::string & caller, void BaseAlgo::check_pf_inputs(const std::string & caller, Eigen::Index ybus_rows, Eigen::Index ybus_cols, Eigen::Index v_size, Eigen::Index sbus_size, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { if (ybus_rows != ybus_cols) { std::ostringstream exc_; @@ -113,11 +113,11 @@ void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_typ bool BaseAlgo::compute_pf_with_input_validation( const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol) { @@ -128,13 +128,13 @@ bool BaseAlgo::compute_pf_with_input_validation( } bool BaseAlgo::compute_pf_dc_with_input_validation( - const Eigen::SparseMatrix & Bbus, + const Eigen::Ref> & Bbus, CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { check_pf_inputs("compute_pf_dc", Bbus.rows(), Bbus.cols(), V.size(), Pbus.size(), slack_ids, slack_weights, pv, pq); @@ -158,11 +158,11 @@ void BaseAlgo::reset(){ } -RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, +RealVect BaseAlgo::_evaluate_Fx(const Eigen::Ref> & Ybus, const CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & Sbus, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { auto timer = CustTimer(); auto npv = pv.size(); @@ -184,14 +184,14 @@ RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, return res; } -RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, +RealVect BaseAlgo::_evaluate_Fx(const Eigen::Ref> & Ybus, const CplxVect & V, - Eigen::Ref Sbus, + const Eigen::Ref & Sbus, size_t slack_id, // id of the ref slack bus real_type slack_absorbed, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { /** Remember, when this function is used: @@ -270,8 +270,8 @@ bool BaseAlgo::_check_for_convergence(const RealVect & p, return res; } -Eigen::VectorXi BaseAlgo::extract_slack_bus_id(Eigen::Ref pv, - Eigen::Ref pq, +Eigen::VectorXi BaseAlgo::extract_slack_bus_id(const Eigen::Ref & pv, + const Eigen::Ref & pq, unsigned int nb_bus) { // pv: list of index of pv nodes diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 757a9586..84d72bec 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -110,10 +110,10 @@ class LS2G_API BaseAlgo : public BaseConstants static void check_pf_inputs(const std::string & caller, Eigen::Index ybus_rows, Eigen::Index ybus_cols, Eigen::Index v_size, Eigen::Index sbus_size, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); // Throws std::runtime_error unless max_iter >= 0 (0 is a legitimate, // well-defined call: every solver builds its initial state / first @@ -133,14 +133,20 @@ class LS2G_API BaseAlgo : public BaseConstants // the method actually bound to python's Solver.compute_pf / .solve // (see binding_solvers.cpp) -- the raw compute_pf keeps its name and // stays reachable only from C++. + // Ybus stays a plain reference (not Eigen::Ref): NRSystem::update_state caches + // its address (Ybus_ptr_) across phases (update_state -> build_J_sparsity), + // which needs the caller's actual, long-lived matrix, not a call-site Ref + // temporary. Since compute_pf is one shared virtual signature (BaseAlgo, + // NRAlgo, GaussSeidelAlgo, BaseFDPFAlgo all share it), every Ybus/Ybus-forwarding + // parameter in this family must stay plain for the same reason. bool compute_pf_with_input_validation( const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol); @@ -149,13 +155,13 @@ class LS2G_API BaseAlgo : public BaseConstants // itself isn't exposed under any name -- kept symmetric with the AC // wrapper above and ready if that binding gap is ever closed. bool compute_pf_dc_with_input_validation( - const Eigen::SparseMatrix & Bbus, + const Eigen::Ref> & Bbus, CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); virtual Eigen::Ref > get_J() const { throw std::runtime_error("AlgorithmSelector::get_J: There is not Jacobian matrix for this solver type."); @@ -282,13 +288,15 @@ class LS2G_API BaseAlgo : public BaseConstants // Every AC solver overrides this; the DC solver does not (it uses `compute_pf_dc`) and // therefore inherits this throwing default (symmetric with `compute_pf_dc` below). virtual + // Ybus stays a plain reference here too -- see the comment on + // compute_pf_with_input_validation above (NRSystem::update_state pointer-caching). bool compute_pf(const Eigen::SparseMatrix & /*Ybus*/, CplxVect & /*V*/, // store the results of the powerflow and the Vinit ! - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/, int /*max_iter*/, real_type /*tol*/ ){ @@ -300,13 +308,13 @@ class LS2G_API BaseAlgo : public BaseConstants // `V` carries the complex initial voltage (slack angle + voltage setpoints) on input // and the complex result on output. There is no max_iter / tol: DC is a single linear solve. virtual - bool compute_pf_dc(const Eigen::SparseMatrix & /*Bbus*/, + bool compute_pf_dc(const Eigen::Ref> & /*Bbus*/, CplxVect & /*V*/, - Eigen::Ref /*Pbus*/, - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/){ + const Eigen::Ref & /*Pbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/){ throw std::runtime_error("compute_pf_dc is only available for DC solvers."); } @@ -354,20 +362,20 @@ class LS2G_API BaseAlgo : public BaseConstants // return res; return (err_ != ErrorType::LicenseError); } - RealVect _evaluate_Fx(const Eigen::SparseMatrix & Ybus, + RealVect _evaluate_Fx(const Eigen::Ref> & Ybus, const CplxVect & V, - Eigen::Ref Sbus, + const Eigen::Ref & Sbus, size_t slack_id, // id of the slack bus real_type slack_absorbed, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); - RealVect _evaluate_Fx(const Eigen::SparseMatrix & Ybus, + RealVect _evaluate_Fx(const Eigen::Ref> & Ybus, const CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref pv, - Eigen::Ref pq); + const Eigen::Ref & Sbus, + const Eigen::Ref & pv, + const Eigen::Ref & pq); bool _check_for_convergence(const RealVect & F, real_type tol); @@ -376,16 +384,16 @@ class LS2G_API BaseAlgo : public BaseConstants const RealVect & q, real_type tol); - Eigen::VectorXi extract_slack_bus_id(Eigen::Ref pv, - Eigen::Ref pq, + Eigen::VectorXi extract_slack_bus_id(const Eigen::Ref & pv, + const Eigen::Ref & pq, unsigned int nb_bus); /** When there are multiple slacks, add the other "slack buses" in the PV buses indexes (behaves as if only the first element is used for the slack !!!, called "ref slack") **/ - Eigen::VectorXi retrieve_pv_with_slack(Eigen::Ref slack_ids, - Eigen::Ref pv) const { + Eigen::VectorXi retrieve_pv_with_slack(const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) const { if(slack_ids.size() > 1){ const auto nb_slack_added = slack_ids.size() - 1; Eigen::VectorXi my_pv = Eigen::VectorXi(pv.size() + nb_slack_added); @@ -404,21 +412,23 @@ class LS2G_API BaseAlgo : public BaseConstants /** When there are multiple slacks, add the other "slack buses" in the PV buses indexes **/ - Eigen::VectorXi add_slack_to_pv(Eigen::Ref slack_ids, - Eigen::Ref pv) const { + Eigen::VectorXi add_slack_to_pv(const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) const { Eigen::VectorXi my_pv = Eigen::VectorXi(slack_ids.size() + pv.size()); my_pv << slack_ids, pv; return my_pv; } // terribly inefficient way to know if an element is in a vector - bool isin(int k, Eigen::Ref vect) const{ + bool isin(int k, const Eigen::Ref & vect) const{ for(auto el : vect){ if(el == k) return true; } return false; } + // Bf / Bf_T are resized and filled from scratch by fillBf_for_PTDF: a real + // reference is needed, Eigen::Ref can't resize/reserve. void get_Bf(Eigen::SparseMatrix & Bf) const; void get_Bf_transpose(Eigen::SparseMatrix & Bf_T) const; diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index 93adf648..8558019b 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -81,13 +81,13 @@ class BaseDCAlgo final: public BaseAlgo // Native real-valued DC power flow: solves `Bbus . theta = Pbus`. // (the DC solver does not implement the complex `compute_pf`: it inherits the throwing // default from BaseAlgo, since every DC code path goes through `compute_pf_dc`) - bool compute_pf_dc(const Eigen::SparseMatrix & Bbus, + bool compute_pf_dc(const Eigen::Ref> & Bbus, CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq ) override; RealMat get_ptdf() override; @@ -131,7 +131,7 @@ class BaseDCAlgo final: public BaseAlgo protected: void fill_mat_bus_id(int nb_bus_solver); - void fill_dcYbus_noslack(int nb_bus_solver, const Eigen::SparseMatrix & ref_mat); + void fill_dcYbus_noslack(int nb_bus_solver, const Eigen::Ref> & ref_mat); // hvdc angle-droop ("AC emulation") support: in dc, the linear-mode // droop lines contribute `p = p0 + k * (theta1 - theta2)`: the k term @@ -143,9 +143,10 @@ class BaseDCAlgo final: public BaseAlgo void add_droop_to_dcYbus(); void add_droop_to_dcSbus(); - // remove_slack_buses: res_mat is initialized and make_compressed in this function + // remove_slack_buses: res_mat is reset (reassigned) and setFromTriplets/makeCompressed'd + // from scratch in this function, so it needs a real reference, not Eigen::Ref. template // ref_mat_type should be `real_type` or `cplx_type` - void remove_slack_buses(int nb_bus_solver, const Eigen::SparseMatrix & ref_mat, Eigen::SparseMatrix & res_mat); + void remove_slack_buses(int nb_bus_solver, const Eigen::Ref> & ref_mat, Eigen::SparseMatrix & res_mat); protected: LinearSolver _linear_solver; diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 4ca341fb..a6f5a72f 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -11,13 +11,13 @@ #include // for nans template -bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix & Bbus, +bool BaseDCAlgo::compute_pf_dc(const Eigen::Ref> & Bbus, CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq ) { // V is used the following way: at pq buses it's completely ignored. For pv bus only the magnitude is used, @@ -341,7 +341,7 @@ void BaseDCAlgo::fill_mat_bus_id(int nb_bus_solver){ } template -void BaseDCAlgo::fill_dcYbus_noslack(int nb_bus_solver, const Eigen::SparseMatrix & ref_mat){ +void BaseDCAlgo::fill_dcYbus_noslack(int nb_bus_solver, const Eigen::Ref> & ref_mat){ // TODO see if "prune" might work here https://eigen.tuxfamily.org/dox/classEigen_1_1SparseMatrix.html#title29 remove_slack_buses(nb_bus_solver, ref_mat, dcYbus_noslack_); add_droop_to_dcYbus(); @@ -405,13 +405,13 @@ void BaseDCAlgo::add_droop_to_dcSbus(){ template template // ref_mat_type should be `real_type` or `cplx_type` -void BaseDCAlgo::remove_slack_buses(int nb_bus_solver, const Eigen::SparseMatrix & ref_mat, Eigen::SparseMatrix & res_mat){ +void BaseDCAlgo::remove_slack_buses(int nb_bus_solver, const Eigen::Ref> & ref_mat, Eigen::SparseMatrix & res_mat){ res_mat = Eigen::SparseMatrix(sizeYbus_without_slack_, sizeYbus_without_slack_); // TODO dist slack: -1 or -mat_bus_id_.size() here ???? std::vector > tripletList; tripletList.reserve(ref_mat.nonZeros()); for (int k=0; k < nb_bus_solver; ++k){ if(mat_bus_id_(k) == -1) continue; // I don't add anything to the slack bus - for (typename Eigen::SparseMatrix::InnerIterator it(ref_mat, k); it; ++it) + for (typename Eigen::Ref>::InnerIterator it(ref_mat, k); it; ++it) { int row_res = static_cast(it.row()); // TODO Eigen::Index here ? row_res = mat_bus_id_(row_res); diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 3a766cda..8102b98a 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -23,13 +23,16 @@ class BaseFDPFAlgo final: public BaseAlgo BaseFDPFAlgo() noexcept :BaseAlgo(true), need_factorize_(true) {} ~BaseFDPFAlgo() noexcept override = default; + // Ybus stays a plain reference: compute_pf is the same shared virtual + // signature as NRAlgo's (see BaseAlgo::compute_pf) even though FDPF itself + // has no pointer-caching need. bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol ) override; // requires a gridmodel ! @@ -61,12 +64,12 @@ class BaseFDPFAlgo final: public BaseAlgo BaseAlgo::reset_timer(); } - CplxVect evaluate_mismatch(const Eigen::SparseMatrix & Ybus, + CplxVect evaluate_mismatch(const Eigen::Ref> & Ybus, const CplxVect & V, - Eigen::Ref Sbus, + const Eigen::Ref & Sbus, size_t /*slack_id*/, // id of the ref slack bus real_type slack_absorbed, - Eigen::Ref slack_weights) + const Eigen::Ref & slack_weights) { // CplxVect tmp = Ybus * V; // this is a vector // tmp = tmp.array().conjugate(); // i take the conjugate @@ -121,11 +124,11 @@ class BaseFDPFAlgo final: public BaseAlgo } bool has_converged(const Eigen::Ref & tmp_va, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref Sbus, + const Eigen::Ref> & Ybus, + const Eigen::Ref & Sbus, size_t slack_bus_id, real_type & slack_absorbed, - Eigen::Ref slack_weights, + const Eigen::Ref & slack_weights, const Eigen::Ref & pvpq, const Eigen::Ref & pq, real_type tol) @@ -172,14 +175,16 @@ class BaseFDPFAlgo final: public BaseAlgo return _check_for_convergence(p_, q_, tol); } - void fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp, - const Eigen::SparseMatrix & grid_Bpp, + void fill_sparse_matrices(const Eigen::Ref> & grid_Bp, + const Eigen::Ref> & grid_Bpp, const std::vector & pvpq_inv, const std::vector & pq_inv, size_t n_pvpq, size_t n_pq); - void aux_fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp_Bpp, + // res is reset (reassigned) and setFromTriplets'd from scratch, so it needs + // a real reference, not Eigen::Ref. + void aux_fill_sparse_matrices(const Eigen::Ref> & grid_Bp_Bpp, const std::vector & ind_inv, size_t mat_dim, Eigen::SparseMatrix & res); diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp index c3201b47..2a2a00b7 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp @@ -11,11 +11,11 @@ template bool BaseFDPFAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol ) @@ -154,8 +154,8 @@ bool BaseFDPFAlgo::compute_pf(const Eigen::SparseMatrix -void BaseFDPFAlgo::fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp, - const Eigen::SparseMatrix & grid_Bpp, +void BaseFDPFAlgo::fill_sparse_matrices(const Eigen::Ref> & grid_Bp, + const Eigen::Ref> & grid_Bpp, const std::vector & pvpq_inv, const std::vector & pq_inv, size_t n_pvpq, @@ -171,7 +171,7 @@ void BaseFDPFAlgo::fill_sparse_matrices(const Eigen::Sparse } template -void BaseFDPFAlgo::aux_fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp_Bpp, +void BaseFDPFAlgo::aux_fill_sparse_matrices(const Eigen::Ref> & grid_Bp_Bpp, const std::vector & ind_inv, size_t mat_dim, Eigen::SparseMatrix & res) @@ -182,7 +182,7 @@ void BaseFDPFAlgo::aux_fill_sparse_matrices(const Eigen::Sp std::vector > tripletList; tripletList.reserve(grid_Bp_Bpp.nonZeros()); for (int k = 0; k < grid_Bp_Bpp.outerSize(); ++k){ - for (Eigen::SparseMatrix::InnerIterator it(grid_Bp_Bpp, k); it; ++it){ + for (Eigen::Ref>::InnerIterator it(grid_Bp_Bpp, k); it; ++it){ if ((ind_inv[it.row()] >= 0) && (ind_inv[it.col()] >= 0)){ auto tmp = Eigen::Triplet(ind_inv[it.row()], ind_inv[it.col()], it.value()); tripletList.push_back(tmp); diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp index 436aae64..8ee1e33f 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp @@ -12,11 +12,11 @@ namespace ls2g { bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref /*slack_weights*/, // currently unused - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & /*slack_weights*/, // currently unused + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol ) @@ -81,9 +81,9 @@ bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, } void GaussSeidelAlgo::one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref> & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { // do an update with the standard GS algorithm cplx_type tmp; diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp index f4bd075c..e6a60f66 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp @@ -26,13 +26,15 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo } // todo change the name! + // Ybus stays a plain reference: same shared virtual signature as + // NRAlgo::compute_pf (see BaseAlgo::compute_pf). bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, // currently unused - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, // currently unused + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol ) override; @@ -41,9 +43,9 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo virtual void one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq + const Eigen::Ref> & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq ); private: diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp index 11d71009..776316d6 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp @@ -11,9 +11,9 @@ namespace ls2g { void GaussSeidelSynchAlgo::one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref> & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { // do an update with all nodes being updated at the same time (different than the original GaussSeidel) cplx_type tmp; diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp index 8a91d440..5637d6e1 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp @@ -26,9 +26,9 @@ class LS2G_API GaussSeidelSynchAlgo final: public GaussSeidelAlgo protected: void one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq + const Eigen::Ref> & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq ) override; private: diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 5f392cfa..bcb447a5 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -121,13 +121,15 @@ class NRAlgo final : public BaseAlgo // ----- powerflow ----------------------------------------------------------- + // Ybus stays a plain reference: NRSystem::update_state caches its address + // (Ybus_ptr_) across phases, which needs a real, long-lived reference. bool compute_pf(const Eigen::SparseMatrix& Ybus, CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol ) override; diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index e1e547f7..b0109227 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -10,11 +10,11 @@ template bool NRAlgo::compute_pf( const Eigen::SparseMatrix& Ybus, CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol) { diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index a44d96d8..b72968a0 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -156,7 +156,7 @@ class LS2G_API Base {} static int find_J_pos ( - Eigen::Ref > J_csc, + const Eigen::Ref > & J_csc, int row, int col){ int start = J_csc.outerIndexPtr()[col]; @@ -173,21 +173,25 @@ class LS2G_API Base // need a free Vm unknown + Q equation -- shared by EVERY NRSystem // instantiation (SingleSlackNRSystem has no MultiSlack extension to // do this, so Base must own it). + // Ybus stays a plain reference (not Eigen::Ref): this update_state + // caches its address (Ybus_ptr_, see below) across phases (this call + // -> build_J_sparsity), which needs the caller's actual, long-lived + // matrix, not a call-site Eigen::Ref temporary. void update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); // call after update_state // at the beginning of each solve // only if the topology has changed void init_topology( - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref pv, - Eigen::Ref pq + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & pv, + const Eigen::Ref & pq ) { pv_ = IntVect(pv); pq_ = IntVect(pq); @@ -302,18 +306,18 @@ class LS2G_API MultiSlack // distributed-slack extension const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); // call after update_state // at the beginning of each solve // only if the topology has changed void init_topology( - Eigen::Ref slack_ids, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/ + const Eigen::Ref & slack_ids, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/ ) { my_size_ = static_cast(slack_ids.size()); ref_slack_id_ = slack_ids[0]; @@ -447,15 +451,15 @@ class LS2G_API Hvdc const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); void init_topology( - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/ + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/ ) {} // claims nothing: only caches the rows / columns of the two end buses @@ -615,15 +619,15 @@ class LS2G_API VoltageControl const Base * nr_system_base_ptr, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); void init_topology( - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/ + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/ ) {} // claims, per group: N q-unknown columns, 1 voltage row, N-1 sharing rows; @@ -839,10 +843,10 @@ class NRSystem // ----- Phase 1: topology init (call when pv/pq/slack topology changes) ------- void init_topology( - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); // ----- Phase 1.5: per-compute_pf state update (cheap) ----------------------- @@ -850,8 +854,8 @@ class NRSystem const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& V_init, - Eigen::Ref Sbus, - Eigen::Ref slack_weights); + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights); // ----- Phase 2: build J sparsity + value maps ------------------------------- @@ -1083,10 +1087,10 @@ class NRSystem // ---- component hook fold helpers (C++14 index_sequence/dummy-array idiom) --- template void _init_topology_extensions( - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, std::index_sequence) { int dummy[] = { 0, (std::get(extensions_).init_topology( slack_ids, @@ -1101,8 +1105,8 @@ class NRSystem void _update_state_extensions( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights, std::index_sequence){ int dummy[] = { 0, (std::get(extensions_).update_state( &base_, diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index 1f11e656..f5ce16c5 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -11,10 +11,10 @@ namespace ls2g { // ---- Phase 1: topology init -------------------------------------------------- template inline void NRSystem::init_topology( - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { // init the sparsity pattern of the dS matrices (values do not matter yet) dS_dVm_ = *Ybus_ptr_; @@ -42,8 +42,8 @@ inline void NRSystem::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& Ybus, const CplxVect& V_init, - Eigen::Ref Sbus, - Eigen::Ref slack_weights) + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights) { lsgrid_ptr_ = lsgrid_ptr; Ybus_ptr_ = &Ybus; diff --git a/src/core/powerflow_algorithm/NRSystemBase.cpp b/src/core/powerflow_algorithm/NRSystemBase.cpp index b332ef42..14478b7c 100644 --- a/src/core/powerflow_algorithm/NRSystemBase.cpp +++ b/src/core/powerflow_algorithm/NRSystemBase.cpp @@ -23,8 +23,8 @@ namespace ls2g { void Base::update_state( const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_weights*/ + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/ ) { // Slack buses not pinned by a LOCAL voltage-regulating generator need a diff --git a/src/core/powerflow_algorithm/NRSystemHvdc.cpp b/src/core/powerflow_algorithm/NRSystemHvdc.cpp index 0512611a..bc0979c5 100644 --- a/src/core/powerflow_algorithm/NRSystemHvdc.cpp +++ b/src/core/powerflow_algorithm/NRSystemHvdc.cpp @@ -18,8 +18,8 @@ void Hvdc::update_state( const Base * /*nr_system_base_ptr*/, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_weights*/ + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/ ) { data_.clear(); diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp index f9b103a7..756d90c0 100644 --- a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -14,8 +14,8 @@ void MultiSlack::update_state( const Base * /*nr_system_base_ptr*/, const LSGrid * /*lsgrid_ptr*/, const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ) { slack_weights_ = slack_weights; diff --git a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp index 73fb6314..51eacc9b 100644 --- a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp +++ b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp @@ -18,8 +18,8 @@ void VoltageControl::update_state( const Base * /*nr_system_base_ptr*/, const LSGrid * lsgrid_ptr, const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_weights*/ + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/ ) { data_.clear(); diff --git a/src/core/powerflow_algorithm/SlackPolicies.hpp b/src/core/powerflow_algorithm/SlackPolicies.hpp index 7f165542..0dbf571d 100644 --- a/src/core/powerflow_algorithm/SlackPolicies.hpp +++ b/src/core/powerflow_algorithm/SlackPolicies.hpp @@ -25,21 +25,21 @@ struct MultiSlackPolicy { template static Eigen::VectorXi get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv); + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv); static real_type initial_slack_absorbed(const CplxVect& Sbus); template static RealVect evaluate_Fx(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, const CplxVect& Sbus, size_t slack_bus_id, real_type slack_absorbed, const RealVect& slack_weights, const Eigen::VectorXi& my_pv, - Eigen::Ref pq); + const Eigen::Ref & pq); template static void update_slack_absorbed(NRAlgoT& algo, @@ -48,7 +48,7 @@ struct MultiSlackPolicy { template static void fill_jacobian_matrix(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, size_t slack_bus_id, const RealVect& slack_weights, @@ -60,7 +60,7 @@ struct MultiSlackPolicy { private: template static void fill_jac_unknown_sparsity(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, size_t slack_bus_id, const RealVect& slack_weights, @@ -92,21 +92,21 @@ struct SingleSlackPolicy { template static Eigen::VectorXi get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv); + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv); static real_type initial_slack_absorbed(const CplxVect& Sbus); template static RealVect evaluate_Fx(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, const CplxVect& Sbus, size_t slack_bus_id, real_type slack_absorbed, const RealVect& slack_weights, const Eigen::VectorXi& my_pv, - Eigen::Ref pq); + const Eigen::Ref & pq); template static void update_slack_absorbed(NRAlgoT& algo, @@ -115,7 +115,7 @@ struct SingleSlackPolicy { template static void fill_jacobian_matrix(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, size_t slack_bus_id, const RealVect& slack_weights, @@ -127,7 +127,7 @@ struct SingleSlackPolicy { private: template static void fill_jac_unknown_sparsity(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, const Eigen::VectorXi& pq, const Eigen::VectorXi& pvpq, diff --git a/src/core/powerflow_algorithm/SlackPolicies.tpp b/src/core/powerflow_algorithm/SlackPolicies.tpp index 6c28983b..f1693878 100644 --- a/src/core/powerflow_algorithm/SlackPolicies.tpp +++ b/src/core/powerflow_algorithm/SlackPolicies.tpp @@ -12,8 +12,8 @@ template Eigen::VectorXi MultiSlackPolicy::get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv) + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) { return algo.retrieve_pv_with_slack(slack_ids, pv); } @@ -25,14 +25,14 @@ inline real_type MultiSlackPolicy::initial_slack_absorbed(const CplxVect& Sbus) template RealVect MultiSlackPolicy::evaluate_Fx(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, const CplxVect& Sbus, size_t slack_bus_id, real_type slack_absorbed, const RealVect& slack_weights, const Eigen::VectorXi& my_pv, - Eigen::Ref pq) + const Eigen::Ref & pq) { return algo._evaluate_Fx(Ybus, V, Sbus, slack_bus_id, slack_absorbed, slack_weights, my_pv, pq); } @@ -47,7 +47,7 @@ void MultiSlackPolicy::update_slack_absorbed(NRAlgoT& algo, template void MultiSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, size_t slack_bus_id, const RealVect& slack_weights, @@ -78,7 +78,7 @@ void MultiSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, template void MultiSlackPolicy::fill_jac_unknown_sparsity(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, size_t slack_bus_id, const RealVect& slack_weights, @@ -286,8 +286,8 @@ void MultiSlackPolicy::fill_jac_known_sparsity(NRAlgoT& algo, template Eigen::VectorXi SingleSlackPolicy::get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv) + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) { return IntVect(pv); } @@ -299,14 +299,14 @@ inline real_type SingleSlackPolicy::initial_slack_absorbed(const CplxVect& Sbus) template RealVect SingleSlackPolicy::evaluate_Fx(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, const CplxVect& Sbus, size_t slack_bus_id, real_type slack_absorbed, const RealVect& slack_weights, const Eigen::VectorXi& my_pv, - Eigen::Ref pq) + const Eigen::Ref & pq) { return algo._evaluate_Fx(Ybus, V, Sbus, my_pv, pq); } @@ -321,7 +321,7 @@ void SingleSlackPolicy::update_slack_absorbed(NRAlgoT& algo, template void SingleSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, size_t slack_bus_id, const RealVect& slack_weights, @@ -352,7 +352,7 @@ void SingleSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, template void SingleSlackPolicy::fill_jac_unknown_sparsity(NRAlgoT& algo, - const Eigen::SparseMatrix& Ybus, + const Eigen::Ref>& Ybus, const CplxVect& V, const Eigen::VectorXi& pq, const Eigen::VectorXi& pvpq, From 756b2875e27cf359d3b857f7352eb88608c2d897 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 15:51:13 +0200 Subject: [PATCH 117/166] Generalize RTE-specific grid references in from_pypowsybl and tests Per PR #140 self-review: replace "RTE grid(s)" wording with "real grid(s)" in docstrings/comments, and drop mentions of local-only files (HVDC_OLF_FINDINGS.md) and scripts not part of the lightsim2grid package (reduce_and_compare.py, validate_olf_vs_lightsim.py). Scoped to the files this PR actually touches; CHANGELOG.rst's historical "RTE grid" bug provenance entries are left as-is. Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_from_pypowsybl.py | 12 ++++++------ lightsim2grid/tests/test_bus_fusion_pypowsybl.py | 6 +++--- lightsim2grid/tests/test_gen_reactive_curve_swap.py | 2 +- lightsim2grid/tests/test_hvdc_main_component.py | 4 ++-- lightsim2grid/tests/test_pst_tap_impedance.py | 4 ++-- .../tests/test_remote_control_disconnected_target.py | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index ce1eddbc..3c6f48e4 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -73,8 +73,7 @@ def _aux_dangling_lines_fictitious(net, sort_index): Real full grids never have any dangling line -- they only appear when zooming into a sub-area with - ``network.reduce_by_ids_and_depths(..., with_boundary_lines=True)`` - (*eg* in ``reduce_and_compare.py`` / ``validate_olf_vs_lightsim.py``), + ``network.reduce_by_ids_and_depths(..., with_boundary_lines=True)``, which cuts the grid and represents everything beyond the cut as a ``DanglingLine`` (series r/x/g/b + a constant-power p0/q0 "load" at the boundary). Without this, ``_from_pypowsybl`` silently drops that @@ -139,7 +138,8 @@ def _aux_phase_shift_rx_tables(trafo_index, net): pypowsybl exposes the transformer r / x at the *neutral* tap and only folds the tap into ``rho`` / ``alpha``; the per-step r/x deltas (percent) are dropped. They matter for phase-shifting transformers whose series impedance varies with the - shift (RTE PSTs: tens of MW of through-flow). On those grids ``r% == x%`` for + shift (phase-shifting transformers on real grids: tens of MW of through-flow). + On those grids ``r% == x%`` for every step, so a single correction table is applied to both r and x.""" n = len(trafo_index) alpha = [[] for _ in range(n)] @@ -334,7 +334,7 @@ def _default_distributed_slack(net, df_gen): participate. An earlier version of this function restricted participants to the country hosting the most buses; that was a guess, not something OLF actually does, and it was found to materially skew which generators absorb - the slack mismatch on real multi-country RTE grids -- removed; + the slack mismatch on real multi-country grids -- removed; * the per-generator weight matches OLF's default ``PROPORTIONAL_TO_GENERATION_P_MAX`` balance type exactly (``GenerationActivePowerDistributionStep.getParticipationFactor``, ``MAX`` case): ``max_p / droop``, where ``droop`` is the ``activePowerControl`` @@ -952,7 +952,7 @@ def _uf_union(a, b): # malformed source curve data (eg a reactive capability curve point entered with # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init - # hard-rejects it (real case found on a real RTE grid snapshot). Restore a valid + # hard-rejects it (real case found on a real grid snapshot). Restore a valid # interval by sorting the pair instead of crashing -- this only ever affects the # (already tiny) width of the interval, never which generators get a reactive # constraint at all. @@ -990,7 +990,7 @@ def _uf_union(a, b): remote_idx = np.nonzero(mask_remote_gen)[0] gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) # a *connected* generator can still remotely regulate a busbar section that is - # itself disconnected (found on a real RTE grid snapshot: a de-energized + # itself disconnected (found on a real grid snapshot: a de-energized # voltage level). pypowsybl's bus-view id for a disconnected element is '', # not NaN, and can't be resolved to any bus_df row. OLF converges fine on such # grids, so it must fall back to local voltage control in this situation; diff --git a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py index af8dfd09..a8a13cf5 100644 --- a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py +++ b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py @@ -8,13 +8,13 @@ """`fuse_zero_impedance_branches` (from_pypowsybl `init()`). -A real RTE grid was found to have a line with `r_pu = x_pu = 0.0`. lightsim2grid +A real grid was found to have a line with `r_pu = x_pu = 0.0`. lightsim2grid computed `B = 1/X = Inf`, corrupting the Ybus/dcYbus with literal `+/-Inf` entries and making the sparse LU factorization fail outright. OpenLoadFlow avoids this via its `lowImpedanceBranchMode` (default `REPLACE_BY_ZERO_IMPEDANCE_LINE`): the two terminal buses of a near-zero-impedance branch are fused into a single electrical node instead of computing `1/Z`. This is the same behaviour, opt-in via -`fuse_zero_impedance_branches` (see HVDC_OLF_FINDINGS.md and the docstring of +`fuse_zero_impedance_branches` (see the docstring of `lightsim2grid.network.from_pypowsybl.init`). """ @@ -262,7 +262,7 @@ class TestBusFusionResultNetwork(unittest.TestCase): branch itself must be recoverable by Kirchhoff's current law wherever a leaf endpoint makes it unambiguous (see `_reconstruct_fused_branches`). - Found on a real RTE grid (`PtFige-20241018-0355`): a zero-impedance line + Found on a real grid snapshot (`PtFige-20241018-0355`): a zero-impedance line (`CPNIEL61ZSINA`) merged bus `ZSINAP6_0` into `CPNIEP6_0`; the fused-away bus read `v_mag=0.0` and the fused line itself read 0 flow instead of the ~250 MW / 634 A OpenLoadFlow (with outer loops disabled by `outerLoopNames: diff --git a/lightsim2grid/tests/test_gen_reactive_curve_swap.py b/lightsim2grid/tests/test_gen_reactive_curve_swap.py index 52ea3bd6..9077e290 100644 --- a/lightsim2grid/tests/test_gen_reactive_curve_swap.py +++ b/lightsim2grid/tests/test_gen_reactive_curve_swap.py @@ -7,7 +7,7 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """Malformed reactive capability curve data (min_q/max_q effectively "swapped" at -one of the curve points) was found on a real RTE grid snapshot: pypowsybl's +one of the curve points) was found on a real grid snapshot: pypowsybl's "at target p" interpolation then yields min_q > max_q, which OpenLoadFlow tolerates silently but which used to make lightsim2grid's ``GeneratorContainer::init`` hard-crash with ``RuntimeError: ... min_q being diff --git a/lightsim2grid/tests/test_hvdc_main_component.py b/lightsim2grid/tests/test_hvdc_main_component.py index 17e24a2d..08e7e327 100644 --- a/lightsim2grid/tests/test_hvdc_main_component.py +++ b/lightsim2grid/tests/test_hvdc_main_component.py @@ -16,8 +16,8 @@ OpenLoadFlow keeps that in-main converter as a fixed boundary injection (it still imports / exports its scheduled HVDC power). lightsim2grid used to deactivate the WHOLE line as soon as one side was out of the main component, silently dropping -that injection (hundreds of MW on real RTE grids -- see HVDC_OLF_FINDINGS.md). -These tests pin the fixed behaviour: keep the line connected, keep the in-main +that injection (hundreds of MW on real grids). These tests pin the fixed +behaviour: keep the line connected, keep the in-main converter injecting, open only the out-of-main converter. """ diff --git a/lightsim2grid/tests/test_pst_tap_impedance.py b/lightsim2grid/tests/test_pst_tap_impedance.py index 4466529a..3de21d0a 100644 --- a/lightsim2grid/tests/test_pst_tap_impedance.py +++ b/lightsim2grid/tests/test_pst_tap_impedance.py @@ -11,8 +11,8 @@ pypowsybl exposes the transformer r/x/g/b at the *neutral* tap and only folds the tap position into ``rho`` / ``alpha``. The per-step r/x/g/b deltas (in percent) must be applied on top, otherwise the (phase-shifting) transformer impedance -- -and hence its through-flow -- is wrong. On real RTE grids this caused tens of MW -of disagreement with PowSyBl Open Load Flow (see HVDC_OLF_FINDINGS.md). +and hence its through-flow -- is wrong. On real grids this caused tens of MW +of disagreement with PowSyBl Open Load Flow. The ``four_substations`` pypowsybl test network carries a phase-shifting transformer ``TWT`` whose phase-tap step applies a ~-28.8% r/x correction, which diff --git a/lightsim2grid/tests/test_remote_control_disconnected_target.py b/lightsim2grid/tests/test_remote_control_disconnected_target.py index 20e922e6..8833ca21 100644 --- a/lightsim2grid/tests/test_remote_control_disconnected_target.py +++ b/lightsim2grid/tests/test_remote_control_disconnected_target.py @@ -7,7 +7,7 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. """A *connected* generator (or SVC) can remotely regulate the voltage of a -terminal that is itself disconnected (found on a real RTE grid snapshot: two +terminal that is itself disconnected (found on a real grid snapshot: two generators remotely regulate busbar sections in a de-energized voltage level). pypowsybl's bus-view id for a disconnected element is `''`, not NaN, which used to crash `from_pypowsybl.init()` with From e7f1caff5435c74bedc57ffe7bbe0495105508d8 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 15:53:28 +0200 Subject: [PATCH 118/166] Sharpen doc wording per PR #140 self-review - binary_serialization.rst: add a danger block -- never load a binary file from an untrusted source, and generate/consume it on the same machine with the same lightsim2grid version (pickle remains the portable option). - comparison_with_pypowsybl.rst: warn that LightsimResultNetwork only reads powerflow *results* from the solved LSGrid (everything else comes from the original pypowsybl net), and that it's a convenience wrapper, not the fast path -- use lightsim2grid's own accessors for performance-sensitive code. - _olf_bake.py: clarify that outer loops change the input of the inner solves (not "the power flow" itself), and soften "OLF and lightsim2grid agree" to "should agree" since this does not always hold (multi-root / basin-boundary cases). Signed-off-by: DONNOT Benjamin --- docs/binary_serialization.rst | 21 ++++++++++++++++--- docs/comparison_with_pypowsybl.rst | 14 +++++++++++++ .../network/from_pypowsybl/_olf_bake.py | 13 +++++++----- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 5ffe442d..0e2bcd28 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -63,9 +63,24 @@ Individual element containers work the same way: bytes are all rejected. ``save_binary`` on the other hand is **atomic by default**: it writes to a temporary file that only replaces the destination once fully written, so an interrupted save never destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead - (marginally faster -- it skips one temporary file and rename -- but without that protection). Note that the format stores raw native data: files are meant to be written - and read by builds sharing the same data layout (same endianness, ``real_type``, ...) -- there is - no checksum nor cross-platform migration, pickle remains the portable format. + (marginally faster -- it skips one temporary file and rename -- but without that protection). + +.. danger:: + Not even pickle is a *portable* format, and this binary format is even less so. Two + rules to follow: + + - **Never load a binary file from a source you do not trust.** ``load_binary`` + rejects structurally ill-formed input (see above), but it cannot tell a + legitimate grid from a maliciously crafted one that happens to be well-formed -- + loading it still means trusting its content outright, the same way loading a + pickle file does. + - **Generate the file on the machine that will consume it, with the same + lightsim2grid version.** The format stores raw native data (same endianness, + ``real_type``, ...) with no checksum and no cross-platform migration; the + binary-format-version check (see above) only catches layout changes the + lightsim2grid authors explicitly bumped it for, not every possible mismatch. + Use pickle instead if you need to move a grid across machines, python versions, + or lightsim2grid versions. .. note:: The binary format walks the exact same internal state (``get_state`` / ``set_state``) that pickle diff --git a/docs/comparison_with_pypowsybl.rst b/docs/comparison_with_pypowsybl.rst index 270b85b5..341da9c0 100644 --- a/docs/comparison_with_pypowsybl.rst +++ b/docs/comparison_with_pypowsybl.rst @@ -189,6 +189,20 @@ sign conventions (post-solve ``p`` / ``q`` in the load convention): :members: :no-index: +.. warning:: + Only powerflow *results* (post-solve quantities such as ``p`` / ``q`` / ``i`` / bus + voltage) are actually read from the solved ``LSGrid`` and mapped back onto + pypowsybl's DataFrame shape. Everything else in the returned DataFrame -- topology, + ratings, static per-element metadata, ... -- is read from the original pypowsybl + ``net``, not recomputed by lightsim2grid. + +.. note:: + ``LightsimResultNetwork`` is a convenience wrapper for pypowsybl-shaped analysis + code, not the fast path: building each DataFrame has real overhead (id alignment, + column renaming, sign-convention conversion) on top of the powerflow itself. If you + only need raw arrays for performance-sensitive code, use lightsim2grid's own + accessors on ``LSGrid`` directly instead. + Results ----------------------------------- diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 4088bec3..39fb96ed 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -16,9 +16,10 @@ lightsim2grid solves a single power-flow problem: it does not run the discrete "outer loops" that OLF runs (distributed slack, reactive-limit PV<->PQ switching, transformer/shunt voltage control, phase control, ...). Those loops -change the *inputs* of the power flow between inner solves: a generator that -hits its Q limit becomes a fixed-Q (PQ) injection, a tap changer settles on a -discrete position, the slack mismatch is spread over participating units, etc. +change the input of the inner solves (*eg* the input of the inner Newton +Raphson) between iterations: a generator that hits its Q limit becomes a +fixed-Q (PQ) injection, a tap changer settles on a discrete position, the +slack mismatch is spread over participating units, etc. If you take the raw network, run OLF *with* outer loops, and then hand the same raw network to lightsim2grid, the two engines disagree -- not because the @@ -27,8 +28,10 @@ ``bake_outer_loops`` rewrites the network's input setpoints to the values the outer loops settled on, and disables the corresponding regulation, so the problem becomes a plain power flow. After baking, OLF (loop-free) and -lightsim2grid agree to solver tolerance, and they keep agreeing through -topology changes (line/transformer outages) applied identically to both. +lightsim2grid *should* agree to solver tolerance, and keep agreeing through +topology changes (line/transformer outages) applied identically to both -- +this does not always hold in practice (see e.g. multi-root / basin-boundary +cases where the two engines land on different, both-valid, roots). The loop-free OLF parameters that reproduce the baked operating point are available as :func:`lightsim2grid.network.get_pypowsybl_loopfree_parameters`. From 2bee701086ea90f95af28c7f52ec79182b848d42 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 15:56:31 +0200 Subject: [PATCH 119/166] Misc PR #140 self-review items: debug ids + nb_thread benchmark - _mpc_to_powermodels.py: report the affected branch row ids (not just a count) in the TAP==0-with-nonzero-SHIFT warning, for easier debugging. - Add benchmarks/benchmark_ca_nb_threads.py: scans ContingencyAnalysis.nb_thread from 1 to 8 on case6515rte, and report the results in docs/security_analysis.rst. Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_ca_nb_threads.py | 97 +++++++++++++++++++ docs/security_analysis.rst | 50 ++++++++++ .../from_matpower/_mpc_to_powermodels.py | 9 +- 3 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 benchmarks/benchmark_ca_nb_threads.py diff --git a/benchmarks/benchmark_ca_nb_threads.py b/benchmarks/benchmark_ca_nb_threads.py new file mode 100644 index 00000000..5e9d2a9d --- /dev/null +++ b/benchmarks/benchmark_ca_nb_threads.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid a implements a c++ backend targeting the Grid2Op platform. + +"""``ContingencyAnalysis.nb_thread`` scaling benchmark: how much does splitting an +n-1 security analysis across 1 to 8 CPU threads actually speed things up, on a +single, reasonably large real-topology grid (``case6515rte``, ~6515 buses)? + +Results are reported in ``docs/security_analysis.rst`` (section +"Running the contingencies on multiple threads"). +""" + +import os +import pandapower as pp +import numpy as np +from tqdm import tqdm + +from lightsim2grid import ContingencyAnalysis +from benchmark_grid_size import make_grid2op_env +from utils_benchmark import print_configuration, get_env_name_displayed + +try: + from tabulate import tabulate + TABULATE_AVAIL = True +except ImportError: + print("The tabulate package is not installed. Some output might not work properly") + TABULATE_AVAIL = False + +CASE_NAME = "case6515rte.json" +NB_THREADS_RANGE = [1, 2, 3, 4, 5, 6, 7, 8] +MAX_CONT = 1000 # cap the number of n-1 contingencies simulated, for speed + + +if __name__ == "__main__": + print_configuration() + + if not os.path.exists(CASE_NAME): + import pandapower.networks as pn + case = getattr(pn, os.path.splitext(CASE_NAME)[0])() + pp.to_json(case, CASE_NAME) + + case = pp.from_json(CASE_NAME) + pp.runpp(case) # for slack + + load_p_init = case.load["p_mw"].to_numpy().copy() + load_q_init = case.load["q_mvar"].to_numpy().copy() + gen_p_init = case.gen["p_mw"].to_numpy().copy() + sgen_p_init = case.sgen["p_mw"].to_numpy().copy() + + slack_gens = np.zeros((1, case.ext_grid.shape[0])) + if "res_ext_grid" in case: + slack_gens += np.tile(case.res_ext_grid["p_mw"].to_numpy(), (1, 1)) + gen_p_g2op = np.concatenate((gen_p_init, slack_gens.reshape(-1))) + + env_lightsim = make_grid2op_env(case, + CASE_NAME, + load_p_init.reshape((1, -1)), + load_q_init.reshape((1, -1)), + gen_p_g2op.reshape((1, -1)), + sgen_p_init.reshape((1, -1))) + + rows = [] + total_time_1_thread = None + for nb_threads in tqdm(NB_THREADS_RANGE): + env_lightsim.reset() + sa = ContingencyAnalysis(env_lightsim) + for i in range(env_lightsim.n_line): + sa.add_single_contingency(i) + if i >= MAX_CONT: + break + sa.init_from_n_powerflow = True + sa.nb_thread = nb_threads + sa.get_flows() + computer_sa = sa.computer + total_time = computer_sa.total_time() + computer_sa.amps_computation_time() + nb_solved = computer_sa.nb_solved() + pf_per_s = nb_solved / total_time if total_time > 0. else float("nan") + if nb_threads == 1: + total_time_1_thread = total_time + speed_up = total_time_1_thread / total_time if total_time > 0. else float("nan") + rows.append([nb_threads, nb_solved, f"{1000. * total_time:.2f}", f"{pf_per_s:.0f}", f"{speed_up:.2f}x"]) + sa.close() + + env_lightsim.close() + + print(f"\nnb_thread scaling on {get_env_name_displayed(CASE_NAME)} " + f"({env_lightsim.n_sub} substations, up to {MAX_CONT + 1} n-1 contingencies)") + if TABULATE_AVAIL: + print(tabulate(rows, + headers=["nb_thread", "nb solved", "time (ms)", "pf / s", "speed-up vs 1 thread"], + tablefmt="rst")) + else: + print(rows) diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index 234e9adb..59566b4f 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -147,6 +147,56 @@ This feature only relies on the C++ standard library (``std::thread``): no addit ``nb_thread`` is also available on the lower-level ``ContingencyAnalysisCPP`` class (same semantics). +Benchmarks (``nb_thread`` scaling) ++++++++++++++++++++++++++++++++++++ + +The script below scans ``nb_thread`` from 1 to 8 on a single, reasonably large real-topology +grid (``case6515rte``, up to 1001 n-1 contingencies), and is available by running, from the +root of the lightsim2grid repository: + +.. code-block:: bash + + cd benchmarks + python3 benchmark_ca_nb_threads.py + +Results, made with: + +- date: 2026-07-15 15:55 CEST +- system: Linux 6.8.0-60-generic +- OS: ubuntu 22.04 +- processor: 13th Gen Intel(R) Core(TM) i7-13700H +- python version: 3.12.8.final.0 (64 bit) +- numpy version: 2.0.2 +- pandas version: 2.3.3 +- pandapower version: 3.4.0 +- grid2op version: 1.12.5.dev0 +- lightsim2grid version: 1.0.0.rc2 +- lightsim2grid extra information: + + - klu_solver_available: True + - nicslu_solver_available: False + - cktso_solver_available: False + - compiled_march_native: False + - compiled_o3_optim: False + +=========== =========== =========== ======== ====================== + nb_thread nb solved time (ms) pf / s speed-up vs 1 thread +=========== =========== =========== ======== ====================== + 1 703 4213.12 167 1.00x + 2 703 2367.70 297 1.78x + 3 703 1791.98 392 2.35x + 4 703 1451.98 484 2.90x + 5 703 1276.28 551 3.30x + 6 703 1250.20 562 3.37x + 7 703 1230.02 572 3.43x + 8 703 1184.67 593 3.56x +=========== =========== =========== ======== ====================== + +As documented above, speed-up is sub-linear (the per-thread set-up cost, plus the +already-fast single-thread baseline, both eat into the theoretical ``nb_thread``-x +speed-up): most of the gain is captured by 4-5 threads, with diminishing returns beyond +that on this grid / contingency count. + Reporting limit violations --------------------------- diff --git a/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py b/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py index c69c74d0..905e0a3e 100644 --- a/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py +++ b/lightsim2grid/network/from_matpower/_mpc_to_powermodels.py @@ -65,7 +65,7 @@ def mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) -> dict: "pmin": float(gen[i, PMIN]), } - n_weird_shift = 0 + weird_shift_rows = [] for i in range(branch.shape[0]): key = str(i + 1) tap = float(branch[i, TAP]) @@ -75,7 +75,7 @@ def mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) -> dict: # matpower's own convention: a non-transformer (TAP == 0) branch has no # meaningful phase shift; `from_matpower`'s legacy array-based loader # silently dropped it too (it never passed SHIFT to `init_powerlines`) - n_weird_shift += 1 + weird_shift_rows.append(i) shift_degree = 0. network["branch"][key] = { "f_bus": int(branch[i, F_BUS]), @@ -90,8 +90,9 @@ def mpc_to_powermodels(bus, gen, branch, dcline, baseMVA) -> dict: "rate_a": float(branch[i, RATE_A]), "br_status": int(branch[i, BR_STATUS]), } - if n_weird_shift: - warnings.warn(f"{n_weird_shift} branch(es) have `TAP == 0` (so are treated as a plain " + if weird_shift_rows: + warnings.warn(f"{len(weird_shift_rows)} branch(es) (0-based mpc `branch` row ids: " + f"{weird_shift_rows}) have `TAP == 0` (so are treated as a plain " "powerline) but a non-zero `SHIFT`, which is not physically meaningful " "for a plain line. The `SHIFT` will be ignored for these branches.") From 470fb1f965835c4344e8e015703193632e3c7cd5 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 16:05:19 +0200 Subject: [PATCH 120/166] _olf_params.py: robust version parsing + clarify remove_outer_loops intent Per PR #140 self-review: - Replace the hand-rolled `_ver_tuple` pypowsybl-version parser with `packaging.version.parse` (already used the same way elsewhere in this package, eg lightSimBackend.py / _from_pypowsybl.py), avoiding the tuple-comparison corner cases (dev/rc/post releases) a manual parser gets wrong. - The review also questioned whether `remove_outer_loops`'s inline-mode substitution should override an outer-loop mode the caller's input `Parameters` already requested. Investigated: this override is intentional and, since the review, already made explicit by the `keep=` parameter and its docstring (this function's entire purpose is producing an outer-loop-free config comparable to lightsim2grid's single-shot solve). No behavior change; added one more explicit note next to `_INLINE_MODE` tying this back to the reviewer's root-change concern and to `_olf_bake.py`'s "should agree, not always does" caveat. Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_olf_params.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_params.py b/lightsim2grid/network/from_pypowsybl/_olf_params.py index 4b709276..2c3f45ef 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_params.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_params.py @@ -76,26 +76,14 @@ import pypowsybl as _pp import pypowsybl.loadflow as _lf - - -def _ver_tuple(v: str): - out = [] - for part in str(v).split("."): - num = "" - for ch in part: - if ch.isdigit(): - num += ch - else: - break - out.append(int(num) if num else 0) - return tuple(out) +from packaging import version as _version _PARAM_SIG = set(inspect.signature(_lf.Parameters.__init__).parameters) -_PYPOWSYBL_VER = _ver_tuple(_pp.__version__) +_PYPOWSYBL_VER = _version.parse(_pp.__version__) # OLF rejects an empty ``outerLoopNames`` value before 1.4.0 (and the parameter # does not exist at all in 1.0.0); only use the allow-list where it is accepted. -_OUTERLOOP_EMPTY_OK = _PYPOWSYBL_VER >= (1, 4) +_OUTERLOOP_EMPTY_OK = _PYPOWSYBL_VER >= _version.parse("1.4") # Lazily-computed cache of the OLF provider parameter metadata # ({name: set(possible_values) or None}); ``False`` means "not yet computed". @@ -148,7 +136,15 @@ def _olf_provider_params(): # Outer loops with an inline (inner-Newton-Raphson) alternative: switching the # mode keeps the modeled control active while removing the separate outer-loop # pass. Works on every pypowsybl version, so these are always applied unless -# the caller explicitly asks to ``keep`` the outer-loop-based mode. +# the caller explicitly asks to ``keep`` the outer-loop-based mode. This *is* +# a deliberate override of whatever mode ``parameters`` already had -- not a +# bug: this whole function's job is to make OLF solve the same single-shot, +# outer-loop-free problem lightsim2grid solves (see module docstring), so an +# input that requests an outer-loop-based mode is exactly what gets changed +# by default. Yes, that can in principle move OLF to a different NR root than +# the caller's original (with-loops) parameters would have (see +# ``_olf_bake.py``'s "should agree" caveat); pass the loop's name in ``keep`` +# if that is not acceptable for a given comparison. _INLINE_MODE = { "TransformerVoltageControl": ("transformerVoltageControlMode", "WITH_GENERATOR_VOLTAGE_CONTROL"), "ShuntVoltageControl": ("shuntVoltageControlMode", "WITH_GENERATOR_VOLTAGE_CONTROL"), From cf80027c1b52c2776f3aac673f309efe69596b98 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 16:05:31 +0200 Subject: [PATCH 121/166] _from_pypowsybl.py: document the net-vs-net_pu split for dangling lines/SVC Per PR #140 self-review, which flagged possible per-unit bugs from reading `net` instead of `net_pu` for dangling lines and SVC. Investigated both, empirically (native `per_unit=True` and the legacy `PerUnitView`, on a small SVC test network): - Dangling lines: `df_dl` (built from `net`) is only ever used for ids/`connected` and `p0`/`q0` (legitimately physical MW/MVAr, like every other load here); the per-unit-sensitive r/x/g/b are already read from `net_pu` separately (`df_dl_pu`). No bug. - SVC: `target_v` converts to per-unit exactly like pypowsybl's own per-unit view does. `slope` (from the `voltagePerReactivePowerControl` extension) and `b_min`/`b_max` are a different story: pypowsybl's own per-unit view leaves both in physical units (Siemens for b_min/b_max) even under `per_unit=True` -- verified empirically, not just assumed. Deferring to `net_pu` there would silently reintroduce the ~500x susceptance bug the existing code comment already describes. No bug; the manual conversion is required. No functional change -- comments only, explaining why `net` is used where it is so a future reader doesn't "simplify" this into a `net_pu` call and reintroduce either issue. Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_from_pypowsybl.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py index 3c6f48e4..5315ecf3 100644 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py @@ -90,6 +90,14 @@ def _aux_dangling_lines_fictitious(net, sort_index): ``None`` and ``df_dl`` is empty if the grid has no dangling line. ``df_dl`` carries two extra columns, ``boundary_bus_id`` / ``boundary_vl_id``, used later to attach the equivalent branch + load. + + Deliberately built from ``net`` (physical units), not ``net_pu``: every + field actually read off ``df_dl`` downstream is unit-agnostic (ids, + ``connected``) or physical on purpose (``p0``/``q0`` in MW/MVAr, matching + every other load in this module). The per-unit-sensitive series + impedance (r/x/g/b) is read separately, from ``net_pu.get_dangling_lines()`` + (see the ``df_dl_pu`` fetch further down) -- ``net``/``df_dl`` is never + used for those. """ df_dl = net.get_dangling_lines() if sort_index: @@ -1283,6 +1291,11 @@ def _uf_union(a, b): # optional voltage/reactive-power slope ("droop"), in kV/MVar: # s_pu = slope[kV/MVar] * sn_mva / vn_kv(regulated bus) (Phase 0 probe #1) + # Read from `net` (not `net_pu`): pypowsybl's per-unit view (native + # `per_unit=True` and the legacy `PerUnitView`) does not per-unit this + # extension at all -- `slope` comes back numerically identical whether + # or not `per_unit` is set (checked empirically) -- so there is no + # `net_pu` value to defer to here; the conversion has to be done by hand. try: df_slope = net.get_extensions("voltagePerReactivePowerControl") except Exception: @@ -1307,6 +1320,12 @@ def _uf_union(a, b): # (or, since check_solution never enforces SVC Q limits, silently hits a "hard" # voltage pin at its own target_vm_pu regardless of what Q that would truly take) # long before the real device would. + # This CANNOT be replaced by reading `net_pu.get_static_var_compensators()` + # instead: checked empirically that pypowsybl's per-unit view (native + # `per_unit=True` and the legacy `PerUnitView`) leaves `b_min`/`b_max` + # in SIEMENS even under `per_unit=True` -- it only per-units `target_v` + # (and, generically, elements it fully models) -- so relying on it here + # would silently reintroduce this exact bug. b_min = df_svc["b_min"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used b_max = df_svc["b_max"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used else: From 3b876603d51f70b8d423e15202053a101dd1a83d Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 16:05:40 +0200 Subject: [PATCH 122/166] _result_network.py: align angle datum on the actual slack bus, not a median Per PR #140 self-review: the uniform angle-offset correction in _build_buses used a median across all buses, flagged as fragile. Note the review's suggested replacement, `get_reference_slack_bus()`, turns out not to be the right API for this: it returns the *forced* override set via `set_reference_slack_bus` (-1, i.e. "none", unless something explicitly pins it -- which nothing in this codebase does today), not the bus actually used as angle reference by the last solve. That bus is `get_slack_ids()[0]` (gridmodel id space, per the "index 0 is the NR angle reference" invariant documented in ContingencyAnalysis.cpp). Compare that bus's own angle on both sides (lightsim2grid's solved value vs `net`'s) for an exact offset instead of a statistical approximation; fall back to the previous median-based estimate only when the reference bus can't be resolved on either side (fused away, disconnected, or no slack). Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_result_network.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lightsim2grid/network/from_pypowsybl/_result_network.py b/lightsim2grid/network/from_pypowsybl/_result_network.py index b67f71ec..b9869cbb 100644 --- a/lightsim2grid/network/from_pypowsybl/_result_network.py +++ b/lightsim2grid/network/from_pypowsybl/_result_network.py @@ -173,13 +173,31 @@ def _build_buses(self) -> pd.DataFrame: # a uniform angle offset across every bus is just a difference of # reference-datum convention (lightsim2grid's slack bus needs not be # at the same angle-datum as whatever `net` last held), not physical: - # remove it by aligning medians, mirroring the "offset-removed" angle - # metric already used by `_olf_compare.py::compare_baked`. + # remove it. Preferred: compare the two engines' own angle at the + # *same* bus -- lightsim2grid's actual solved reference slack, + # `get_slack_ids()[0]` (see `ContingencyAnalysis.cpp`'s "index 0 is + # the NR angle reference" invariant) -- which is exact, unlike a + # median across all buses. Falls back to the median-based estimate + # (mirroring the "offset-removed" angle metric already used by + # `_olf_compare.py::compare_baked`) when the reference bus can't be + # resolved on either side (eg fused away, disconnected, or the grid + # has no slack at all). orig_angle = self._net.get_buses()["v_angle"] - mask_orig = np.isfinite(orig_angle.to_numpy()) & (orig_angle.abs() > 1e-6) - mask_new = np.isfinite(res["v_angle"].to_numpy()) & (res["v_angle"].abs() > 1e-6) - if mask_orig.any() and mask_new.any(): - offset = res.loc[mask_new, "v_angle"].median() - orig_angle.loc[mask_orig].median() + offset = None + slack_ids = np.asarray(grid.get_slack_ids()) + if slack_ids.shape[0] and slack_ids[0] >= 0: + ref_pypo_id = self._ls_bus_to_pypo(int(slack_ids[0])) + if ref_pypo_id is not None and ref_pypo_id in res.index and ref_pypo_id in orig_angle.index: + new_ref_angle = res.at[ref_pypo_id, "v_angle"] + orig_ref_angle = orig_angle.at[ref_pypo_id] + if np.isfinite(new_ref_angle) and np.isfinite(orig_ref_angle): + offset = new_ref_angle - orig_ref_angle + if offset is None: + mask_orig = np.isfinite(orig_angle.to_numpy()) & (orig_angle.abs() > 1e-6) + mask_new = np.isfinite(res["v_angle"].to_numpy()) & (res["v_angle"].abs() > 1e-6) + if mask_orig.any() and mask_new.any(): + offset = res.loc[mask_new, "v_angle"].median() - orig_angle.loc[mask_orig].median() + if offset is not None: res["v_angle"] = res["v_angle"] - offset return res From 33ac4ba892a6f43600b6f15fb8e7fab6e9c3eae0 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 18:23:05 +0200 Subject: [PATCH 123/166] ContingencyResult: add element_names alongside element_ids Per PR #140 self-review: element_ids alone (branch ids disconnected by a contingency) requires the caller to separately look up names; add element_names (env.name_line, lines-then-trafos, same convention as element_ids) so it's available directly on the result. Signed-off-by: DONNOT Benjamin --- lightsim2grid/contingencyAnalysis.py | 3 +++ .../tests/test_ContingencyAnalysis_limit_violations.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/lightsim2grid/contingencyAnalysis.py b/lightsim2grid/contingencyAnalysis.py index cb6a7e57..fbb7013c 100644 --- a/lightsim2grid/contingencyAnalysis.py +++ b/lightsim2grid/contingencyAnalysis.py @@ -55,6 +55,7 @@ class ContingencyResult: solver ran but did not converge). """ element_ids: List[int] #: branch ids (lines then trafos) disconnected by this contingency + element_names: List[str] #: names (`env.name_line`) of the elements disconnected by this contingency contingency_name: Optional[str] #: user-supplied name, see `add_single_contingency` converged: bool limit_violations: List[LimitViolation] @@ -487,6 +488,7 @@ def run(self) -> SecurityAnalysisResult: ... for cont in res.post_contingency_results: # a list, ordered like `add_single_contingency` calls cont.element_ids # branch ids disconnected by this contingency (always present) + cont.element_names # names (env.name_line) of these same elements (always present) cont.contingency_name # optional, user-supplied via add_single_contingency(..., name=...) cont.converged cont.limit_violations @@ -525,6 +527,7 @@ def run(self) -> SecurityAnalysisResult: post_contingency_results = [ ContingencyResult( element_ids=list(all_defaults[id_cpp]), + element_names=[str(self._ls_backend.name_line[el_id]) for el_id in all_defaults[id_cpp]], contingency_name=self._contingency_names.get(self._all_contingencies[id_me]), converged=bool(converged[id_cpp]), limit_violations=list(violations[id_cpp]), diff --git a/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py b/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py index 8992c819..37d2c78e 100644 --- a/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py +++ b/lightsim2grid/tests/test_ContingencyAnalysis_limit_violations.py @@ -219,6 +219,7 @@ def test_base_case_and_per_contingency_violations(self): assert (ViolationElementType.BUS, LimitViolationType.LOW_VOLTAGE) in types_seen assert (ViolationElementType.LINE, LimitViolationType.CURRENT) in types_seen assert (ViolationElementType.TRAFO, LimitViolationType.CURRENT) in types_seen + sub_names = self.env.backend._grid.get_substation_names() for v in viol_n: assert np.isfinite(v.value) assert np.isfinite(v.limit) @@ -226,6 +227,10 @@ def test_base_case_and_per_contingency_violations(self): assert v.value < v.limit else: assert v.value > v.limit + if v.element_type == ViolationElementType.BUS: + # `name` is the *substation* the violating bus belongs to (no per-bus name + # exists in LSGrid), not the bus id itself + assert v.name == sub_names[v.element_id] violations = SA.get_violations() assert sum(len(v) for v in violations) > 0 @@ -392,6 +397,7 @@ def test_run_result_shape_order_and_names(self): for cont, exp_ids, exp_name in zip(res.post_contingency_results, expected_ids, expected_names): assert isinstance(cont, ContingencyResult) assert cont.element_ids == exp_ids + assert cont.element_names == [str(self.env.name_line[el_id]) for el_id in exp_ids] assert cont.contingency_name == exp_name assert cont.converged is True assert len(cont.limit_violations) > 0 From ae464176a5a08ed674177b5c228b384662609a3c Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 18:23:25 +0200 Subject: [PATCH 124/166] LimitViolation.name: report the substation name for BUS violations Per PR #140 self-review: a BUS voltage-limit violation's `name` was always empty (LSGrid has no per-bus name, only per-substation ones). Add SubstationContainer::sub_id_of_bus (reverse of the existing local_to_gridmodel layout: gridmodel bus id % n_sub_ recovers the substation id) and use it in check_bus_voltage_violations to populate `name` with the violating bus's substation name, mirroring what LINE / TRAFO violations already get from their own element names. Signed-off-by: DONNOT Benjamin --- docs/security_analysis.rst | 12 +++++++---- src/bindings/python/binding_batch.cpp | 8 ++++--- src/core/SubstationContainer.hpp | 7 +++++++ .../batch_algorithm/ContingencyAnalysis.cpp | 21 +++++++++++++------ src/core/batch_algorithm/LimitViolation.hpp | 7 ++++--- 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/security_analysis.rst b/docs/security_analysis.rst index 59566b4f..5f802c1c 100644 --- a/docs/security_analysis.rst +++ b/docs/security_analysis.rst @@ -254,12 +254,16 @@ Each ``LimitViolation`` reports: - ``violation_type``: :class:`lightsim2grid.contingencyAnalysis.LimitViolationType` (``LOW_VOLTAGE``, ``HIGH_VOLTAGE``, ``CURRENT``, ``NOT_SIMULATED`` or ``DIVERGENCE``, see below); - ``value`` / ``limit``: the value reached and the limit that was violated; unused (``NaN``) for - ``NOT_SIMULATED`` / ``DIVERGENCE``. + ``NOT_SIMULATED`` / ``DIVERGENCE``; +- ``name``: for ``LINE`` / ``TRAFO``, the element's own name (see ``LSGrid.set_line_names`` / + ``set_trafo_names``); for ``BUS``, the name of the *substation* the violating bus belongs to + (see ``LSGrid.set_substation_names`` -- there is no per-bus name, only per-substation ones); + empty string if names were never set on the grid for the relevant kind, or for ``GRID``. Each ``ContingencyResult`` reports ``element_ids`` (the branch ids disconnected by this -contingency -- always present, even without a ``name``), the optional user-supplied -``contingency_name`` (set via ``add_single_contingency(..., name=...)``), whether the -contingency ``converged``, and its ``limit_violations``. +contingency -- always present, even without a ``name``) and the corresponding ``element_names``, +the optional user-supplied ``contingency_name`` (set via ``add_single_contingency(..., name=...)``), +whether the contingency ``converged``, and its ``limit_violations``. .. warning:: diff --git a/src/bindings/python/binding_batch.cpp b/src/bindings/python/binding_batch.cpp index 5239f5c2..1d804c5f 100644 --- a/src/bindings/python/binding_batch.cpp +++ b/src/bindings/python/binding_batch.cpp @@ -46,9 +46,11 @@ void bind_batch(py::module_& m) { .def_readonly("limit", &LimitViolation::limit, "limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE") .def_readonly("name", &LimitViolation::name, - "element name (LINE / TRAFO only, see LSGrid.set_line_names / set_trafo_names) ; " - "empty string if names were never set on the grid, or for BUS / GRID (no per-bus " - "name exists in LSGrid, only per-substation ones)"); + "element name: LINE / TRAFO (see LSGrid.set_line_names / set_trafo_names) or, for " + "BUS, the name of the substation the violating bus belongs to (see " + "LSGrid.set_substation_names) -- there is no per-bus name in LSGrid, only " + "per-substation ones. Empty string if names were never set on the grid for the " + "relevant kind, or for GRID."); py::class_(m, "TimeSeriesCPP", DocComputers::Computers.c_str()) .def(py::init()) diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 7fb42d4a..fac35b8a 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -243,6 +243,13 @@ class LS2G_API SubstationContainer final : public IteratorAdder bus_vmin_kv, Eigen::Ref bus_vmax_kv, Eigen::Ref bus_vn_kv, + const SubstationContainer & subs, const std::vector * masked_solver_ids, std::vector & out) { const Eigen::Index nb_bus = bus_vmin_kv.size(); if(nb_bus == 0) return; // voltage limits never configured on this grid + const std::vector & sub_names = subs.get_sub_names(); // empty if never set + std::vector is_masked; if(masked_solver_ids != nullptr && !masked_solver_ids->empty()){ is_masked.assign(static_cast(V.size()), false); @@ -56,11 +60,15 @@ void check_bus_voltage_violations( const real_type vm_kv = std::abs(V(solver_id)) * bus_vn_kv(grid_id); if(!isnan(vmin) && vm_kv < vmin){ + const int sub_id = subs.sub_id_of_bus(static_cast(grid_id)); + const std::string sub_name = static_cast(sub_id) < sub_names.size() ? sub_names[sub_id] : std::string(); out.push_back(LimitViolation{ViolationElementType::BUS, static_cast(grid_id), 0, - LimitViolationType::LOW_VOLTAGE, vm_kv, vmin}); + LimitViolationType::LOW_VOLTAGE, vm_kv, vmin, sub_name}); } else if(!isnan(vmax) && vm_kv > vmax){ + const int sub_id = subs.sub_id_of_bus(static_cast(grid_id)); + const std::string sub_name = static_cast(sub_id) < sub_names.size() ? sub_names[sub_id] : std::string(); out.push_back(LimitViolation{ViolationElementType::BUS, static_cast(grid_id), 0, - LimitViolationType::HIGH_VOLTAGE, vm_kv, vmax}); + LimitViolationType::HIGH_VOLTAGE, vm_kv, vmax, sub_name}); } } } @@ -637,7 +645,8 @@ void ContingencyAnalysis::compute(const Eigen::Ref & Vinit, int const std::vector no_skip; check_bus_voltage_violations(V_n, id_me_to_solver_, _grid_model.get_bus_vmin_kv(), _grid_model.get_bus_vmax_kv(), - _grid_model.get_bus_vn_kv(), nullptr, _violations_n_); + _grid_model.get_bus_vn_kv(), _grid_model.get_substations(), + nullptr, _violations_n_); check_current_violations(_grid_model.get_powerlines_as_data(), ViolationElementType::LINE, V_n, id_me_to_solver_, _grid_model.get_bus_vn_kv(), ac_solver_used, sn_mva, @@ -902,8 +911,8 @@ void ContingencyAnalysis::run_contingency_range( ? &_li_masked[cont_id] : nullptr; check_bus_voltage_violations(V, id_me_to_solver_, _grid_model.get_bus_vmin_kv(), _grid_model.get_bus_vmax_kv(), - _grid_model.get_bus_vn_kv(), masked_ids, - _violations[cont_id]); + _grid_model.get_bus_vn_kv(), _grid_model.get_substations(), + masked_ids, _violations[cont_id]); check_current_violations(_grid_model.get_powerlines_as_data(), ViolationElementType::LINE, V, id_me_to_solver_, _grid_model.get_bus_vn_kv(), ac_solver_used, sn_mva, diff --git a/src/core/batch_algorithm/LimitViolation.hpp b/src/core/batch_algorithm/LimitViolation.hpp index 065876b8..bbac3358 100644 --- a/src/core/batch_algorithm/LimitViolation.hpp +++ b/src/core/batch_algorithm/LimitViolation.hpp @@ -39,9 +39,10 @@ struct LS2G_API LimitViolation { LimitViolationType violation_type; real_type value; // value reached ; unused (NaN) for NOT_SIMULATED / DIVERGENCE real_type limit; // limit that was violated ; unused (NaN) for NOT_SIMULATED / DIVERGENCE - // element name (LINE / TRAFO only, from LSGrid::set_line_names / set_trafo_names) ; empty - // string if the grid never had names set for this element type, or for BUS / GRID (no - // per-bus name exists in LSGrid, only per-substation ones) + // element name: LINE / TRAFO (from LSGrid::set_line_names / set_trafo_names) or, for BUS, + // the name of the *substation* the violating bus belongs to (from + // LSGrid::set_substation_names) -- there is no per-bus name in LSGrid, only per-substation + // ones. Empty string if the grid never had names set for the relevant kind, or for GRID. std::string name{}; }; From 7363e95c04e3d8f5bcab257c9df62d96142487bd Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 18:29:34 +0200 Subject: [PATCH 125/166] Document slack_ids[0]-must-be-reference invariant at point of use Per PR #140 self-review: the "keep the ref slack as the first slack" invariant was only commented where ContingencyAnalysis maintains it (reordering slack_ids), not where NR/DC actually consume it. Found the single shared choke point every non-NR algorithm (DC, Fast-Decoupled, Gauss-Seidel) funnels through for this -- BaseAlgo::retrieve_pv_with_slack -- and strengthened its existing (correct but soft-spoken) doc comment to state the invariant explicitly and its failure mode (silent wrong results, not a crash). Added a shorter pointer to it from NRSystem's MultiSlack extension, which makes the same `slack_ids[0]` assumption independently for the NR path. Comments only, no behavior change. Signed-off-by: DONNOT Benjamin --- src/core/powerflow_algorithm/BaseAlgo.hpp | 10 ++++++++++ src/core/powerflow_algorithm/NRSystem.hpp | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 757a9586..d439b503 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -383,6 +383,16 @@ class LS2G_API BaseAlgo : public BaseConstants /** When there are multiple slacks, add the other "slack buses" in the PV buses indexes (behaves as if only the first element is used for the slack !!!, called "ref slack") + + `slack_ids(0)` MUST be the intended reference / angle slack whenever this is called -- + every algorithm family (DC via BaseDCAlgo, Fast-Decoupled via BaseFDPFAlgo, Gauss-Seidel) + funnels through this one method for that choice, and the (single-slack) Newton-Raphson + path makes the same assumption independently in NRSystem's MultiSlack extension + (`ref_slack_id_ = slack_ids[0]`, see NRSystem.hpp). Reordering `slack_ids` so a different + bus ends up at index 0 (eg ContingencyAnalysis::select_ref_slack_and_masks) silently + changes which bus is removed from the reduced linear system / used as the angle datum -- + not a crash, just wrong (or non-reproducible) results that are miserable to debug back to + this assumption. **/ Eigen::VectorXi retrieve_pv_with_slack(Eigen::Ref slack_ids, Eigen::Ref pv) const { diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index a44d96d8..3c2b77c4 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -316,6 +316,9 @@ class LS2G_API MultiSlack // distributed-slack extension Eigen::Ref /*pq*/ ) { my_size_ = static_cast(slack_ids.size()); + // `slack_ids[0]` is the reference bus by convention, shared with every other + // algorithm family -- see BaseAlgo::retrieve_pv_with_slack's doc comment for why + // getting this wrong is silent and hard to debug, not a crash. ref_slack_id_ = slack_ids[0]; // slack buses in registration order: non-ref slacks sorted by bus // id, then the ref slack last From 2040998ded37dddf7e6f88a6a1912a7481567d65 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 18:30:16 +0200 Subject: [PATCH 126/166] LightsimResultNetwork: document supported element types + per-method columns Per PR #140 self-review, applied uniformly across ~9 flagged locations: - class docstring: which pypowsybl element types are supported (one get_* method each) and which are not, even when present on the input (dangling lines -- no get_dangling_lines, including with convert_dangling_lines=True; three-winding transformers, not modeled by _from_pypowsybl.init at all). - every get_* method: its exact columns, split into the three actual provenance categories -- solved-powerflow results (read off the solved LSGrid), topology/metadata (read off LSGrid's *current* state, which only mirrors `net` until something changes the grid after import), and the couple of columns read verbatim from the original `net` and frozen at construction time (only converter_station1_id/2_id, on get_hvdc_lines). The reviewer's original "native vs computed" framing undersold this: almost nothing here is a live passthrough from `net`. Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_result_network.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/lightsim2grid/network/from_pypowsybl/_result_network.py b/lightsim2grid/network/from_pypowsybl/_result_network.py index b9869cbb..9abd41ca 100644 --- a/lightsim2grid/network/from_pypowsybl/_result_network.py +++ b/lightsim2grid/network/from_pypowsybl/_result_network.py @@ -57,6 +57,39 @@ class LightsimResultNetwork: it accepts an optional ``attributes`` list and returns a DataFrame indexed by the pypowsybl element id, built lazily on first call and cached afterwards. + + **Supported element types** (one ``get_*`` method each): buses, lines, + 2-winding transformers, generators, loads, shunt compensators, static var + compensators, batteries/storage units, HVDC lines, and VSC / LCC + converter stations. + + **Not exposed here**, even when present on ``net`` (or, for dangling + lines, on the ``LSGrid`` itself): dangling lines -- no ``get_dangling_lines`` + method, including when the grid was built with + ``init_from_pypowsybl(..., convert_dangling_lines=True)`` -- and + three-winding transformers, which `_from_pypowsybl.init` does not model + at all (not just unexposed here). + + **Column provenance**, for every ``get_*`` method's DataFrame: + + - power-flow *results* (``p``/``q``/``i``/``i1``/``i2``/``p1``/``p2``/ + ``q1``/``q2``/``v_mag``/``v_angle``) are read off the *solved* ``LSGrid`` + -- this specific powerflow's outcome, not the original ``net``'s. + - topology / metadata columns (``bus_id``/``bus1_id``/``bus2_id``, + ``connected``/``connected1``/``connected2``, + ``voltage_level_id``/``voltage_level1_id``/``voltage_level2_id``, + ``is_lcc``) reflect ``LSGrid``'s *current* state, which mirrors ``net`` + only as long as nothing changed the grid (topology, connectivity, ...) + after ``init_from_pypowsybl`` built it -- they are not re-read from + ``net`` on every call. + - a handful of columns are read verbatim from the original ``net`` and + frozen at construction time, never from ``LSGrid``: currently only + ``converter_station1_id`` / ``converter_station2_id`` on + :meth:`get_hvdc_lines`. + - every DataFrame's index (``id``) mirrors the element's pypowsybl id by + construction (`_from_pypowsybl.init` sets every non-bus element's + lightsim2grid ``name`` verbatim to it, see the module docstring), even + though it is technically sourced from ``LSGrid``, not read from ``net``. """ def __init__(self, ls_grid: LSGrid, net: "pypo.network.Network"): @@ -136,6 +169,10 @@ def _ls_sub_to_vl(self, sub_id: int) -> Optional[str]: # buses # ------------------------------------------------------------------ # def get_buses(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """Columns: ``v_mag`` (kV, solved result), ``v_angle`` (degree, solved + result, offset-aligned to ``net``'s own angle datum -- see + :meth:`_build_buses`), ``voltage_level_id`` (topology, from ``net``). + """ if "buses" not in self._cache: self._cache["buses"] = self._build_buses() return self._maybe_select(self._cache["buses"], attributes) @@ -206,10 +243,19 @@ def _build_buses(self) -> pd.DataFrame: # lines / transformers # ------------------------------------------------------------------ # def get_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """Columns: ``p1``/``q1``/``i1``/``p2``/``q2``/``i2`` (solved results, + MW/MVAr/A), ``bus1_id``/``bus2_id``/``connected1``/``connected2``/ + ``voltage_level1_id``/``voltage_level2_id`` (topology, from the + *current* ``LSGrid`` state, see the class docstring). See + :meth:`_reconstruct_fused_branches` for how a fused (near-zero-impedance) + line's flow is recovered where possible, instead of reporting 0. + """ self._ensure_lines_trafos() return self._maybe_select(self._cache["lines"], attributes) def get_2_windings_transformers(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """Same columns as :meth:`get_lines` (this class does not expose tap + position / ratio / phase-shift columns).""" self._ensure_lines_trafos() return self._maybe_select(self._cache["trafos"], attributes) @@ -407,27 +453,48 @@ def _reconstruct_fused_branches(self, lines_df, trafos_df, fused_line_ids, fused # ------------------------------------------------------------------ # # generators / loads / shunts / svc / batteries: one-sided elements # ------------------------------------------------------------------ # + # Shared by every one-sided `get_*` below -- columns: ``p``/``q`` (solved + # results, MW/MVAr, sign per the module docstring's convention discussion), + # ``bus_id``/``connected``/``voltage_level_id`` (topology, from the + # *current* ``LSGrid`` state, see the class docstring). + def get_generators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """See the one-sided column list above ``get_generators``. ``p``/``q`` + use the generation sign convention, negated from lightsim2grid's + internal convention (see the module docstring).""" if "generators" not in self._cache: self._cache["generators"] = self._build_one_sided(self._grid.get_generators(), flip_sign=True) return self._maybe_select(self._cache["generators"], attributes) def get_loads(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """See the one-sided column list above :meth:`get_generators`. ``p``/``q`` + already match pypowsybl's convention, no sign flip (see the module + docstring).""" if "loads" not in self._cache: self._cache["loads"] = self._build_one_sided(self._grid.get_loads(), flip_sign=False) return self._maybe_select(self._cache["loads"], attributes) def get_shunt_compensators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """See the one-sided column list above :meth:`get_generators`. No sign + flip, same as :meth:`get_loads`. Does not expose section count / + susceptance columns.""" if "shunts" not in self._cache: self._cache["shunts"] = self._build_one_sided(self._grid.get_shunts(), flip_sign=False) return self._maybe_select(self._cache["shunts"], attributes) def get_static_var_compensators(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """See the one-sided column list above :meth:`get_generators`. Assumed + to use the same generation sign convention as generators (see the + module docstring's caveat: not independently double-checked against a + converged real grid). Does not expose the regulation mode / slope / + b_min / b_max columns.""" if "svcs" not in self._cache: self._cache["svcs"] = self._build_one_sided(self._grid.get_svcs(), flip_sign=True) return self._maybe_select(self._cache["svcs"], attributes) def get_batteries(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """See the one-sided column list above :meth:`get_generators`. No sign + flip, same as :meth:`get_loads`.""" if "batteries" not in self._cache: self._cache["batteries"] = self._build_one_sided(self._grid.get_storages(), flip_sign=False) return self._maybe_select(self._cache["batteries"], attributes) @@ -450,16 +517,29 @@ def _build_one_sided(self, container, flip_sign: bool) -> pd.DataFrame: # hvdc lines / converter stations # ------------------------------------------------------------------ # def get_hvdc_lines(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """Columns: ``p1``/``q1``/``p2``/``q2`` (solved results, MW/MVAr, + generation sign convention, negated from lightsim2grid's internal + convention), ``connected1``/``connected2`` (topology, from the + *current* ``LSGrid`` state), ``converter_station1_id`` / + ``converter_station2_id`` (the only columns in this whole class read + verbatim from the original ``net`` and frozen at construction time -- + see the class docstring).""" if "hvdc_lines" not in self._cache: self._build_hvdc() return self._maybe_select(self._cache["hvdc_lines"], attributes) def get_vsc_converter_stations(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """Columns: ``p``/``q`` (solved results, MW/MVAr, generation sign + convention), ``bus_id``/``connected``/``voltage_level_id`` (topology, + from the *current* ``LSGrid`` state, see the class docstring). Does + not expose ``target_v``/``target_q``/``voltage_regulator_on``.""" if "vsc_stations" not in self._cache: self._build_hvdc() return self._maybe_select(self._cache["vsc_stations"], attributes) def get_lcc_converter_stations(self, attributes: Optional[List[str]] = None) -> pd.DataFrame: + """Same columns as :meth:`get_vsc_converter_stations` (this class does + not expose ``power_factor``).""" if "lcc_stations" not in self._cache: self._build_hvdc() return self._maybe_select(self._cache["lcc_stations"], attributes) From 9231e9c84b3d14fb71210d6c08f3e6f45e7c8df0 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 18:40:09 +0200 Subject: [PATCH 127/166] Add shared exotic-elements test fixture; wire into HVDC/SVC round trips Per PR #140 self-review: test_binary_hvdc/test_binary_svcs and their test_pickleable.py counterparts round-tripped l2rpn_idf_2023's containers, but that grid2op environment carries neither an SVC nor any HVDC line -- so these tests were round-tripping empty containers, covering nothing about those element types (as the reviewer put it, "this basically tests nothing"). Add lightsim2grid/tests/_exotic_elements_fixture.py: IEEE14 augmented with an SVC, a storage unit, an HVDC VSC line with angle-droop, an HVDC VSC line without, an HVDC LCC line, and a phase-shifting transformer, built once via init_from_pypowsybl (no grid2op env at all -- also faster than the l2rpn_idf_2023 path it partially replaces). Deviates from a literal "LCC with droop" reading: HvdcLineContainer::init only supports the angle-droop extension for VSC-VSC lines, so LCC-with-droop is substituted with a second VSC line (with vs. without droop) for equivalent regime coverage -- see the fixture's module docstring. Storage was already covered (l2rpn_idf_2023 has 7 batteries), so left as-is. Wire the new fixture into test_binary_hvdc/test_binary_svcs and test_pickle_hvdc/test_pickle_svcs via an optional `grid=` parameter on each file's shared `_aux_test_binary`/`_aux_test_pickle` helper, leaving every other test in both files on l2rpn_idf_2023 unchanged. Signed-off-by: DONNOT Benjamin --- .../tests/_exotic_elements_fixture.py | 109 ++++++++++++++++++ .../tests/test_binary_serialization.py | 30 +++-- lightsim2grid/tests/test_pickleable.py | 35 ++++-- 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 lightsim2grid/tests/_exotic_elements_fixture.py diff --git a/lightsim2grid/tests/_exotic_elements_fixture.py b/lightsim2grid/tests/_exotic_elements_fixture.py new file mode 100644 index 00000000..3043f1c6 --- /dev/null +++ b/lightsim2grid/tests/_exotic_elements_fixture.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Shared "one of everything exotic" test grid: IEEE14 augmented with a static var +compensator, a storage unit, three HVDC lines (VSC with angle-droop, VSC without, +LCC without), and a phase-shifting transformer. + +Built for PR #140 self-review: ``test_binary_serialization.py`` / +``test_pickleable.py`` used a full ``l2rpn_idf_2023`` grid2op environment (118 +substations, slow to build) for every one of their per-element-type round-trip +tests, but that environment carries neither an SVC nor any HVDC line -- so +``test_binary_hvdc`` / ``test_binary_svcs`` (and their pickle counterparts) were +round-tripping empty containers, testing nothing about those element types. This +grid is built once (no grid2op environment at all -- ``init_from_pypowsybl`` +straight into an ``LSGrid``) and reused wherever SVC / HVDC coverage is needed. + +Deviation from a literal "1 HVDC LCC with droop, 1 HVDC LCC without droop, 1 HVDC +VSC" reading: lightsim2grid's ``HvdcLineContainer`` only supports the angle-droop +(AC emulation) extension for VSC-VSC lines (``HvdcLineContainer::init`` raises +otherwise) -- an LCC line with droop is not a configuration lightsim2grid can ever +load. Substituted for equivalent regime coverage: HVDC VSC-VSC *with* droop, HVDC +VSC-VSC *without* droop, and HVDC LCC-LCC (never has droop). +""" + +import pandas as pd + +import pypowsybl as pp + +from lightsim2grid.network import init_from_pypowsybl + + +def build_exotic_elements_network() -> "pp.network.Network": + """Return the raw pypowsybl ``Network`` (not yet solved / converted), for + tests that want to inspect or tweak it before conversion.""" + n = pp.network.create_ieee14() + + # SVC, voltage-regulating with a slope, on the (otherwise load-only) leaf bus B14 + n.create_static_var_compensators(id="SVC1", voltage_level_id="VL14", bus_id="B14", + regulation_mode="VOLTAGE", regulating=True, + target_v=12.0, target_q=0.0, b_min=-0.02, b_max=0.02) + n.create_extensions("voltagePerReactivePowerControl", id="SVC1", slope=0.01) + + # storage unit (battery), on the (otherwise load-only) leaf bus B13 + n.create_batteries(id="BATT1", voltage_level_id="VL13", bus_id="B13", + max_p=20.0, min_p=-20.0, target_p=5.0, target_q=1.0) + + # HVDC VSC-VSC, angle-droop (AC emulation) enabled + n.create_vsc_converter_stations(id="VSC1", voltage_level_id="VL10", bus_id="B10", loss_factor=1.1, + voltage_regulator_on=True, target_v=12.0, target_q=0.0) + n.create_vsc_converter_stations(id="VSC2", voltage_level_id="VL11", bus_id="B11", loss_factor=1.1, + voltage_regulator_on=True, target_v=12.0, target_q=0.0) + n.create_hvdc_lines(id="HVDC_VSC_DROOP", converter_station1_id="VSC1", converter_station2_id="VSC2", + r=1.0, nominal_v=12.0, converters_mode="SIDE_1_RECTIFIER_SIDE_2_INVERTER", + target_p=2.0, max_p=20.0) + n.create_extensions("hvdcAngleDroopActivePowerControl", id="HVDC_VSC_DROOP", + p0=1.0, droop=180.0, enabled=True) + + # HVDC VSC-VSC, fixed setpoint (no droop) + n.create_vsc_converter_stations(id="VSC3", voltage_level_id="VL12", bus_id="B12", loss_factor=1.1, + voltage_regulator_on=True, target_v=12.0, target_q=0.0) + n.create_vsc_converter_stations(id="VSC4", voltage_level_id="VL9", bus_id="B9", loss_factor=1.1, + voltage_regulator_on=True, target_v=12.0, target_q=0.0) + n.create_hvdc_lines(id="HVDC_VSC_NODROOP", converter_station1_id="VSC3", converter_station2_id="VSC4", + r=1.0, nominal_v=12.0, converters_mode="SIDE_1_RECTIFIER_SIDE_2_INVERTER", + target_p=2.0, max_p=20.0) + + # HVDC LCC-LCC, fixed setpoint (LCC never supports droop, see module docstring) + n.create_lcc_converter_stations(id="LCC1", voltage_level_id="VL14", bus_id="B14", + loss_factor=1.1, power_factor=0.9) + n.create_lcc_converter_stations(id="LCC2", voltage_level_id="VL13", bus_id="B13", + loss_factor=1.1, power_factor=0.9) + n.create_hvdc_lines(id="HVDC_LCC", converter_station1_id="LCC1", converter_station2_id="LCC2", + r=1.0, nominal_v=12.0, converters_mode="SIDE_1_RECTIFIER_SIDE_2_INVERTER", + target_p=1.0, max_p=20.0) + + # phase-shifting transformer: add a (non-regulating) phase tap changer to the + # existing T4-7-1 2-winding transformer, 3 steps (-5deg / 0deg / +5deg) + ptc_df = pd.DataFrame.from_records( + index="id", columns=["id", "tap", "low_tap", "regulating", "regulation_mode"], + data=[("T4-7-1", 1, 0, False, "CURRENT_LIMITER")]) + steps_df = pd.DataFrame.from_records( + index="id", columns=["id", "b", "g", "r", "x", "rho", "alpha"], + data=[("T4-7-1", 0., 0., 0., 0., 1.0, -5.0), + ("T4-7-1", 0., 0., 0., 0., 1.0, 0.0), + ("T4-7-1", 0., 0., 0., 0., 1.0, 5.0)]) + n.create_phase_tap_changers(ptc_df, steps_df) + + return n + + +def build_exotic_elements_grid(solve: bool = True): + """Return the grid above, converted to an :class:`LSGrid` via + ``init_from_pypowsybl`` (slack pinned at ``B1-G``). Solved (AC, flat start) + by default, matching what every ``*.get_*()`` "results" accessor expects.""" + net = build_exotic_elements_network() + grid = init_from_pypowsybl(net, gen_slack_id="B1-G") + if solve: + import numpy as np + v0 = np.ones(grid.total_bus(), dtype=complex) + conv = grid.ac_pf(v0, 20, 1e-7) + if len(conv) == 0: + raise RuntimeError("build_exotic_elements_grid: the ac_pf did not converge; " + "this fixture is expected to always converge, this is a bug.") + return grid diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index e8f93c97..7a683f21 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -7,6 +7,7 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. import struct +import sys import tempfile import os import unittest @@ -16,6 +17,10 @@ import grid2op from lightsim2grid.lightSimBackend import LightSimBackend + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _exotic_elements_fixture import build_exotic_elements_grid # noqa: E402 + from lightsim2grid.network.compare_lsgrid import ( compare_network_input, _compare_loads, @@ -142,11 +147,17 @@ def test_save_load(self): self.aux_test_2sides(grid, grid_1, True) self.aux_test_1side(grid, grid_1, True) - def _aux_test_binary(self, fun_name, fun_comp): - with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) - els = getattr(self.env.backend._grid, fun_name)() + def _aux_test_binary(self, fun_name, fun_comp, grid=None): + """`grid` defaults to a full l2rpn_idf_2023 grid2op env's LSGrid; pass one + explicitly (eg `build_exotic_elements_grid()`) to test an element type + l2rpn_idf_2023 does not carry any of (SVC, HVDC -- see + `_exotic_elements_fixture.py`).""" + if grid is None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + els = getattr(grid, fun_name)() with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, f"test_binary_{fun_name}.lsb") els.save_binary(path) @@ -155,7 +166,7 @@ def _aux_test_binary(self, fun_name, fun_comp): class Struct: pass setattr(Struct, fun_name, lambda self: els_reloaded) - diff_ = fun_comp(Struct(), self.env.backend._grid) + diff_ = fun_comp(Struct(), grid) assert len(diff_) == 0 def test_binary_loads(self): @@ -183,10 +194,13 @@ def test_binary_sgens(self): self._aux_test_binary("get_static_generators", _compare_static_generators) def test_binary_hvdc(self): - self._aux_test_binary("get_dclines", _compare_dclines) + # l2rpn_idf_2023 carries no HVDC line at all -- use the exotic-elements + # fixture instead, which has 3 (VSC droop, VSC no-droop, LCC). + self._aux_test_binary("get_dclines", _compare_dclines, grid=build_exotic_elements_grid()) def test_binary_svcs(self): - self._aux_test_binary("get_svcs", _compare_svcs) + # l2rpn_idf_2023 carries no SVC at all -- use the exotic-elements fixture. + self._aux_test_binary("get_svcs", _compare_svcs, grid=build_exotic_elements_grid()) def test_binary_algo_config(self): with warnings.catch_warnings(): diff --git a/lightsim2grid/tests/test_pickleable.py b/lightsim2grid/tests/test_pickleable.py index 4e8de0f7..0610f974 100644 --- a/lightsim2grid/tests/test_pickleable.py +++ b/lightsim2grid/tests/test_pickleable.py @@ -7,6 +7,7 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. from pathlib import Path +import sys import tempfile import pickle import os @@ -17,6 +18,10 @@ import grid2op from lightsim2grid.lightSimBackend import LightSimBackend + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _exotic_elements_fixture import build_exotic_elements_grid # noqa: E402 + from lightsim2grid.network.compare_lsgrid import ( compare_network_input, _compare_loads, @@ -189,12 +194,21 @@ def test_cannot_load_unfit_ls_version(self): with open(tmpdir / "test_pickle_2.pickle", "rb") as f: pickle.load(f) - def _aux_test_pickle(self, fun_name, fun_comp, check_old_version=True): + def _aux_test_pickle(self, fun_name, fun_comp, check_old_version=True, grid=None): + """`grid` defaults to a full l2rpn_idf_2023 grid2op env's LSGrid; pass one + explicitly (eg `build_exotic_elements_grid()`) to test an element type + l2rpn_idf_2023 does not carry any of (SVC, HVDC -- see + `_exotic_elements_fixture.py`). Only meaningful with `check_old_version=False`: + the `old_pickle/` legacy fixtures below were generated from an + l2rpn_idf_2023 grid and have nothing to do with `grid`. + """ # test I can reload if saved some the same ls version - with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) - els = getattr(self.env.backend._grid, fun_name)() + if grid is None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_idf_2023", test=True, backend=LightSimBackend()) + grid = self.env.backend._grid + els = getattr(grid, fun_name)() with tempfile.TemporaryDirectory() as tmpdir: with open(os.path.join(tmpdir, f"test_pickle_{fun_name}.pickle"), "wb") as f: pickle.dump(els, f) @@ -204,7 +218,7 @@ def _aux_test_pickle(self, fun_name, fun_comp, check_old_version=True): class Struct: pass setattr(Struct, fun_name, lambda self: els_reloaded) - diff_ = fun_comp(Struct(), self.env.backend._grid) + diff_ = fun_comp(Struct(), grid) assert len(diff_) == 0 if not check_old_version: @@ -248,10 +262,15 @@ def test_pickle_sgens(self): self._aux_test_pickle("get_static_generators", _compare_static_generators, check_old_version=False) def test_pickle_hvdc(self): - self._aux_test_pickle("get_dclines", _compare_dclines, check_old_version=False) + # l2rpn_idf_2023 carries no HVDC line at all -- use the exotic-elements + # fixture instead, which has 3 (VSC droop, VSC no-droop, LCC). + self._aux_test_pickle("get_dclines", _compare_dclines, check_old_version=False, + grid=build_exotic_elements_grid()) def test_pickle_svcs(self): - self._aux_test_pickle("get_svcs", _compare_svcs, check_old_version=False) + # l2rpn_idf_2023 carries no SVC at all -- use the exotic-elements fixture. + self._aux_test_pickle("get_svcs", _compare_svcs, check_old_version=False, + grid=build_exotic_elements_grid()) if __name__ == "__main__": From 84e2020aaa96cf45775ec53979cdac08dc5c299c Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 15 Jul 2026 18:58:19 +0200 Subject: [PATCH 128/166] Split _from_pypowsybl.py's 1083-line init() into per-element modules Per PR #140 self-review: the file was 1551 lines, almost entirely one init() function handling buses, generators, loads, lines, transformers, shunts, SVCs, HVDC lines, storage, and slack assignment in a single body. Split to mirror the from_pandapower / from_powermodels converters' existing layout (_aux_add_gen.py, _aux_add_line.py, ...): - _aux_add_buses.py: bus/substation numbering, dangling-line fictitious boundary buses, zero-impedance branch fusion (the phase every other converter depends on for its own bus resolution) - _aux_add_generators.py, _aux_add_loads.py, _aux_add_lines.py, _aux_add_trafos.py, _aux_add_shunts.py, _aux_add_svc.py, _aux_add_hvdc.py, _aux_add_storage.py: one file per element type - _aux_add_slack.py: slack bus assignment (explicit / OLF-matching default distributed slack / most-connected fallback) - _aux_common.py: low-level helpers shared by 2+ of the above (bus resolution, current limits, per-unit view, transformer ratio / phase-shift, remote voltage-control bus resolution) - _my_const.py: the one constant shared across files - initLSGrid.py: the new init() orchestrator, now ~85 lines of actual logic (the rest is the unchanged docstring) calling each phase in sequence and threading their outputs (voltage_levels/bus_df/ first_bus_per_vl from the bus phase; ol_current/net_pu shared by lines+trafos; the various df_*/*_sub pairs needed for the final substation-id bookkeeping and return_sub_id) Pure move: every extracted function's body, comments and docstrings are unchanged from the original file, only wrapped in a function signature and given explicit parameters/return values instead of sharing one function's local scope. Verified with the full pypowsybl-related test suite (21 files, 411 tests, all passing) plus a standalone smoke test (ieee14 -> init_from_pypowsybl -> ac_pf). Updated the 2 in-package imports that referenced the old module directly (__init__.py, _olf_compare.py) and the handful of docstring/ comment mentions of `_from_pypowsybl.py`/`_from_pypowsybl.init` across _result_network.py, _olf_bake.py and one test file. Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/__init__.py | 3 +- .../network/from_pypowsybl/_aux_add_buses.py | 373 ++++ .../from_pypowsybl/_aux_add_generators.py | 120 ++ .../network/from_pypowsybl/_aux_add_hvdc.py | 131 ++ .../network/from_pypowsybl/_aux_add_lines.py | 97 + .../network/from_pypowsybl/_aux_add_loads.py | 46 + .../network/from_pypowsybl/_aux_add_shunts.py | 32 + .../network/from_pypowsybl/_aux_add_slack.py | 247 +++ .../from_pypowsybl/_aux_add_storage.py | 40 + .../network/from_pypowsybl/_aux_add_svc.py | 136 ++ .../network/from_pypowsybl/_aux_add_trafos.py | 137 ++ .../network/from_pypowsybl/_aux_common.py | 210 +++ .../network/from_pypowsybl/_from_pypowsybl.py | 1570 ----------------- .../network/from_pypowsybl/_my_const.py | 11 + .../network/from_pypowsybl/_olf_bake.py | 2 +- .../network/from_pypowsybl/_olf_compare.py | 2 +- .../network/from_pypowsybl/_result_network.py | 18 +- .../network/from_pypowsybl/initLSGrid.py | 311 ++++ .../tests/test_bus_fusion_pypowsybl.py | 4 +- 19 files changed, 1906 insertions(+), 1584 deletions(-) create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_buses.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_generators.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_hvdc.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_lines.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_loads.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_shunts.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_slack.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_storage.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_svc.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_add_trafos.py create mode 100644 lightsim2grid/network/from_pypowsybl/_aux_common.py delete mode 100644 lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py create mode 100644 lightsim2grid/network/from_pypowsybl/_my_const.py create mode 100644 lightsim2grid/network/from_pypowsybl/initLSGrid.py diff --git a/lightsim2grid/network/from_pypowsybl/__init__.py b/lightsim2grid/network/from_pypowsybl/__init__.py index 8ea891d8..87ea28dc 100644 --- a/lightsim2grid/network/from_pypowsybl/__init__.py +++ b/lightsim2grid/network/from_pypowsybl/__init__.py @@ -16,7 +16,8 @@ "ComparisonResult", "LightsimResultNetwork"] -from ._from_pypowsybl import init, dangling_line_boundary_bus +from .initLSGrid import init +from ._aux_add_buses import dangling_line_boundary_bus from ._olf_bake import bake_outer_loops from ._olf_params import ( remove_outer_loops, diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py b/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py new file mode 100644 index 00000000..e4fa9ea6 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py @@ -0,0 +1,373 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Bus / substation numbering: the first phase of `init_from_pypowsybl`, every +other element converter (`_aux_add_generators.py`, `_aux_add_lines.py`, ...) +depends on its output (`voltage_levels`, `bus_df`, `first_bus_per_vl`). Also +covers dangling-line fictitious boundary buses and zero-impedance branch fusion, +both of which are bus-numbering concerns resolved before any other element type +is read (see `_aux_add_buses`'s docstring).""" + +import warnings + +import numpy as np +import pandas as pd + +from typing import Dict + +from ...lightsim2grid_cpp import LSGrid # type: ignore +from ._aux_common import ( + _aux_ensure_net_pu, + _aux_get_2wt_all_attrs, + _aux_trafo_alpha, + _aux_trafo_rho, +) +from ._my_const import _DANGLING_BOUNDARY_LINE_SUFFIX + + +def _aux_dangling_lines_fictitious(net, sort_index): + """One fictitious 1-bus "substation" per dangling line, at the far + (boundary) end of its series impedance. + + Real full grids never have any dangling line -- they only appear when + zooming into a sub-area with + ``network.reduce_by_ids_and_depths(..., with_boundary_lines=True)``, + which cuts the grid and represents everything beyond the cut as a + ``DanglingLine`` (series r/x/g/b + a constant-power p0/q0 "load" at the + boundary). Without this, the converter silently drops that + boundary injection (it never reads ``get_dangling_lines()``), which can + be tens to hundreds of MW and makes the reduced-grid comparison + meaningless. + + IIDM convention: the shunt admittance (g, b) of a dangling line sits + entirely on the connectable (network) side; the boundary side carries + none (same convention already used for transformers' single ``h``, see + ``trafo_h`` in `_aux_add_lines.py`). + + Returns ``(bus_extra, vl_extra, df_dl)``; ``bus_extra``/``vl_extra`` are + ``None`` and ``df_dl`` is empty if the grid has no dangling line. + ``df_dl`` carries two extra columns, ``boundary_bus_id`` / + ``boundary_vl_id``, used later to attach the equivalent branch + load. + + Deliberately built from ``net`` (physical units), not ``net_pu``: every + field actually read off ``df_dl`` downstream is unit-agnostic (ids, + ``connected``) or physical on purpose (``p0``/``q0`` in MW/MVAr, matching + every other load in this module). The per-unit-sensitive series + impedance (r/x/g/b) is read separately, from ``net_pu.get_dangling_lines()`` + (see the ``df_dl_pu`` fetch in `_aux_add_lines.py`) -- ``net``/``df_dl`` is + never used for those. + """ + df_dl = net.get_dangling_lines() + if sort_index: + df_dl = df_dl.sort_index() + if df_dl.shape[0] == 0: + return None, None, df_dl + df_dl = df_dl.copy() + df_dl["boundary_bus_id"] = [f"{dl_id}@boundary_bus" for dl_id in df_dl.index] + df_dl["boundary_vl_id"] = [f"{dl_id}@boundary_vl" for dl_id in df_dl.index] + nominal_v = net.get_voltage_levels().loc[df_dl["voltage_level_id"].values, "nominal_v"].values + bus_extra = pd.DataFrame({"voltage_level_id": df_dl["boundary_vl_id"].values, "name": ""}, + index=pd.Index(df_dl["boundary_bus_id"].values, name=df_dl.index.name or "id")) + vl_extra = pd.DataFrame({"nominal_v": nominal_v}, + index=pd.Index(df_dl["boundary_vl_id"].values, name="id")) + return bus_extra, vl_extra, df_dl + + +def dangling_line_boundary_bus(model: LSGrid) -> Dict[str, int]: + """Dangling-line id -> lightsim2grid global bus id of its fictitious + boundary bus, for a grid built with ``init(..., convert_dangling_lines=True)`` + (see :func:`_aux_dangling_lines_fictitious`). Empty if the grid has no + dangling line, or was built with ``convert_dangling_lines=False``. + + Unlike every other bus, a dangling line's boundary bus has no counterpart in + ``network.get_buses()`` so it is not reachable through ``model._ls_to_orig``. + Rather than stashing extra state on ``model`` (a pybind11 object without + ``dynamic_attr``, so arbitrary attributes cannot be set on it), this is + reconstructed from the synthetic boundary branch's own (real, C++-backed) + ``bus2_id`` -- once built, that branch is an ordinary lightsim2grid line. + """ + suffix = _DANGLING_BOUNDARY_LINE_SUFFIX + return {el.name[:-len(suffix)]: el.bus2_id + for el in model.get_lines() if el.name.endswith(suffix)} + + +def _aux_add_buses(model, net, net_pu, sort_index, buses_for_sub, n_busbar_per_sub, + convert_dangling_lines, fuse_zero_impedance_branches, zero_impedance_threshold_pu): + """Assign every pypowsybl bus a lightsim2grid (substation, local-bus) pair, + call ``model.init_bus``/``set_substation_names``/``set_bus_voltage_limits``, + and (optionally) fuse (near-)zero-impedance branches' terminal buses + together. Every other element converter depends on this phase's + ``voltage_levels``/``bus_df``/``first_bus_per_vl`` outputs to resolve its + own elements' buses (see ``_aux_get_bus`` in `_aux_common.py`). + + Returns ``(voltage_levels, bus_df, first_bus_per_vl, df_dl, net_pu, + fused_line_ids, fused_trafo_ids)``. + """ + # assign unique id to the buses + bus_df_orig = net.get_buses() + if sort_index: + bus_df = bus_df_orig.sort_index().copy() + else: + bus_df = bus_df_orig.copy() + bus_df["orig_id"] = np.arange(bus_df.shape[0]) + + if sort_index: + voltage_levels = net.get_voltage_levels().sort_index() + else: + voltage_levels = net.get_voltage_levels() + + # dangling lines (only present when the grid was cut with + # `reduce_by_ids_and_depths(..., with_boundary_lines=True)`, see + # `_aux_dangling_lines_fictitious`) each get their own fictitious 1-bus + # "substation" at their boundary end, appended *after* the real buses so + # `orig_id` / row-position based mappings (eg `_ls_to_orig`, used by + # `lightsim_bus_to_iidm`) keep pointing at real pypowsybl buses only. + if convert_dangling_lines: + bus_extra, vl_extra, df_dl = _aux_dangling_lines_fictitious(net, sort_index) + else: + raw_dl = net.get_dangling_lines() + bus_extra, vl_extra, df_dl = None, None, raw_dl.iloc[0:0] + if raw_dl.shape[0] > 0: + warnings.warn(f"{raw_dl.shape[0]} dangling line(s) found in the grid (eg from " + "`network.reduce_by_ids_and_depths(..., with_boundary_lines=True)`) " + "but `convert_dangling_lines=False` (the default): their boundary " + "injection is ignored. Pass `convert_dangling_lines=True` to model " + "them as an equivalent branch + constant-power load instead.") + if df_dl.shape[0] > 0: + if buses_for_sub is not None and buses_for_sub: + raise RuntimeError("Dangling lines (eg from `network.reduce_by_ids_and_depths(" + "..., with_boundary_lines=True)`) are not supported " + "together with buses_for_sub=True (legacy mode). Use " + "buses_for_sub=False (or None).") + bus_extra = bus_extra.copy() + bus_extra["orig_id"] = np.arange(bus_df.shape[0], bus_df.shape[0] + bus_extra.shape[0]) + bus_df = pd.concat([bus_df, bus_extra]) + voltage_levels = pd.concat([voltage_levels, vl_extra]) + + all_buses_vn_kv = voltage_levels.loc[bus_df["voltage_level_id"].values]["nominal_v"].values + nb_bus_per_vl = bus_df[["voltage_level_id", "name"]].groupby("voltage_level_id").count() + + if buses_for_sub is not None and buses_for_sub: + # I am in a compatibility mode, + # the "substation" in lightsim2grid will be read + # from the buses in the original grid (and not from the + # voltage levels) + if n_busbar_per_sub is None: + # setting automatically n_busbar_per_sub + # to 1 + # TODO logger here + n_busbar_per_sub = 1 + + all_buses_vn_kv = voltage_levels.loc[bus_df["voltage_level_id"], "nominal_v"].values + all_buses_vmin_kv = voltage_levels.loc[bus_df["voltage_level_id"], "low_voltage_limit"].values + all_buses_vmax_kv = voltage_levels.loc[bus_df["voltage_level_id"], "high_voltage_limit"].values + if n_busbar_per_sub > 1: + all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmin_kv = np.concatenate([all_buses_vmin_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmax_kv = np.concatenate([all_buses_vmax_kv for _ in range(n_busbar_per_sub)]) + n_sub_ls = bus_df.shape[0] + ls_to_orig = np.zeros(all_buses_vn_kv.shape[0], dtype=int) - 1 + ls_to_orig[:n_sub_ls] = np.arange(n_sub_ls) + n_busbar_per_sub_ls = n_busbar_per_sub + bus_df["bus_global_id"] = np.arange(n_sub_ls) + bus_df["glop_sub_id"] = np.arange(n_sub_ls) # np.concatenate([np.arange(n_sub_ls) for _ in range(n_busbar_per_sub)]) + sub_names = bus_df.index.values.astype(str) + voltage_levels["vl_id"] = bus_df[["voltage_level_id", "bus_global_id"]].groupby("voltage_level_id").min() + first_bus_per_vl = None + else: + # the "substation" in lightsim2grid + voltage_levels["nb_bus_per_vl"] = nb_bus_per_vl["name"] + bus_df["name"] = [[el] for el in bus_df.index] + voltage_levels["bus_names"] = bus_df[["name", "voltage_level_id"]].groupby("voltage_level_id").sum() + + bus_df["local_id"] = [voltage_levels.loc[el, "bus_names"].index(id_) + 1 + for id_, el in zip(bus_df.index, + bus_df["voltage_level_id"].values)] + n_vl = voltage_levels.shape[0] + voltage_levels["vl_id"] = np.arange(n_vl) + bus_df["bus_global_id"] = [(loc_id - 1) * n_vl + voltage_levels.loc[vl, "vl_id"] + for loc_id, vl in zip( + bus_df["local_id"], + bus_df["voltage_level_id"] + )] + nb_bus_per_vl_in_grid = nb_bus_per_vl.values.max() + if n_busbar_per_sub is None: + # setting automatically n_busbar_per_sub + # to the value read from the grid + # TODO logger here + n_busbar_per_sub = int(nb_bus_per_vl_in_grid) + elif n_busbar_per_sub < nb_bus_per_vl_in_grid: + raise RuntimeError(f"The input pypowsybl grid counts some voltage levels " + f"with {nb_bus_per_vl_in_grid} independant buses, " + f"which is not compatible with the n_busbar_per_sub={n_busbar_per_sub} " + "given as input.") + all_buses_vn_kv = voltage_levels["nominal_v"].values + all_buses_vmin_kv = voltage_levels["low_voltage_limit"].values + all_buses_vmax_kv = voltage_levels["high_voltage_limit"].values + if n_busbar_per_sub > 1: + all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmin_kv = np.concatenate([all_buses_vmin_kv for _ in range(n_busbar_per_sub)]) + all_buses_vmax_kv = np.concatenate([all_buses_vmax_kv for _ in range(n_busbar_per_sub)]) + n_sub_ls = voltage_levels.shape[0] + voltage_levels["glop_sub_id"] = np.arange(voltage_levels.shape[0]) + n_busbar_per_sub_ls = n_busbar_per_sub + ls_to_orig = np.zeros(all_buses_vn_kv.shape[0], dtype=int) - 1 + ls_to_orig[bus_df["bus_global_id"].values] = np.arange(bus_df.shape[0]) + sub_names = voltage_levels.index.values.astype(str) + bus_df["glop_sub_id"] = voltage_levels.loc[bus_df["voltage_level_id"].values, "glop_sub_id"].values + + # retrieve the "first bus of every substation" + # this is used to connected reconnected element + first_bus_per_vl = bus_df[["glop_sub_id", "voltage_level_id", "name"]].groupby("glop_sub_id").first().reset_index().set_index("voltage_level_id") + first_bus_per_vl["first_bus_name"] = [el[0] for el in first_bus_per_vl["name"].values] + first_bus_per_vl.drop(["glop_sub_id", "name"], axis=1, inplace=True) + + # make sure every voltage level as a "first bus" + # I would raise an error in the case a voltage is fully disconnected but... + check_grid_ok = voltage_levels.index.isin(first_bus_per_vl.index) + if np.any(~check_grid_ok): + warnings.warn("There are some voltage levels without any connected buses. " + f"Check voltage levels {voltage_levels[~check_grid_ok].index}") + # name_added_bus = voltage_levels[~check_grid_ok].index + nm_vl_without_bus = voltage_levels.loc[~check_grid_ok, ["vl_id", "glop_sub_id"]] + to_add = voltage_levels.loc[~check_grid_ok, ["vl_id", "glop_sub_id"]].copy() + to_add.index = to_add.index.astype(str) + "added_bus" + to_add["name"] = [[el] for el in to_add.index.astype(str)] + to_add["v_mag"] = np.nan + to_add["v_angle"] = np.nan + to_add["connected_component"] = 99999 + to_add["synchronous_component"] = 99999 + to_add["voltage_level_id"] = nm_vl_without_bus.index + to_add["orig_id"] = 9999 + to_add["local_id"] = 1 + to_add["bus_global_id"] = to_add["glop_sub_id"] + for added_bus_nm in to_add.index: + bus_df.loc[added_bus_nm] = to_add.loc[added_bus_nm] + for vl_bus_added in nm_vl_without_bus.index: + first_bus_per_vl.loc[vl_bus_added, "first_bus_name"] = vl_bus_added + "added_bus" + + # all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) + model.init_bus(n_sub_ls, + n_busbar_per_sub_ls, + all_buses_vn_kv, + 0, 0 # unused + ) + model._ls_to_orig = ls_to_orig + model._max_nb_bus_per_sub = n_busbar_per_sub_ls + # relevant kwargs downstream consumers (eg a pypowsybl-shaped result view) need to + # recover conversion-time settings that are otherwise plain Python arguments lost + # after this function returns -- see LSGrid._init_kwargs. + model._init_kwargs = {"sort_index": str(sort_index), "buses_for_sub": str(buses_for_sub)} + model.set_substation_names(sub_names) + model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) + + # fuse the two terminal buses of (near-)zero-impedance lines / neutral-tap + # transformers, mirroring OpenLoadFlow's `lowImpedanceBranchMode`. Must run + # before any `_aux_get_bus` call (generators, just below) so every element + # type transparently picks up the fused `bus_global_id` for free -- see + # `_aux_get_bus`'s single choke point at `bus_df["bus_global_id"]`. + fused_line_ids = set() + fused_trafo_ids = set() + if fuse_zero_impedance_branches: + net_pu = _aux_ensure_net_pu(net, net_pu) + parent = np.arange(all_buses_vn_kv.shape[0]) + + def _uf_find(x): + while parent[x] != x: + x = parent[x] + return x + + def _uf_union(a, b): + ra, rb = _uf_find(a), _uf_find(b) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) # deterministic: smaller id wins + + # -- zero-impedance LINES -- + df_line_fuse = net.get_lines() + if sort_index: + df_line_fuse = df_line_fuse.sort_index() + df_line_fuse_pu = net_pu.get_lines().loc[df_line_fuse.index] + both_conn = (df_line_fuse["connected1"].values & df_line_fuse["connected2"].values) + is_zero = ((np.abs(df_line_fuse_pu["r"].values) < zero_impedance_threshold_pu) + & (np.abs(df_line_fuse_pu["x"].values) < zero_impedance_threshold_pu)) + line_candidate = both_conn & is_zero + if line_candidate.any(): + cand = df_line_fuse[line_candidate] + vn1 = voltage_levels.loc[cand["voltage_level1_id"].values, "nominal_v"].values + vn2 = voltage_levels.loc[cand["voltage_level2_id"].values, "nominal_v"].values + bad = ~np.isclose(vn1, vn2) + if bad.any(): + raise RuntimeError( + f"Zero-impedance line(s) {list(cand.index[bad])} connect two buses at " + "different nominal voltages: this is inconsistent grid data, refusing " + "to fuse them (fuse_zero_impedance_branches=True)." + ) + gid1 = bus_df.loc[cand["bus1_id"].values, "bus_global_id"].values + gid2 = bus_df.loc[cand["bus2_id"].values, "bus_global_id"].values + for a, b in zip(gid1, gid2): + _uf_union(int(a), int(b)) + fused_line_ids.update(cand.index) + + # -- zero-impedance, neutral-tap, SAME-NOMINAL-VOLTAGE TRANSFORMERS -- + # NB: pypowsybl's per-unit `rho` is the deviation from the transformer's OWN + # rated_u1/rated_u2 ratio (tap-changer effect), not the absolute turns ratio: + # a genuine step-down transformer (eg rated_u1=225kV/rated_u2=90kV) reports + # rho=1 at neutral tap even though it performs a real voltage transformation + # (implicit in the differing per-unit bus voltage bases on each side, not in + # `rho` itself). So `rho~=1` alone only means "no ADDITIONAL tap deviation" -- + # it does NOT mean "no transformation at all". A transformer is only a true, + # fusable ideal wire when it is ALSO between two buses of the same nominal + # voltage (same requirement as for lines, just not an error here since most + # real transformers legitimately span different nominal voltages). + df_trafo_fuse = _aux_get_2wt_all_attrs(net) + if sort_index: + df_trafo_fuse = df_trafo_fuse.sort_index() + if df_trafo_fuse.shape[0] > 0: + df_trafo_fuse_pu = _aux_get_2wt_all_attrs(net_pu).loc[df_trafo_fuse.index] + ratio_tap_changer_fuse = net_pu.get_ratio_tap_changers() + alpha_fuse = _aux_trafo_alpha(df_trafo_fuse_pu, net) + rho_fuse = _aux_trafo_rho(df_trafo_fuse_pu, ratio_tap_changer_fuse) + both_conn_t = (df_trafo_fuse["connected1"].values & df_trafo_fuse["connected2"].values) + is_zero_t = ((np.abs(df_trafo_fuse_pu["r"].values) < zero_impedance_threshold_pu) + & (np.abs(df_trafo_fuse_pu["x"].values) < zero_impedance_threshold_pu)) + is_ideal_t = (np.abs(rho_fuse - 1.) < 1e-9) & (np.abs(alpha_fuse) < 1e-9) + vn1_t = voltage_levels.loc[df_trafo_fuse["voltage_level1_id"].values, "nominal_v"].values + vn2_t = voltage_levels.loc[df_trafo_fuse["voltage_level2_id"].values, "nominal_v"].values + same_vn_t = np.isclose(vn1_t, vn2_t) + trafo_candidate = both_conn_t & is_zero_t & is_ideal_t & same_vn_t + if trafo_candidate.any(): + cand_t = df_trafo_fuse[trafo_candidate] + gid1 = bus_df.loc[cand_t["bus1_id"].values, "bus_global_id"].values + gid2 = bus_df.loc[cand_t["bus2_id"].values, "bus_global_id"].values + for a, b in zip(gid1, gid2): + _uf_union(int(a), int(b)) + fused_trafo_ids.update(cand_t.index) + + rep = np.array([_uf_find(i) for i in range(parent.shape[0])]) + bus_df["bus_global_id"] = rep[bus_df["bus_global_id"].values] + # a fused-away ("loser") bus keeps its own row in the lightsim2grid model + # (fixed-size bus containers) but loses every element to its representative, + # so it ends up disconnected with no solved voltage of its own. Stash the + # representative lookup so a downstream result view (eg + # `LightsimResultNetwork`) can report that bus's voltage as its + # representative's -- the two are electrically the same node -- instead of + # the disconnected bus's stale/zero value. See `LSGrid._bus_fusion_rep`. + model._bus_fusion_rep = rep.astype(int) + if fused_line_ids or fused_trafo_ids: + # ids of the fused-away branches themselves, so a downstream result view + # (eg `LightsimResultNetwork`) can tell "deactivated because fused" apart + # from "deactivated because genuinely out of service" and, where the + # physics allow it, reconstruct the flow through it (see + # `LightsimResultNetwork._reconstruct_fused_branches`). "\x1f" (ASCII unit + # separator) is used as delimiter: pypowsybl element ids never contain it. + kwargs = dict(model._init_kwargs) + kwargs["fused_line_ids"] = "\x1f".join(sorted(str(i) for i in fused_line_ids)) + kwargs["fused_trafo_ids"] = "\x1f".join(sorted(str(i) for i in fused_trafo_ids)) + model._init_kwargs = kwargs + + return voltage_levels, bus_df, first_bus_per_vl, df_dl, net_pu, fused_line_ids, fused_trafo_ids diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_generators.py b/lightsim2grid/network/from_pypowsybl/_aux_add_generators.py new file mode 100644 index 00000000..d02f888e --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_generators.py @@ -0,0 +1,120 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import copy + +import numpy as np + +from ._aux_common import _aux_get_bus, _aux_regulated_bus_view_ids + + +def _aux_add_generators(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl): + """Add every generator of ``net`` to ``model``. Returns ``(df_gen, gen_sub)``: + ``df_gen`` is reused by the slack-assignment phase (`_aux_add_slack.py`), + ``gen_sub`` by the final substation-id bookkeeping in `initLSGrid.py`.""" + gen_attrs = [ + "connected", "max_p", "target_p", "target_v", "target_q", "p", + "voltage_regulator_on", "regulated_element_id", "voltage_level_id", "bus_id", + "min_q", "max_q", "min_q_at_target_p", "max_q_at_target_p", + ] + if sort_index: + df_gen = net.get_generators(attributes=gen_attrs).sort_index() + else: + df_gen = net.get_generators(attributes=gen_attrs) + + # to handle encoding in 32 bits and overflow when "splitting" the Q values among + min_float_value = np.finfo(np.float32).min * 1e-4 + 1. + max_float_value = np.finfo(np.float32).max * 1e-4 + 1. + # "min_q"/"max_q" (the flat MIN_MAX box) are NaN for a generator whose + # reactive_limits_kind is CURVE -- pypowsybl only populates those through + # "min_q_at_target_p"/"max_q_at_target_p" (the capability curve evaluated at the + # generator's own target P, available even before any loadflow has been run, + # unlike "min_q_at_p" which depends on a solved "p"). Without this, every + # CURVE-kind generator silently got the "no limit" float32 sentinel below + # regardless of its real reactive range. + min_q_src = df_gen["min_q_at_target_p"].where(df_gen["min_q_at_target_p"].notna(), df_gen["min_q"]) + max_q_src = df_gen["max_q_at_target_p"].where(df_gen["max_q_at_target_p"].notna(), df_gen["max_q"]) + min_q_aux = 1. * min_q_src.values + max_q_aux = 1. * max_q_src.values + # malformed source curve data (eg a reactive capability curve point entered with + # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. + # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init + # hard-rejects it (real case found on a real grid snapshot). Restore a valid + # interval by sorting the pair instead of crashing -- this only ever affects the + # (already tiny) width of the interval, never which generators get a reactive + # constraint at all. + swapped = min_q_aux > max_q_aux + if swapped.any(): + min_q_aux[swapped], max_q_aux[swapped] = max_q_aux[swapped], min_q_aux[swapped].copy() + too_small = min_q_aux < min_float_value + min_q_aux[too_small] = min_float_value + min_q = min_q_aux.astype(np.float32) + + too_big = np.abs(max_q_aux) > max_float_value + max_q_aux[too_big] = np.sign(max_q_aux[too_big]) * max_float_value + max_q = max_q_aux.astype(np.float32) + min_q[~np.isfinite(min_q)] = min_float_value + max_q[~np.isfinite(max_q)] = max_float_value + gen_bus, gen_disco, gen_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "gen", df_gen) + + # remote voltage control: a generator may regulate the voltage of a *different* + # bus, identified by `regulated_element_id` (its own id when controlling locally). + # Resolve that element to the bus-view id of its terminal, then to the regulated + # voltage level (used for the target_v -> pu conversion) and, further down, to the + # lightsim2grid global bus id threaded into the C++ container. + bus_reg = copy.deepcopy(df_gen["regulated_element_id"].values) + # for oldest pypowsybl version, we could have "" there + bus_reg = np.where(bus_reg == "", df_gen.index, bus_reg) + vl_reg = copy.deepcopy(df_gen["voltage_level_id"].values) + # a disconnected generator is deactivated below regardless of its (possibly + # itself disconnected / unresolvable) regulated element, so exclude it here: + # otherwise a disconnected generator remotely "regulating" a disconnected + # busbar section (empty bus-view id) crashes the (moot, since deactivated) + # bus resolution below. + mask_remote_gen = (bus_reg != df_gen.index.values) & ~gen_disco + gen_reg_bus_view = None + if mask_remote_gen.any(): + remote_idx = np.nonzero(mask_remote_gen)[0] + gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) + # a *connected* generator can still remotely regulate a busbar section that is + # itself disconnected (found on a real grid snapshot: a de-energized + # voltage level). pypowsybl's bus-view id for a disconnected element is '', + # not NaN, and can't be resolved to any bus_df row. OLF converges fine on such + # grids, so it must fall back to local voltage control in this situation; + # mirror that instead of crashing. + unresolved = gen_reg_bus_view == "" + if unresolved.any(): + mask_remote_gen[remote_idx[unresolved]] = False + gen_reg_bus_view = gen_reg_bus_view[~unresolved] + vl_reg[mask_remote_gen] = bus_df.loc[gen_reg_bus_view, "voltage_level_id"].values + model.init_generators_full(df_gen["target_p"].values, + # df_gen["target_v"].values / voltage_levels.loc[df_gen["voltage_level_id"].values]["nominal_v"].values, + df_gen["target_v"].values / voltage_levels.loc[vl_reg]["nominal_v"].values, + df_gen["target_q"].values, + df_gen["voltage_regulator_on"].values, + min_q, + max_q, + gen_bus + ) + for gen_id, is_disco in enumerate(gen_disco): + if is_disco: + model.deactivate_gen(gen_id) + model.set_gen_names(df_gen.index) + + # thread the regulated bus to the C++ generator container. Local generators keep + # their own bus (already the C++ default), so a grid without any remote control + # stays byte-identical to before this feature. + # TODO: the regulated bus is resolved once, here, from the pypowsybl grid. If the + # regulated element later changes bus inside lightsim2grid, this stays frozen and + # the two grids desynchronise (see `_aux_regulated_bus_view_ids` warning). + if mask_remote_gen.any(): + gen_reg_bus_global = bus_df.loc[gen_reg_bus_view, "bus_global_id"].values + for gen_id, reg_bus in zip(np.nonzero(mask_remote_gen)[0], gen_reg_bus_global): + model.set_gen_regulated_bus(int(gen_id), int(reg_bus)) + + return df_gen, gen_sub diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_hvdc.py b/lightsim2grid/network/from_pypowsybl/_aux_add_hvdc.py new file mode 100644 index 00000000..ba707b5e --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_hvdc.py @@ -0,0 +1,131 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import warnings + +import numpy as np +import pandas as pd + +from ._aux_common import _aux_get_bus + + +def _aux_add_hvdc(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl): + """Add every HVDC line of ``net`` (VSC / LCC converter stations, possibly + carrying the angle-droop ("AC emulation") extension) to ``model``. Returns + ``(df_dc, hvdc_sub_from_id, hvdc_sub_to_id)``, used by the final + substation-id bookkeeping and ``return_sub_id`` in `initLSGrid.py`.""" + if sort_index: + df_dc = net.get_hvdc_lines().sort_index() + else: + df_dc = net.get_hvdc_lines() + df_vsc = net.get_vsc_converter_stations() + df_lcc = net.get_lcc_converter_stations() + # the vsc / lcc frames have different columns (target_v / power_factor...): + # the concatenation puts NaN where an attribute does not exist for a type + df_stations = pd.concat([df_vsc, df_lcc]) + nb_dc = df_dc.shape[0] + _max_hvdc_mva = 1.0e7 # when pypowsybl exposes NaN limits + + df_station1 = df_stations.loc[df_dc["converter_station1_id"].values] + df_station2 = df_stations.loc[df_dc["converter_station2_id"].values] + hvdc_bus_from_id, hvdc_from_disco, hvdc_sub_from_id = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "hvdc (side 1)", df_station1) + hvdc_bus_to_id, hvdc_to_disco, hvdc_sub_to_id = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "hvdc (side 2)", df_station2) + + def _aux_hvdc_station_data(df_side): + # type: 0 = VSC, 1 = LCC (ConverterStationContainer convention) + is_lcc = df_side.index.isin(df_lcc.index) + types = np.where(is_lcc, 1, 0).astype(int) + loss_factor = df_side["loss_factor"].values / 100. # pypowsybl % -> fraction + loss_factor = np.where(np.isfinite(loss_factor), loss_factor, 0.) + vreg_on = df_side["voltage_regulator_on"].values.astype(bool) if nb_dc else np.zeros(0, dtype=bool) + vreg_on = vreg_on & ~is_lcc # lcc never regulates (NaN -> random bool otherwise) + vl_kv = voltage_levels.loc[df_side["voltage_level_id"].values]["nominal_v"].values + vset_pu = df_side["target_v"].values / vl_kv + vset_pu = np.where(np.isfinite(vset_pu), vset_pu, 1.0) + qset = df_side["target_q"].values + qset = np.where(np.isfinite(qset), qset, 0.) + min_q = df_side["min_q"].values + min_q = np.where(np.isfinite(min_q), min_q, -_max_hvdc_mva) + max_q = df_side["max_q"].values + max_q = np.where(np.isfinite(max_q), max_q, _max_hvdc_mva) + power_factor = df_side["power_factor"].values + power_factor = np.where(np.isfinite(power_factor), power_factor, 1.0) + return types, loss_factor, vreg_on, vset_pu, qset, min_q, max_q, power_factor + + type1, lf1, vreg1, vset1, qset1, min_q1, max_q1, pf1 = _aux_hvdc_station_data(df_station1) + type2, lf2, vreg2, vset2, qset2, min_q2, max_q2, pf2 = _aux_hvdc_station_data(df_station2) + + # 0 = side 1 rectifier, 1 = side 2 rectifier (HvdcLineContainer convention) + converters_mode = np.where(df_dc["converters_mode"].values.astype(str) == "SIDE_1_RECTIFIER_SIDE_2_INVERTER", 0, 1).astype(int) + p_setpoint_mw = df_dc["target_p"].values.astype(float).copy() + if (~np.isfinite(p_setpoint_mw)).any(): + warnings.warn("Some non finite values are found for hvdc target_p, they have been replaced by 0.") + p_setpoint_mw[~np.isfinite(p_setpoint_mw)] = 0. + r_ohm = df_dc["r"].values.astype(float) + nominal_v_kv = df_dc["nominal_v"].values.astype(float) + max_p_mw = df_dc["max_p"].values.astype(float) + max_p_mw = np.where(np.isfinite(max_p_mw), max_p_mw, _max_hvdc_mva) + + # the angle-droop active power control ("AC emulation"), an IIDM extension + droop_enabled = np.zeros(nb_dc, dtype=bool) + droop_p0_mw = np.zeros(nb_dc) + droop_mw_per_deg = np.zeros(nb_dc) + try: + df_droop = net.get_extensions("hvdcAngleDroopActivePowerControl") + except Exception: + # extension tables may be unavailable on (very) old pypowsybl versions + df_droop = None + if df_droop is not None and df_droop.shape[0]: + for hvdc_pos, line_id in enumerate(df_dc.index): + if line_id not in df_droop.index: + continue + if not bool(df_droop.loc[line_id, "enabled"]): + continue + droop_enabled[hvdc_pos] = True + droop_p0_mw[hvdc_pos] = float(df_droop.loc[line_id, "p0"]) + droop_mw_per_deg[hvdc_pos] = float(df_droop.loc[line_id, "droop"]) + + model.init_hvdc_lines(hvdc_bus_from_id.astype(np.int32), + hvdc_bus_to_id.astype(np.int32), + [int(el) for el in type1], + [int(el) for el in type2], + lf1, lf2, + [bool(el) for el in vreg1], + [bool(el) for el in vreg2], + vset1, vset2, + qset1, qset2, + min_q1, max_q1, min_q2, max_q2, + pf1, pf2, + [int(el) for el in converters_mode], + p_setpoint_mw, + r_ohm, + nominal_v_kv, + [bool(el) for el in droop_enabled], + droop_p0_mw, + droop_mw_per_deg, + max_p_mw, # pmax 1 -> 2: IIDM has a single max_p (open-loadflow convention) + max_p_mw, # pmax 2 -> 1 + ) + for hvdc_id, (is_or_disc, is_ex_disc, line_conn1, line_conn2) in enumerate( + zip(hvdc_from_disco, hvdc_to_disco, df_dc["connected1"].values, df_dc["connected2"].values)): + # a converter station with its own terminal open (eg its DC partner is + # switched off, or its whole substation is dead) is NOT a dead branch: real + # VSC stations (and OpenLoadFlow) keep the still-connected converter + # injecting its scheduled P / regulating Q-V as a local device. Only fully + # deactivate when BOTH stations are disconnected. + or_disc = is_or_disc or (not line_conn1) + ex_disc = is_ex_disc or (not line_conn2) + if or_disc and ex_disc: + model.deactivate_dcline(hvdc_id) + elif or_disc: + model.deactivate_dcline_side1(hvdc_id) + elif ex_disc: + model.deactivate_dcline_side2(hvdc_id) + model.set_dcline_names(df_dc.index) + + return df_dc, hvdc_sub_from_id, hvdc_sub_to_id diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_lines.py b/lightsim2grid/network/from_pypowsybl/_aux_add_lines.py new file mode 100644 index 00000000..49ded9d3 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_lines.py @@ -0,0 +1,97 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np +import pandas as pd + +from ._aux_common import _aux_get_bus, _aux_current_limits +from ._my_const import _DANGLING_BOUNDARY_LINE_SUFFIX + + +def _aux_add_lines(model, net, net_pu, sort_index, voltage_levels, bus_df, first_bus_per_vl, + df_dl, ol_current, keep_half_open_lines, fuse_zero_impedance_branches, fused_line_ids): + """Add every line of ``net`` to ``model``, plus the equivalent branch (local + bus -> fictitious boundary bus) for each dangling line (``df_dl``, from + `_aux_add_buses.py`). ``ol_current`` (``net.get_operational_limits()`` + filtered to CURRENT, or ``None``) is shared with `_aux_add_trafos.py`, computed + once in `initLSGrid.py`. Returns ``(df_line, lor_sub, lex_sub)``, used by the + final substation-id bookkeeping and ``return_sub_id`` in `initLSGrid.py`.""" + if sort_index: + df_line = net.get_lines().sort_index() + else: + df_line = net.get_lines() + try: + line_limit_groups = net.get_lines(all_attributes=True)[["selected_limits_group_1", "selected_limits_group_2"]] + except (TypeError, KeyError): + # not available on legacy pypowsybl / grid with no limit group at all + line_limit_groups = pd.DataFrame( + {"selected_limits_group_1": np.nan, "selected_limits_group_2": np.nan}, index=df_line.index + ) + + df_line_pu = net_pu.get_lines().loc[df_line.index] + if df_dl.shape[0] > 0: + # equivalent branch (local bus -> fictitious boundary bus) for each + # dangling line, see `_aux_dangling_lines_fictitious`. Shunt admittance + # (g, b) sits entirely on the local (network) side, none on the + # boundary side -- same convention already used for the single `h` of + # a transformer (see `trafo_h` in `_aux_add_trafos.py`). + df_dl_pu = net_pu.get_dangling_lines().loc[df_dl.index] + line_ids_extra = [f"{dl_id}{_DANGLING_BOUNDARY_LINE_SUFFIX}" for dl_id in df_dl.index] + df_line_extra = pd.DataFrame({ + "bus1_id": df_dl["bus_id"].values, + "bus2_id": df_dl["boundary_bus_id"].values, + "connected1": df_dl["connected"].values, + "connected2": True, + "voltage_level1_id": df_dl["voltage_level_id"].values, + "voltage_level2_id": df_dl["boundary_vl_id"].values, + }, index=line_ids_extra) + df_line_extra_pu = pd.DataFrame({ + "r": df_dl_pu["r"].values, + "x": df_dl_pu["x"].values, + "g1": df_dl_pu["g"].values, + "b1": df_dl_pu["b"].values, + "g2": 0., + "b2": 0., + }, index=line_ids_extra) + df_line = pd.concat([df_line, df_line_extra]) + df_line_pu = pd.concat([df_line_pu, df_line_extra_pu]) + line_r = df_line_pu["r"].values + line_x = df_line_pu["x"].values + line_h_or = (df_line_pu["g1"].values + 1j * df_line_pu["b1"].values) + line_h_ex = (df_line_pu["g2"].values + 1j * df_line_pu["b2"].values) + lor_bus, lor_disco, lor_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "line (side 1)", df_line, conn_key="connected1", bus_key="bus1_id", vl_key="voltage_level1_id") + lex_bus, lex_disco, lex_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "line (side 2)", df_line, conn_key="connected2", bus_key="bus2_id", vl_key="voltage_level2_id") + model.init_powerlines_full(line_r, + line_x, + line_h_or, + line_h_ex, + lor_bus, + lex_bus + ) + for line_id, (is_or_disc, is_ex_disc) in enumerate(zip(lor_disco, lex_disco)): + if is_or_disc and is_ex_disc: + model.deactivate_powerline(line_id) + elif is_or_disc: + model.deactivate_powerline_side1(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) + elif is_ex_disc: + model.deactivate_powerline_side2(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) + elif fuse_zero_impedance_branches and df_line.index[line_id] in fused_line_ids: + # both terminal buses already fused into one node above: this line + # would otherwise contribute a 1/Z admittance (Inf for an exact zero) + model.deactivate_powerline(line_id) + model.set_line_names(df_line.index) + line_limit_a1_ka, line_limit_a2_ka = _aux_current_limits( + df_line.index, + line_limit_groups["selected_limits_group_1"], + line_limit_groups["selected_limits_group_2"], + ol_current, + ) + model.set_line_current_limit_side1(line_limit_a1_ka) + model.set_line_current_limit_side2(line_limit_a2_ka) + + return df_line, lor_sub, lex_sub diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_loads.py b/lightsim2grid/network/from_pypowsybl/_aux_add_loads.py new file mode 100644 index 00000000..4561e5e6 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_loads.py @@ -0,0 +1,46 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import pandas as pd + +from ._aux_common import _aux_get_bus + + +def _aux_add_loads(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl, df_dl): + """Add every load of ``net`` to ``model``, plus the equivalent constant-power + load at each dangling line's boundary bus (``df_dl``, from + `_aux_add_buses.py`, empty unless ``convert_dangling_lines=True``). Returns + ``(df_load, load_sub)``, used by the final substation-id bookkeeping and + ``return_sub_id`` in `initLSGrid.py`.""" + if sort_index: + df_load = net.get_loads().sort_index() + else: + df_load = net.get_loads() + if df_dl.shape[0] > 0: + # equivalent constant-power load at each dangling line's boundary bus + # (see `_aux_dangling_lines_fictitious`); p0/q0 are already in MW/MVAr, + # same raw convention as `net.get_loads()`. + df_load_extra = pd.DataFrame({ + "p0": df_dl["p0"].values, + "q0": df_dl["q0"].values, + "bus_id": df_dl["boundary_bus_id"].values, + "connected": True, + "voltage_level_id": df_dl["boundary_vl_id"].values, + }, index=[f"{dl_id}@boundary_load" for dl_id in df_dl.index]) + df_load = pd.concat([df_load, df_load_extra]) + load_bus, load_disco, load_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "load", df_load) + model.init_loads(df_load["p0"].values, + df_load["q0"].values, + load_bus + ) + for load_id, is_disco in enumerate(load_disco): + if is_disco: + model.deactivate_load(load_id) + model.set_load_names(df_load.index) + + return df_load, load_sub diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_shunts.py b/lightsim2grid/network/from_pypowsybl/_aux_add_shunts.py new file mode 100644 index 00000000..9c5043f2 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_shunts.py @@ -0,0 +1,32 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +from ._aux_common import _aux_get_bus + + +def _aux_add_shunts(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl): + """Add every shunt compensator of ``net`` to ``model``. Returns + ``(df_shunt, sh_sub)``, used by the final substation-id bookkeeping and + ``return_sub_id`` in `initLSGrid.py`.""" + if sort_index: + df_shunt = net.get_shunt_compensators().sort_index() + else: + df_shunt = net.get_shunt_compensators() + + sh_bus, sh_disco, sh_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "shunts", df_shunt) + shunt_kv = voltage_levels.loc[df_shunt["voltage_level_id"].values]["nominal_v"].values + model.init_shunt(df_shunt["g"].values * shunt_kv**2, + -df_shunt["b"].values * shunt_kv**2, + sh_bus + ) + for shunt_id, disco in enumerate(sh_disco): + if disco: + model.deactivate_shunt(shunt_id) + model.set_shunt_names(df_shunt.index) + + return df_shunt, sh_sub diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_slack.py b/lightsim2grid/network/from_pypowsybl/_aux_add_slack.py new file mode 100644 index 00000000..24949fd2 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_slack.py @@ -0,0 +1,247 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +from collections import deque + +import numpy as np + +from ._aux_handle_slack import handle_slack_iterable, handle_slack_one_el + +# OpenLoadFlow's hardcoded fallback droop (in `AbstractLfGenerator.DEFAULT_DROOP`, +# "why not") used for every generator whose `activePowerControl` extension does not +# set its own droop, under the ``PROPORTIONAL_TO_GENERATION_P_MAX`` balance type -- +# see `_default_distributed_slack`. +_OLF_DEFAULT_DROOP = 4.0 + + +def _default_distributed_slack(net, df_gen): + """Build the default distributed slack matching OpenLoadFlow's behaviour. + + Returns an *ordered* ``{generator_name: weight}`` dict (the reference generator + first, so it becomes ``slack_ids[0]`` -- the angle datum) suitable for + ``gen_slack_id``, or ``None`` if no participating generator is found (the caller + then falls back to :meth:`LSGrid.assign_slack_to_most_connected`). + + The participating set and the sharing key mirror OLF's default distributed + slack: + + * participants are the connected generators with ``max_p > 0`` that take part in + active-power control (``participate`` flag of the ``activePowerControl`` + extension); if that extension is absent, every connected producing generator + participates; + * participants are restricted to the *main synchronous component* (OLF's + ``ActivePowerDistribution.run`` only ever considers + ``LfSynchronousNetwork.getBuses()``) -- **not** to any particular country: + ``ActivePowerDistribution.filterParticipatingBuses`` has no country logic at + all, and OLF's own ``slackBusCountryFilter`` is empty by default, so foreign + border-equivalent generators (*eg* a cross-border interconnection's + equivalent generator, which can carry a very large ``max_p``/weight) do + participate. An earlier version of this function restricted participants to + the country hosting the most buses; that was a guess, not something OLF + actually does, and it was found to materially skew which generators absorb + the slack mismatch on real multi-country grids -- removed; + * the per-generator weight matches OLF's default ``PROPORTIONAL_TO_GENERATION_P_MAX`` + balance type exactly (``GenerationActivePowerDistributionStep.getParticipationFactor``, + ``MAX`` case): ``max_p / droop``, where ``droop`` is the ``activePowerControl`` + extension's own droop when it sets one (> 0), otherwise OLF's hardcoded + ``DEFAULT_DROOP = 4`` for *every* generator regardless of the extension. + **Not** the extension's ``participation_factor`` -- that key is only used + under the (different, not reproduced here) ``PROPORTIONAL_TO_GENERATION_PARTICIPATION_FACTOR`` + balance type, even though real grids often set both fields together. + + The reference generator (angle datum, ``slack_ids[0]``) is a separate concern + from the participant/weight logic above and IS restricted to the main country + (the country hosting the most buses) when available: follow each candidate's + step-up transformer to the highest-voltage bus it reaches, pick the bus + carrying the most generator active power, then the generator injecting the + most into it -- a lightsim2grid-only proxy for OLF's own + ``slackBusSelectionMode=MOST_MESHED``. Without the country restriction here, a + single cross-border aggregate generator (*eg* a "whole neighbouring country" + equivalent injection, `max_p` in the tens of GW) reaches a high-voltage tie bus + and dominates this heuristic, becoming the reference -- which behaves nothing + like a real MOST_MESHED domestic bus and was empirically much worse (see the + country-filter history above; this restriction previously covered the + participant set too, until that was found to disagree with OLF and narrowed + down to just the reference pick). + """ + names = df_gen.index + n = len(names) + if n == 0: + return None + connected = df_gen["connected"].to_numpy(bool) + max_p = df_gen["max_p"].to_numpy(float) + target_p = df_gen["target_p"].to_numpy(float) + gen_bus = df_gen["bus_id"].to_numpy() + + # participation set + sharing key from the activePowerControl extension + try: + apc = net.get_extensions("activePowerControl") + except Exception: + apc = None + if apc is not None and len(apc) and "participate" in apc.columns: + # a generator listed in the extension uses its flag; one absent from it + # participates by default (OLF treats a missing extension as participating) + participate = apc["participate"].reindex(names).fillna(True).to_numpy(bool) + if "droop" in apc.columns: + droop = apc["droop"].reindex(names).to_numpy(float) + else: + droop = np.full(n, np.nan) + else: + participate = np.ones(n, bool) # no (or empty) extension -> everything participates + droop = np.full(n, np.nan) + + # sharing key (PROPORTIONAL_TO_GENERATION_P_MAX): max_p / droop, OLF's own + # DEFAULT_DROOP=4 when the extension does not set a droop of its own (pypowsybl + # reports an unset droop as 0.0, not NaN, hence the `> 0.` guard rather than + # `np.isfinite`). + droop_used = np.where(np.isfinite(droop) & (droop > 0.), droop, _OLF_DEFAULT_DROOP) + weight = max_p / droop_used + + # participants: connected, *started* (positive target P -- OLF does not + # distribute on zero-MW generators), participating, with a usable positive weight. + mask = connected & participate & (target_p > 0.) & np.isfinite(weight) & (weight > 0.) + + # main-component filter: a slack generator must sit in the main component, + # otherwise lightsim2grid's `consider_only_main_component` deactivates its + # (islanded) bus and the solver then trips on a disconnected slack. "Main" is + # the largest synchronous component, which matches the line + transformer + # connectivity lightsim2grid keeps (HVDC excluded) and mirrors OLF's own + # `LfSynchronousNetwork.getBuses()` restriction. Real grids carry many small + # boundary islands that satisfy the participation tests above. + df_bus = net.get_buses(attributes=["synchronous_component", "voltage_level_id"]) + main_sync = df_bus["synchronous_component"].value_counts().idxmax() + gen_sync = df_gen["bus_id"].map(df_bus["synchronous_component"]).to_numpy() + mask = mask & (gen_sync == main_sync) + + if not mask.any(): + return None + + # reference (angle datum): follow the step-up transformer(s) to the + # highest-voltage node, take the node with the most generator active power, then + # the generator injecting the most into it. + vls = net.get_voltage_levels() + nomv = net.get_buses()["voltage_level_id"].map(vls["nominal_v"]).to_dict() + tw = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "connected1", "connected2"]) + tc = tw[tw["connected1"] & tw["connected2"]] + adj = {} + for b1, b2 in zip(tc["bus1_id"], tc["bus2_id"]): + adj.setdefault(b1, []).append(b2) + adj.setdefault(b2, []).append(b1) + + def follow_gsu(bus, max_hops=4): + seen = {bus} + q = deque([(bus, 0)]) + best = bus + while q: + b, d = q.popleft() + if nomv.get(b, 0.) > nomv.get(best, 0.): + best = b + if d < max_hops: + for nb in adj.get(b, []): + if nb not in seen: + seen.add(nb) + q.append((nb, d + 1)) + return best + + # candidate pool for the *reference* (angle datum) only: restricted to the main + # country when available. This is a lightsim2grid-only heuristic proxy for OLF's + # own `slackBusSelectionMode=MOST_MESHED` reference pick (unrelated to country), + # added because a cross-border tie's equivalent generator (*eg* a single "whole + # neighbouring country" aggregate with a very large `max_p`) otherwise dominates + # the "most generation at the highest voltage reached" heuristic below and gets + # picked as reference, which is a poor MOST_MESHED proxy and was empirically much + # worse than restricting to the domestic candidates. Unlike this reference pick, + # the *participant* set (`mask` above) is intentionally left unrestricted, since + # OLF's actual active-power distribution (`ActivePowerDistribution. + # filterParticipatingBuses`) has no country logic at all. + ref_mask = mask + subs = net.get_substations() + if "country" in subs.columns and subs["country"].notna().any(): + vl2sub = vls["substation_id"] + bus_country = df_bus["voltage_level_id"].map(vl2sub).map(subs["country"]) + main_country = bus_country.value_counts().idxmax() + gen_country = df_gen["voltage_level_id"].map(vl2sub).map(subs["country"]).to_numpy() + if (mask & (gen_country == main_country)).any(): + ref_mask = mask & (gen_country == main_country) + + cand = np.where(mask)[0] + ref_cand = np.where(ref_mask)[0] + conn = {i: follow_gsu(gen_bus[i]) for i in ref_cand} + cvolt = {i: nomv.get(conn[i], 0.) for i in ref_cand} + vmax = max(cvolt.values()) + node_p = {} + for i in ref_cand: + if cvolt[i] == vmax: + node_p[conn[i]] = node_p.get(conn[i], 0.) + target_p[i] + ref_node = max(node_p, key=node_p.get) + ref_i = max((i for i in ref_cand if cvolt[i] == vmax and conn[i] == ref_node), + key=lambda i: target_p[i]) + + order = [int(ref_i)] + [int(i) for i in cand if i != ref_i] + return {names[i]: float(weight[i]) for i in order} + + +def _aux_add_slack(model, net, df_gen, gen_slack_id, slack_bus_id): + """Resolve and assign the slack bus(es) of ``model``: an explicit + ``gen_slack_id`` / ``slack_bus_id``, else OpenLoadFlow's default distributed + slack (see :func:`_default_distributed_slack`), else a single slack on the + most-connected generator bus. Returns ``gen_slack_ids_int``, the (0-based, + ``df_gen``-indexed) list of slack generator ids -- used by `initLSGrid.py` + to mark ``gen_sub["desired_slack"]``.""" + if gen_slack_id is None and slack_bus_id is None: + # Default: reproduce OpenLoadFlow's distributed slack, sharing the + # active-power mismatch over the participating generators (see + # _default_distributed_slack). Returns None -- handled by the single + # most-connected slack fallback below -- when no participating generator + # is found. + gen_slack_id = _default_distributed_slack(net, df_gen) + + if gen_slack_id is not None: + if slack_bus_id is not None: + raise RuntimeError("You provided both gen_slack_id and slack_bus_id " + "which is not possible.") + if isinstance(gen_slack_id, (str, int, np.int32, np.int64, np.str_, tuple)): + single_slack = True + fun_slack = handle_slack_one_el + else: + single_slack = False + fun_slack = handle_slack_iterable + gen_slack_ids_int, gen_slack_weights = fun_slack(df_gen, gen_slack_id) + if single_slack: + if gen_slack_weights is None: + raise RuntimeError(f"The slack {gen_slack_id} is disconnected.") + gen_slack_ids_int = [gen_slack_ids_int] + gen_slack_weights_fixed = [1.] + else: + gen_slack_weights_fixed = np.asarray([el if el is not None else np.nan for el in gen_slack_weights]) + mask_finite = np.isfinite(gen_slack_weights_fixed) + if not mask_finite.any(): + raise RuntimeError(f"No connected generators match the slack {gen_slack_id}") + gen_slack_weights_fixed[mask_finite] /= gen_slack_weights_fixed[mask_finite].sum() + + for gen_slack_id_int, gen_slack_weight in zip(gen_slack_ids_int, gen_slack_weights_fixed): + if np.isfinite(gen_slack_weight): + model.add_gen_slackbus(gen_slack_id_int, gen_slack_weight) + elif slack_bus_id is not None: + gen_bus = np.array([el.bus_id for el in model.get_generators()]) + gen_is_conn_slack = gen_bus == model._orig_to_ls[slack_bus_id] + nb_conn = gen_is_conn_slack.sum() + if nb_conn == 0: + raise RuntimeError(f"There is no generator connected to bus {slack_bus_id}. It cannot be the slack") + gen_slack_ids_int = [] + for gen_id, is_slack in enumerate(gen_is_conn_slack): + if is_slack: + gen_slack_ids_int.append(gen_id) + model.add_gen_slackbus(gen_id, 1. / nb_conn) + else: + # nothing provided and the default distributed slack found no participating + # generator: fall back to a single slack on the most-connected generator bus + bus_id, gen_id = model.assign_slack_to_most_connected() + gen_slack_ids_int = [gen_id] + + return gen_slack_ids_int diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_storage.py b/lightsim2grid/network/from_pypowsybl/_aux_add_storage.py new file mode 100644 index 00000000..11593f49 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_storage.py @@ -0,0 +1,40 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np + +from ._aux_common import _aux_get_bus + + +def _aux_add_storage(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl): + """Add every storage unit (IIDM battery) of ``net`` to ``model``. IIDM gives + the battery setpoints in the *generator* convention (positive target_p = + power produced / injected) while lightsim2grid stores storage as PQ in the + *load* convention (positive = power drawn from the grid, *ie* charging), same + as pandapower and grid2op. We negate to convert, and sanitize NaN (IIDM + allows an unset target_q). Returns ``(df_batt, batt_sub)``, used by the final + substation-id bookkeeping and ``return_sub_id`` in `initLSGrid.py`.""" + if sort_index: + df_batt = net.get_batteries().sort_index() + else: + df_batt = net.get_batteries() + batt_bus, batt_disco, batt_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "storage", df_batt) + batt_p = df_batt["target_p"].values.astype(float) + batt_q = df_batt["target_q"].values.astype(float) + batt_p = np.where(np.isfinite(batt_p), batt_p, 0.) + batt_q = np.where(np.isfinite(batt_q), batt_q, 0.) + model.init_storages(-batt_p, # IIDM generator convention -> lightsim2grid load convention + -batt_q, + batt_bus + ) + for batt_id, disco in enumerate(batt_disco): + if disco: + model.deactivate_storage(batt_id) + model.set_storage_names(df_batt.index) + + return df_batt, batt_sub diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_svc.py b/lightsim2grid/network/from_pypowsybl/_aux_add_svc.py new file mode 100644 index 00000000..5f794e34 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_svc.py @@ -0,0 +1,136 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import copy + +import numpy as np + +from ._aux_common import _aux_get_bus, _aux_regulated_bus_view_ids + + +def _aux_add_svc(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl, sn_mva_used): + """Add every Static Var Compensator (SVC) of ``net`` to ``model``: VOLTAGE + (local/remote, optional slope), REACTIVE_POWER (fixed Q) or OFF, all solved + through the bordered VoltageControl NR extension. A grid with no SVC declares + no controller and stays byte-identical to before this feature. Returns + ``df_svc`` (its per-substation ids are not needed downstream, unlike every + other element type here).""" + if sort_index: + df_svc = net.get_static_var_compensators().sort_index() + else: + df_svc = net.get_static_var_compensators() + nb_svc = df_svc.shape[0] + svc_bus, svc_disco, svc_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "svc", df_svc) + + # SvcContainer.RegulationMode: OFF=0, VOLTAGE=1, REACTIVE_POWER=2 + OFF_MODE, VOLTAGE_MODE, REACTIVE_POWER_MODE = 0, 1, 2 + svc_mode = np.zeros(nb_svc, dtype=int) + svc_reg_bus = svc_bus.copy() # regulated bus (own bus unless remote) + svc_reg_vn = np.ones(nb_svc) # nominal v (kV) of the regulated bus + svc_slope_pu = np.zeros(nb_svc) + svc_target_q_inject = np.zeros(nb_svc) + if nb_svc: + mode_str = df_svc["regulation_mode"].values.astype(str) + if "regulating" in df_svc.columns: + regulating = df_svc["regulating"].values.astype(bool) + else: + # legacy pypowsybl: "OFF" is encoded directly in the regulation mode + regulating = np.ones(nb_svc, dtype=bool) + svc_mode[(mode_str == "VOLTAGE") & regulating] = VOLTAGE_MODE + svc_mode[(mode_str == "REACTIVE_POWER") & regulating] = REACTIVE_POWER_MODE + + # resolve the regulated bus, mirroring the generator `regulated_element_id` + # logic above (busbar section in node/breaker grids, or any bus-connected + # element). Local SVCs keep their own bus. + svc_vl = copy.deepcopy(df_svc["voltage_level_id"].values) + if "regulated_element_id" in df_svc.columns: + reg_id = copy.deepcopy(df_svc["regulated_element_id"].values) + reg_id = np.where(reg_id == "", df_svc.index, reg_id) + mask_svc_remote = reg_id != df_svc.index.values + if mask_svc_remote.any(): + # TODO: resolved once at import; if the regulated element later changes + # bus inside lightsim2grid this stays frozen and desynchronises from the + # original grid (see `_aux_regulated_bus_view_ids` warning). + remote_svc_idx = np.nonzero(mask_svc_remote)[0] + svc_reg_bus_view = _aux_regulated_bus_view_ids(net, reg_id[mask_svc_remote]) + # same disconnected-remote-target situation as for generators above: + # fall back to local control rather than crashing on an unresolvable + # (disconnected) regulated element. + unresolved_svc = svc_reg_bus_view == "" + if unresolved_svc.any(): + mask_svc_remote[remote_svc_idx[unresolved_svc]] = False + svc_reg_bus_view = svc_reg_bus_view[~unresolved_svc] + svc_reg_bus[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "bus_global_id"].values + svc_vl[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "voltage_level_id"].values + svc_reg_vn = voltage_levels.loc[svc_vl, "nominal_v"].values + + # IIDM gives the SVC reactive setpoint in the receptor (load) convention, + # whereas lightsim2grid stamps Q with the generator-injection convention + # (Phase 0 probe: SVC target_q=+30 absorbs 30 MVar) -> negate. + target_q = df_svc["target_q"].values.astype(float) + target_q = np.where(np.isfinite(target_q), target_q, 0.) + svc_target_q_inject = -target_q + + # optional voltage/reactive-power slope ("droop"), in kV/MVar: + # s_pu = slope[kV/MVar] * sn_mva / vn_kv(regulated bus) (Phase 0 probe #1) + # Read from `net` (not `net_pu`): pypowsybl's per-unit view (native + # `per_unit=True` and the legacy `PerUnitView`) does not per-unit this + # extension at all -- `slope` comes back numerically identical whether + # or not `per_unit` is set (checked empirically) -- so there is no + # `net_pu` value to defer to here; the conversion has to be done by hand. + try: + df_slope = net.get_extensions("voltagePerReactivePowerControl") + except Exception: + # extension tables may be unavailable on (very) old pypowsybl versions + df_slope = None + if df_slope is not None and df_slope.shape[0]: + for svc_pos, svc_id in enumerate(df_svc.index): + if svc_id in df_slope.index: + slope_kv_per_mvar = float(df_slope.loc[svc_id, "slope"]) + svc_slope_pu[svc_pos] = slope_kv_per_mvar * sn_mva_used / svc_reg_vn[svc_pos] + + if nb_svc: + target_v = df_svc["target_v"].values.astype(float) + # target_v (kV) -> pu at the regulated bus; NaN (REACTIVE_POWER / OFF) -> 1 pu + target_vm_pu = np.where(np.isfinite(target_v), target_v, svc_reg_vn) / svc_reg_vn + # pypowsybl/IIDM gives b_min/b_max in SIEMENS (physical susceptance), while + # lightsim2grid's SvcContainer expects them in per unit (base sn_mva, at the + # regulated bus's nominal voltage -- same base as target_vm_pu/slope above). + # Without this conversion the SVC's modeled Q range is smaller than its real one + # by a factor of (nominal_v_kv)^2 / sn_mva (eg ~500x on a 225kV/100MVA bus), + # making a perfectly healthy SVC collapse to a near-zero Q range: it saturates + # (or, since check_solution never enforces SVC Q limits, silently hits a "hard" + # voltage pin at its own target_vm_pu regardless of what Q that would truly take) + # long before the real device would. + # This CANNOT be replaced by reading `net_pu.get_static_var_compensators()` + # instead: checked empirically that pypowsybl's per-unit view (native + # `per_unit=True` and the legacy `PerUnitView`) leaves `b_min`/`b_max` + # in SIEMENS even under `per_unit=True` -- it only per-units `target_v` + # (and, generically, elements it fully models) -- so relying on it here + # would silently reintroduce this exact bug. + b_min = df_svc["b_min"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used + b_max = df_svc["b_max"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used + else: + target_vm_pu = np.zeros(0) + b_min = np.zeros(0) + b_max = np.zeros(0) + + model.init_svcs([int(m) for m in svc_mode], + target_vm_pu, + svc_target_q_inject, + svc_slope_pu, + b_min, + b_max, + svc_reg_bus.astype(np.int32), + svc_bus.astype(np.int32)) + for svc_id, disco in enumerate(svc_disco): + if disco: + model.deactivate_svc(svc_id) + model.set_svc_names(df_svc.index) + + return df_svc diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_trafos.py b/lightsim2grid/network/from_pypowsybl/_aux_add_trafos.py new file mode 100644 index 00000000..c36d86fb --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_trafos.py @@ -0,0 +1,137 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np +import pandas as pd + +from ._aux_common import ( + _aux_get_bus, + _aux_current_limits, + _aux_get_2wt_all_attrs, + _aux_trafo_alpha, + _aux_trafo_rho, +) + + +def _aux_phase_shift_rx_tables(trafo_index, net): + """Per-transformer phase-shift -> series-impedance dependency for + :meth:`LSGrid.set_trafo_shift_dependent_rx`. + + Returns two lists-of-lists aligned to ``trafo_index``: the phase-shift sample + points ``alpha`` (in **radian**, matching lightsim2grid's ``shift_``) and the + matching r/x correction (**in percent**), read from the pypowsybl + phase-tap-changer steps. Inner lists are empty for transformers without a + phase-tap-changer. + + pypowsybl exposes the transformer r / x at the *neutral* tap and only folds the + tap into ``rho`` / ``alpha``; the per-step r/x deltas (percent) are dropped. They + matter for phase-shifting transformers whose series impedance varies with the + shift (phase-shifting transformers on real grids: tens of MW of through-flow). + On those grids ``r% == x%`` for + every step, so a single correction table is applied to both r and x.""" + n = len(trafo_index) + alpha = [[] for _ in range(n)] + corr = [[] for _ in range(n)] + try: + ptc = net.get_phase_tap_changers() + steps = net.get_phase_tap_changer_steps() + except Exception: # noqa: BLE001 - not available on legacy pypowsybl + return alpha, corr + if ptc.shape[0] == 0 or steps.shape[0] == 0: + return alpha, corr + have = set(ptc.index) + for i, tid in enumerate(trafo_index): + if tid not in have: + continue + try: + st = steps.loc[tid] + except KeyError: + continue + a = np.deg2rad(np.atleast_1d(st["alpha"].to_numpy(dtype=float))) + x = np.atleast_1d(st["x"].to_numpy(dtype=float)) # r% == x% on these PSTs + alpha[i] = [float(v) for v in a] + corr[i] = [float(v) for v in x] + return alpha, corr + + +def _aux_add_trafos(model, net, net_pu, sort_index, voltage_levels, bus_df, first_bus_per_vl, + ol_current, keep_half_open_lines, fuse_zero_impedance_branches, fused_trafo_ids): + """Add every 2-winding transformer of ``net`` to ``model``. ``ol_current`` + (``net.get_operational_limits()`` filtered to CURRENT, or ``None``) is shared + with `_aux_add_lines.py`, computed once in `initLSGrid.py`. Returns + ``(df_trafo, tor_sub, tex_sub)``, used by the final substation-id bookkeeping + and ``return_sub_id`` in `initLSGrid.py`.""" + # I extract trafo with `all_attributes=True` so that I have access to the `rho` + df_trafo_not_sorted = _aux_get_2wt_all_attrs(net) + + if sort_index: + df_trafo = df_trafo_not_sorted.sort_index() + else: + df_trafo = df_trafo_not_sorted + + df_trafo_pu = _aux_get_2wt_all_attrs(net_pu) + df_trafo_pu = df_trafo_pu.loc[df_trafo.index] + ratio_tap_changer = net_pu.get_ratio_tap_changers() + + shift_ = _aux_trafo_alpha(df_trafo_pu, net) + # tap is side 2 in IIDM + is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) + # neutral-tap impedance (the phase-shift -> r/x dependence of phase-shifting + # transformers is handled by lightsim2grid as a function of the shift alpha, see + # the model.set_trafo_shift_dependent_rx(...) call below). + trafo_r = df_trafo_pu["r"].values + trafo_x = df_trafo_pu["x"].values + trafo_h = (df_trafo_pu["g"].values + 1j * df_trafo_pu["b"].values) + + # now get the ratio + # in lightsim2grid (cpp) + ratio = _aux_trafo_rho(df_trafo_pu, ratio_tap_changer) + + tor_bus, tor_disco, tor_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 1)", df_trafo, conn_key="connected1", bus_key="bus1_id", vl_key="voltage_level1_id") + tex_bus, tex_disco, tex_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 2)", df_trafo, conn_key="connected2", bus_key="bus2_id", vl_key="voltage_level2_id") + model.init_trafo(trafo_r, + trafo_x, + trafo_h, + ratio, + shift_, # in degree ! + is_tap_side1, + tor_bus, + tex_bus, + False, # ignore_tap_side_for_phase_shift is False for pypowsybl + ) + for t_id, (is_or_disc, is_ex_disc) in enumerate(zip(tor_disco, tex_disco)): + if is_or_disc and is_ex_disc: + model.deactivate_trafo(t_id) + elif is_or_disc: + model.deactivate_trafo_side1(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) + elif is_ex_disc: + model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) + elif fuse_zero_impedance_branches and df_trafo.index[t_id] in fused_trafo_ids: + # both terminal buses already fused into one node above + model.deactivate_trafo(t_id) + model.set_trafo_names(df_trafo.index) + if "selected_limits_group_1" in df_trafo.columns: + trafo_group_1 = df_trafo["selected_limits_group_1"] + trafo_group_2 = df_trafo["selected_limits_group_2"] + else: + # not available on legacy pypowsybl + trafo_group_1 = pd.Series(np.nan, index=df_trafo.index) + trafo_group_2 = pd.Series(np.nan, index=df_trafo.index) + trafo_limit_a1_ka, trafo_limit_a2_ka = _aux_current_limits( + df_trafo.index, trafo_group_1, trafo_group_2, ol_current + ) + model.set_trafo_current_limit_side1(trafo_limit_a1_ka) + model.set_trafo_current_limit_side2(trafo_limit_a2_ka) + # phase-shifting transformers: declare the (alpha -> r/x correction) dependency so + # lightsim2grid keeps the series impedance right when the shift changes, without any + # "tap" concept (the per-step r/x deltas pypowsybl carries only in its tap steps). + ps_alpha, ps_rx_corr = _aux_phase_shift_rx_tables(df_trafo.index, net) + if any(len(a) for a in ps_alpha): + model.set_trafo_shift_dependent_rx(True, ps_alpha, ps_rx_corr) + + return df_trafo, tor_sub, tex_sub diff --git a/lightsim2grid/network/from_pypowsybl/_aux_common.py b/lightsim2grid/network/from_pypowsybl/_aux_common.py new file mode 100644 index 00000000..3606f305 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_aux_common.py @@ -0,0 +1,210 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Low-level helpers shared by two or more of the ``_aux_add_*.py`` element +converters (bus resolution, current limits, per-unit view, transformer ratio / +phase-shift, remote voltage-control bus resolution).""" + +import copy +import warnings + +import numpy as np +import pandas as pd +import pypowsybl as pypo +from packaging import version + +PP_BUG_RATIO_TAP_CHANGER = version.parse("1.9") +PYPOWSYBL_VER = version.parse(pypo.__version__) + + +def _aux_get_bus(vl_df, bus_df, first_bus_per_vl, el_type, df, conn_key="connected", bus_key="bus_id", vl_key="voltage_level_id"): + if df.shape[0] == 0: + # no element of this type so no problem + return np.zeros(0, dtype=int), np.ones(0, dtype=bool), np.zeros(0, dtype=int) + # retrieve which elements are disconnected + mask_disco = ~df[conn_key] + if mask_disco.any() and first_bus_per_vl is None: + raise RuntimeError(f"Some elements of type {el_type} are disconnected (no bus attached to them) and you " + "are in 'use pypowsybl bus as grid2op substation' mode. The converter cannot assign " + f"substation for {el_type}:\n {mask_disco[mask_disco].index.to_numpy()}\n, this is not supported. " + "Please upgrade the pypowsybl grid (*eg* grid.xiidm of the grid2op env) " + "and manually reconnect any non connected elements there.") + # retrieve the bus where the element are + tmp_bus_id = df[bus_key].copy() + + if mask_disco.any(): + # element disconnected are, by default assigned to first bus of their substation + el_disco = vl_df.loc[df.loc[mask_disco, vl_key]].index + tmp_bus_id.loc[mask_disco] = first_bus_per_vl.loc[el_disco, "first_bus_name"].values + + # assign bus id + bus_id = bus_df.loc[tmp_bus_id.values]["bus_global_id"].values.copy() + + if mask_disco.any(): + # deactivate the element not on the main component + # wrong_component = bus_df.loc[tmp_bus_id.values]["connected_component"].values != 0 + # mask_disco[wrong_component] = True + # assign bus -1 to disconnected elements + bus_id[mask_disco] = -1 + + sub_id = bus_df.loc[tmp_bus_id.values]["glop_sub_id"].values + return bus_id, mask_disco.values, sub_id + + +def _aux_current_limits(element_ids, group_1, group_2, operational_limits): + """Per-side thermal (current) limit, in **kA**, for a set of branch elements + (lines or transformers), aligned to ``element_ids``. + + ``group_1``/``group_2`` are the elements' ``selected_limits_group_1``/``_2`` + (pandas Series, from ``net.get_lines()``/``net.get_2_windings_transformers()`` + called with ``all_attributes=True``) -- a branch can carry several unused limit + groups (eg summer/winter), only the *selected* one per side is relevant. + ``operational_limits`` is ``net.get_operational_limits()`` already filtered to + ``type == "CURRENT"``. + + For each (element, side), the minimum finite value across all durations + (permanent + temporary) within the side's selected limit group is kept -- + pypowsybl's ``~1.8e308`` "no limit for this duration" placeholder is ignored. + NaN for a given (element, side) if it carries no current limit (or its selected + group has none).""" + n = len(element_ids) + limit_a1_ka = np.full(n, np.nan) + limit_a2_ka = np.full(n, np.nan) + if operational_limits is None or operational_limits.shape[0] == 0 or n == 0: + return limit_a1_ka, limit_a2_ka + # ignore the "no limit for this duration" placeholder + operational_limits = operational_limits[operational_limits["value"] < 1e30] + pos = pd.Series(np.arange(n), index=element_ids) + for out, side_str, group in ((limit_a1_ka, "ONE", group_1), (limit_a2_ka, "TWO", group_2)): + rows = operational_limits[operational_limits.index.get_level_values("side") == side_str] + if rows.shape[0] == 0: + continue + rows = rows.reset_index()[["element_id", "group_name", "value"]] + sel_group = group.reindex(element_ids).rename("sel_group").rename_axis("element_id").reset_index() + rows = rows.merge(sel_group, on="element_id", how="inner") + rows = rows[rows["group_name"] == rows["sel_group"]] + if rows.shape[0] == 0: + continue + min_per_el = rows.groupby("element_id")["value"].min() / 1000. # A -> kA + idx = pos.reindex(min_per_el.index).dropna().astype(int) + out[idx.values] = min_per_el.loc[idx.index].values + return limit_a1_ka, limit_a2_ka + + +def _aux_get_operational_limits_current(net): + """``net.get_operational_limits()`` filtered to ``type == "CURRENT"``, shared + by the line and transformer converters (see :func:`_aux_current_limits`). + ``None`` on legacy pypowsybl versions that don't expose it.""" + try: + ol_current = net.get_operational_limits() + return ol_current[ol_current.index.get_level_values("type") == "CURRENT"] + except Exception: # noqa: BLE001 - not available on legacy pypowsybl + return None + + +def _aux_ensure_net_pu(net, net_pu): + """Return `net_pu` unchanged if already provided by the caller; otherwise + build the per-unit view of `net` once. Factored out so the zero-impedance + bus-fusion pre-pass (which needs per-unit r/x before the "normal" per-unit + view is built) and the main line/trafo processing can share a single, + lazily-built `net_pu` instead of constructing it twice.""" + if net_pu is not None: + return net_pu + if hasattr(net, "per_unit"): + net_pu = copy.deepcopy(net) + net_pu.per_unit = True + else: + # legacy pypowsybl mode: this did not exist + from pypowsybl.network import PerUnitView + net_pu = PerUnitView(net) + warnings.warn("The `PerUnitView` (python side) is less efficient and less " + "tested that the equivalent java class. Please upgrade pypowsybl version") + return net_pu + + +def _aux_get_2wt_all_attrs(net_like): + """`get_2_windings_transformers(all_attributes=True)`, falling back to the + plain call on legacy pypowsybl versions that don't support it.""" + try: + return net_like.get_2_windings_transformers(all_attributes=True) + except TypeError: + return net_like.get_2_windings_transformers() + + +def _aux_trafo_alpha(df_trafo_pu, net): + """Phase-shift angle, in **degree**, for every transformer in `df_trafo_pu` + (a per-unit `get_2_windings_transformers()` table).""" + if 'alpha' in df_trafo_pu: + return np.rad2deg(df_trafo_pu['alpha'].values) # given in radian by pypowsybl + if net.get_phase_tap_changers().shape[0] > 0: + raise RuntimeError("Phase tap changer are not handled by the pypowsybl converter " + "when not accessible using the 'alpha' columns " + "of the net (once per unit). Please upgrade pypowsybl." + "NB: phase tap change are handled by lightsim2grid)") + return np.zeros(df_trafo_pu.shape[0]) + + +def _aux_trafo_rho(df_trafo_pu, ratio_tap_changer): + """Turns ratio for every transformer in `df_trafo_pu` (a per-unit + `get_2_windings_transformers()` table).""" + if "rho" in df_trafo_pu: + return 1. * df_trafo_pu["rho"].values + # in powsybl (https://javadoc.io/doc/com.powsybl/powsybl-core/latest/com/powsybl/iidm/network/TwoWindingsTransformer.html) + # rho = transfo.getRatedU2() / transfo.getRatedU1() + # * (transfo.getRatioTapChanger() != null ? transfo.getRatioTapChanger().getCurrentStep().getRho() : 1); + # * (transfo.getPhaseTapChanger() != null ? transfo.getPhaseTapChanger().getCurrentStep().getRho() : 1); + ratio = 1. * (df_trafo_pu["rated_u2"].values / df_trafo_pu["rated_u1"].values) + has_r_tap_changer = np.isin(df_trafo_pu.index, ratio_tap_changer.index) + if PYPOWSYBL_VER <= PP_BUG_RATIO_TAP_CHANGER: + # bug in per unit view in both python and java + ratio[has_r_tap_changer] = 1. * ratio_tap_changer.loc[df_trafo_pu.loc[has_r_tap_changer].index, "rho"].values + return ratio + + +def _aux_regulated_bus_view_ids(net, regulated_ids): + """Resolve voltage-controller regulated elements to their terminal bus. + + A remote voltage controller (generator or SVC) points at a `regulated_element_id` + which, depending on the grid topology, may be a busbar section (node/breaker) or + any bus-connected equipment (load, generator, ...). OpenLoadFlow regulates the + voltage of that element's terminal bus. This returns, for each id in + `regulated_ids`, the bus-view bus id of its terminal (which is also the index of + the `bus_df` table used elsewhere in this converter), as a numpy object array. + + .. warning:: + This mapping is resolved **once**, when the grid is imported. lightsim2grid + then stores the regulated bus by its (fixed) global id. If, afterwards, the + regulated element is moved to another bus *in lightsim2grid* (e.g. through a + ``change_bus_*`` / topology change), the controller keeps regulating the bus + resolved here: the two grids will desynchronise. Re-import the grid (or update + the regulated bus manually with ``set_gen_regulated_bus``) if you need to + follow such a topology change. + + .. todo:: + Track the regulated *element* (not the resolved bus) so that moving it to + another bus inside lightsim2grid updates the regulated bus automatically. + """ + lookup = {} + for getter in ("get_busbar_sections", "get_loads", "get_generators", + "get_static_var_compensators", "get_shunt_compensators", + "get_batteries", "get_vsc_converter_stations", + "get_lcc_converter_stations"): + try: + df = getattr(net, getter)() + except Exception: + continue + if df.shape[0] == 0 or "bus_id" not in df.columns: + continue + for el_id, bus_view_id in zip(df.index, df["bus_id"].values): + lookup.setdefault(el_id, bus_view_id) + missing = [rid for rid in regulated_ids if rid not in lookup] + if missing: + raise RuntimeError("Some voltage controllers regulate an element whose bus could " + "not be resolved (unknown or disconnected regulated element): " + f"{missing}. This is not supported at the moment.") + return np.array([lookup[rid] for rid in regulated_ids], dtype=object) diff --git a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py b/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py deleted file mode 100644 index 5315ecf3..00000000 --- a/lightsim2grid/network/from_pypowsybl/_from_pypowsybl.py +++ /dev/null @@ -1,1570 +0,0 @@ -# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. -# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, -# you can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. - -import warnings -import copy -from collections import deque -import numpy as np -import pandas as pd -import pypowsybl as pypo -from typing import Dict, Iterable, Optional, Union -from packaging import version -from ...lightsim2grid_cpp import LSGrid # type: ignore - - -from ._aux_handle_slack import handle_slack_iterable, handle_slack_one_el - -PP_BUG_RATIO_TAP_CHANGER = version.parse("1.9") -PYPOWSYBL_VER = version.parse(pypo.__version__) - -# suffix of the synthetic branch lightsim2grid line id for a dangling line's -# equivalent boundary branch (see `_aux_dangling_lines_fictitious`) -_DANGLING_BOUNDARY_LINE_SUFFIX = "@boundary_line" - -# OpenLoadFlow's hardcoded fallback droop (in `AbstractLfGenerator.DEFAULT_DROOP`, -# "why not") used for every generator whose `activePowerControl` extension does not -# set its own droop, under the ``PROPORTIONAL_TO_GENERATION_P_MAX`` balance type -- -# see `_default_distributed_slack`. -_OLF_DEFAULT_DROOP = 4.0 - - -def _aux_get_bus(vl_df, bus_df, first_bus_per_vl, el_type, df, conn_key="connected", bus_key="bus_id", vl_key="voltage_level_id"): - if df.shape[0] == 0: - # no element of this type so no problem - return np.zeros(0, dtype=int), np.ones(0, dtype=bool), np.zeros(0, dtype=int) - # retrieve which elements are disconnected - mask_disco = ~df[conn_key] - if mask_disco.any() and first_bus_per_vl is None: - raise RuntimeError(f"Some elements of type {el_type} are disconnected (no bus attached to them) and you " - "are in 'use pypowsybl bus as grid2op substation' mode. The converter cannot assign " - f"substation for {el_type}:\n {mask_disco[mask_disco].index.to_numpy()}\n, this is not supported. " - "Please upgrade the pypowsybl grid (*eg* grid.xiidm of the grid2op env) " - "and manually reconnect any non connected elements there.") - # retrieve the bus where the element are - tmp_bus_id = df[bus_key].copy() - - if mask_disco.any(): - # element disconnected are, by default assigned to first bus of their substation - el_disco = vl_df.loc[df.loc[mask_disco, vl_key]].index - tmp_bus_id.loc[mask_disco] = first_bus_per_vl.loc[el_disco, "first_bus_name"].values - - # assign bus id - bus_id = bus_df.loc[tmp_bus_id.values]["bus_global_id"].values.copy() - - if mask_disco.any(): - # deactivate the element not on the main component - # wrong_component = bus_df.loc[tmp_bus_id.values]["connected_component"].values != 0 - # mask_disco[wrong_component] = True - # assign bus -1 to disconnected elements - bus_id[mask_disco] = -1 - - sub_id = bus_df.loc[tmp_bus_id.values]["glop_sub_id"].values - return bus_id, mask_disco.values, sub_id - - -def _aux_dangling_lines_fictitious(net, sort_index): - """One fictitious 1-bus "substation" per dangling line, at the far - (boundary) end of its series impedance. - - Real full grids never have any dangling line -- they only appear when - zooming into a sub-area with - ``network.reduce_by_ids_and_depths(..., with_boundary_lines=True)``, - which cuts the grid and represents everything beyond the cut as a - ``DanglingLine`` (series r/x/g/b + a constant-power p0/q0 "load" at the - boundary). Without this, ``_from_pypowsybl`` silently drops that - boundary injection (it never reads ``get_dangling_lines()``), which can - be tens to hundreds of MW and makes the reduced-grid comparison - meaningless. - - IIDM convention: the shunt admittance (g, b) of a dangling line sits - entirely on the connectable (network) side; the boundary side carries - none (same convention already used for transformers' single ``h``, see - ``trafo_h`` below). - - Returns ``(bus_extra, vl_extra, df_dl)``; ``bus_extra``/``vl_extra`` are - ``None`` and ``df_dl`` is empty if the grid has no dangling line. - ``df_dl`` carries two extra columns, ``boundary_bus_id`` / - ``boundary_vl_id``, used later to attach the equivalent branch + load. - - Deliberately built from ``net`` (physical units), not ``net_pu``: every - field actually read off ``df_dl`` downstream is unit-agnostic (ids, - ``connected``) or physical on purpose (``p0``/``q0`` in MW/MVAr, matching - every other load in this module). The per-unit-sensitive series - impedance (r/x/g/b) is read separately, from ``net_pu.get_dangling_lines()`` - (see the ``df_dl_pu`` fetch further down) -- ``net``/``df_dl`` is never - used for those. - """ - df_dl = net.get_dangling_lines() - if sort_index: - df_dl = df_dl.sort_index() - if df_dl.shape[0] == 0: - return None, None, df_dl - df_dl = df_dl.copy() - df_dl["boundary_bus_id"] = [f"{dl_id}@boundary_bus" for dl_id in df_dl.index] - df_dl["boundary_vl_id"] = [f"{dl_id}@boundary_vl" for dl_id in df_dl.index] - nominal_v = net.get_voltage_levels().loc[df_dl["voltage_level_id"].values, "nominal_v"].values - bus_extra = pd.DataFrame({"voltage_level_id": df_dl["boundary_vl_id"].values, "name": ""}, - index=pd.Index(df_dl["boundary_bus_id"].values, name=df_dl.index.name or "id")) - vl_extra = pd.DataFrame({"nominal_v": nominal_v}, - index=pd.Index(df_dl["boundary_vl_id"].values, name="id")) - return bus_extra, vl_extra, df_dl - - -def dangling_line_boundary_bus(model: LSGrid) -> Dict[str, int]: - """Dangling-line id -> lightsim2grid global bus id of its fictitious - boundary bus, for a grid built with ``init(..., convert_dangling_lines=True)`` - (see :func:`_aux_dangling_lines_fictitious`). Empty if the grid has no - dangling line, or was built with ``convert_dangling_lines=False``. - - Unlike every other bus, a dangling line's boundary bus has no counterpart in - ``network.get_buses()`` so it is not reachable through ``model._ls_to_orig``. - Rather than stashing extra state on ``model`` (a pybind11 object without - ``dynamic_attr``, so arbitrary attributes cannot be set on it), this is - reconstructed from the synthetic boundary branch's own (real, C++-backed) - ``bus2_id`` -- once built, that branch is an ordinary lightsim2grid line. - """ - suffix = _DANGLING_BOUNDARY_LINE_SUFFIX - return {el.name[:-len(suffix)]: el.bus2_id - for el in model.get_lines() if el.name.endswith(suffix)} - - -def _aux_phase_shift_rx_tables(trafo_index, net): - """Per-transformer phase-shift -> series-impedance dependency for - :meth:`LSGrid.set_trafo_shift_dependent_rx`. - - Returns two lists-of-lists aligned to ``trafo_index``: the phase-shift sample - points ``alpha`` (in **radian**, matching lightsim2grid's ``shift_``) and the - matching r/x correction (**in percent**), read from the pypowsybl - phase-tap-changer steps. Inner lists are empty for transformers without a - phase-tap-changer. - - pypowsybl exposes the transformer r / x at the *neutral* tap and only folds the - tap into ``rho`` / ``alpha``; the per-step r/x deltas (percent) are dropped. They - matter for phase-shifting transformers whose series impedance varies with the - shift (phase-shifting transformers on real grids: tens of MW of through-flow). - On those grids ``r% == x%`` for - every step, so a single correction table is applied to both r and x.""" - n = len(trafo_index) - alpha = [[] for _ in range(n)] - corr = [[] for _ in range(n)] - try: - ptc = net.get_phase_tap_changers() - steps = net.get_phase_tap_changer_steps() - except Exception: # noqa: BLE001 - not available on legacy pypowsybl - return alpha, corr - if ptc.shape[0] == 0 or steps.shape[0] == 0: - return alpha, corr - have = set(ptc.index) - for i, tid in enumerate(trafo_index): - if tid not in have: - continue - try: - st = steps.loc[tid] - except KeyError: - continue - a = np.deg2rad(np.atleast_1d(st["alpha"].to_numpy(dtype=float))) - x = np.atleast_1d(st["x"].to_numpy(dtype=float)) # r% == x% on these PSTs - alpha[i] = [float(v) for v in a] - corr[i] = [float(v) for v in x] - return alpha, corr - - -def _aux_current_limits(element_ids, group_1, group_2, operational_limits): - """Per-side thermal (current) limit, in **kA**, for a set of branch elements - (lines or transformers), aligned to ``element_ids``. - - ``group_1``/``group_2`` are the elements' ``selected_limits_group_1``/``_2`` - (pandas Series, from ``net.get_lines()``/``net.get_2_windings_transformers()`` - called with ``all_attributes=True``) -- a branch can carry several unused limit - groups (eg summer/winter), only the *selected* one per side is relevant. - ``operational_limits`` is ``net.get_operational_limits()`` already filtered to - ``type == "CURRENT"``. - - For each (element, side), the minimum finite value across all durations - (permanent + temporary) within the side's selected limit group is kept -- - pypowsybl's ``~1.8e308`` "no limit for this duration" placeholder is ignored. - NaN for a given (element, side) if it carries no current limit (or its selected - group has none).""" - n = len(element_ids) - limit_a1_ka = np.full(n, np.nan) - limit_a2_ka = np.full(n, np.nan) - if operational_limits is None or operational_limits.shape[0] == 0 or n == 0: - return limit_a1_ka, limit_a2_ka - # ignore the "no limit for this duration" placeholder - operational_limits = operational_limits[operational_limits["value"] < 1e30] - pos = pd.Series(np.arange(n), index=element_ids) - for out, side_str, group in ((limit_a1_ka, "ONE", group_1), (limit_a2_ka, "TWO", group_2)): - rows = operational_limits[operational_limits.index.get_level_values("side") == side_str] - if rows.shape[0] == 0: - continue - rows = rows.reset_index()[["element_id", "group_name", "value"]] - sel_group = group.reindex(element_ids).rename("sel_group").rename_axis("element_id").reset_index() - rows = rows.merge(sel_group, on="element_id", how="inner") - rows = rows[rows["group_name"] == rows["sel_group"]] - if rows.shape[0] == 0: - continue - min_per_el = rows.groupby("element_id")["value"].min() / 1000. # A -> kA - idx = pos.reindex(min_per_el.index).dropna().astype(int) - out[idx.values] = min_per_el.loc[idx.index].values - return limit_a1_ka, limit_a2_ka - - -def _aux_ensure_net_pu(net, net_pu): - """Return `net_pu` unchanged if already provided by the caller; otherwise - build the per-unit view of `net` once. Factored out so the zero-impedance - bus-fusion pre-pass (which needs per-unit r/x before the "normal" per-unit - view is built) and the main line/trafo processing can share a single, - lazily-built `net_pu` instead of constructing it twice.""" - if net_pu is not None: - return net_pu - if hasattr(net, "per_unit"): - net_pu = copy.deepcopy(net) - net_pu.per_unit = True - else: - # legacy pypowsybl mode: this did not exist - from pypowsybl.network import PerUnitView - net_pu = PerUnitView(net) - warnings.warn("The `PerUnitView` (python side) is less efficient and less " - "tested that the equivalent java class. Please upgrade pypowsybl version") - return net_pu - - -def _aux_get_2wt_all_attrs(net_like): - """`get_2_windings_transformers(all_attributes=True)`, falling back to the - plain call on legacy pypowsybl versions that don't support it.""" - try: - return net_like.get_2_windings_transformers(all_attributes=True) - except TypeError: - return net_like.get_2_windings_transformers() - - -def _aux_trafo_alpha(df_trafo_pu, net): - """Phase-shift angle, in **degree**, for every transformer in `df_trafo_pu` - (a per-unit `get_2_windings_transformers()` table).""" - if 'alpha' in df_trafo_pu: - return np.rad2deg(df_trafo_pu['alpha'].values) # given in radian by pypowsybl - if net.get_phase_tap_changers().shape[0] > 0: - raise RuntimeError("Phase tap changer are not handled by the pypowsybl converter " - "when not accessible using the 'alpha' columns " - "of the net (once per unit). Please upgrade pypowsybl." - "NB: phase tap change are handled by lightsim2grid)") - return np.zeros(df_trafo_pu.shape[0]) - - -def _aux_trafo_rho(df_trafo_pu, ratio_tap_changer): - """Turns ratio for every transformer in `df_trafo_pu` (a per-unit - `get_2_windings_transformers()` table).""" - if "rho" in df_trafo_pu: - return 1. * df_trafo_pu["rho"].values - # in powsybl (https://javadoc.io/doc/com.powsybl/powsybl-core/latest/com/powsybl/iidm/network/TwoWindingsTransformer.html) - # rho = transfo.getRatedU2() / transfo.getRatedU1() - # * (transfo.getRatioTapChanger() != null ? transfo.getRatioTapChanger().getCurrentStep().getRho() : 1); - # * (transfo.getPhaseTapChanger() != null ? transfo.getPhaseTapChanger().getCurrentStep().getRho() : 1); - ratio = 1. * (df_trafo_pu["rated_u2"].values / df_trafo_pu["rated_u1"].values) - has_r_tap_changer = np.isin(df_trafo_pu.index, ratio_tap_changer.index) - if PYPOWSYBL_VER <= PP_BUG_RATIO_TAP_CHANGER: - # bug in per unit view in both python and java - ratio[has_r_tap_changer] = 1. * ratio_tap_changer.loc[df_trafo_pu.loc[has_r_tap_changer].index, "rho"].values - return ratio - - -def _aux_regulated_bus_view_ids(net, regulated_ids): - """Resolve voltage-controller regulated elements to their terminal bus. - - A remote voltage controller (generator or SVC) points at a `regulated_element_id` - which, depending on the grid topology, may be a busbar section (node/breaker) or - any bus-connected equipment (load, generator, ...). OpenLoadFlow regulates the - voltage of that element's terminal bus. This returns, for each id in - `regulated_ids`, the bus-view bus id of its terminal (which is also the index of - the `bus_df` table used elsewhere in this converter), as a numpy object array. - - .. warning:: - This mapping is resolved **once**, when the grid is imported. lightsim2grid - then stores the regulated bus by its (fixed) global id. If, afterwards, the - regulated element is moved to another bus *in lightsim2grid* (e.g. through a - ``change_bus_*`` / topology change), the controller keeps regulating the bus - resolved here: the two grids will desynchronise. Re-import the grid (or update - the regulated bus manually with ``set_gen_regulated_bus``) if you need to - follow such a topology change. - - .. todo:: - Track the regulated *element* (not the resolved bus) so that moving it to - another bus inside lightsim2grid updates the regulated bus automatically. - """ - lookup = {} - for getter in ("get_busbar_sections", "get_loads", "get_generators", - "get_static_var_compensators", "get_shunt_compensators", - "get_batteries", "get_vsc_converter_stations", - "get_lcc_converter_stations"): - try: - df = getattr(net, getter)() - except Exception: - continue - if df.shape[0] == 0 or "bus_id" not in df.columns: - continue - for el_id, bus_view_id in zip(df.index, df["bus_id"].values): - lookup.setdefault(el_id, bus_view_id) - missing = [rid for rid in regulated_ids if rid not in lookup] - if missing: - raise RuntimeError("Some voltage controllers regulate an element whose bus could " - "not be resolved (unknown or disconnected regulated element): " - f"{missing}. This is not supported at the moment.") - return np.array([lookup[rid] for rid in regulated_ids], dtype=object) - - -def _default_distributed_slack(net, df_gen): - """Build the default distributed slack matching OpenLoadFlow's behaviour. - - Returns an *ordered* ``{generator_name: weight}`` dict (the reference generator - first, so it becomes ``slack_ids[0]`` -- the angle datum) suitable for - ``gen_slack_id``, or ``None`` if no participating generator is found (the caller - then falls back to :meth:`LSGrid.assign_slack_to_most_connected`). - - The participating set and the sharing key mirror OLF's default distributed - slack: - - * participants are the connected generators with ``max_p > 0`` that take part in - active-power control (``participate`` flag of the ``activePowerControl`` - extension); if that extension is absent, every connected producing generator - participates; - * participants are restricted to the *main synchronous component* (OLF's - ``ActivePowerDistribution.run`` only ever considers - ``LfSynchronousNetwork.getBuses()``) -- **not** to any particular country: - ``ActivePowerDistribution.filterParticipatingBuses`` has no country logic at - all, and OLF's own ``slackBusCountryFilter`` is empty by default, so foreign - border-equivalent generators (*eg* a cross-border interconnection's - equivalent generator, which can carry a very large ``max_p``/weight) do - participate. An earlier version of this function restricted participants to - the country hosting the most buses; that was a guess, not something OLF - actually does, and it was found to materially skew which generators absorb - the slack mismatch on real multi-country grids -- removed; - * the per-generator weight matches OLF's default ``PROPORTIONAL_TO_GENERATION_P_MAX`` - balance type exactly (``GenerationActivePowerDistributionStep.getParticipationFactor``, - ``MAX`` case): ``max_p / droop``, where ``droop`` is the ``activePowerControl`` - extension's own droop when it sets one (> 0), otherwise OLF's hardcoded - ``DEFAULT_DROOP = 4`` for *every* generator regardless of the extension. - **Not** the extension's ``participation_factor`` -- that key is only used - under the (different, not reproduced here) ``PROPORTIONAL_TO_GENERATION_PARTICIPATION_FACTOR`` - balance type, even though real grids often set both fields together. - - The reference generator (angle datum, ``slack_ids[0]``) is a separate concern - from the participant/weight logic above and IS restricted to the main country - (the country hosting the most buses) when available: follow each candidate's - step-up transformer to the highest-voltage bus it reaches, pick the bus - carrying the most generator active power, then the generator injecting the - most into it -- a lightsim2grid-only proxy for OLF's own - ``slackBusSelectionMode=MOST_MESHED``. Without the country restriction here, a - single cross-border aggregate generator (*eg* a "whole neighbouring country" - equivalent injection, `max_p` in the tens of GW) reaches a high-voltage tie bus - and dominates this heuristic, becoming the reference -- which behaves nothing - like a real MOST_MESHED domestic bus and was empirically much worse (see the - country-filter history above; this restriction previously covered the - participant set too, until that was found to disagree with OLF and narrowed - down to just the reference pick). - """ - names = df_gen.index - n = len(names) - if n == 0: - return None - connected = df_gen["connected"].to_numpy(bool) - max_p = df_gen["max_p"].to_numpy(float) - target_p = df_gen["target_p"].to_numpy(float) - gen_bus = df_gen["bus_id"].to_numpy() - - # participation set + sharing key from the activePowerControl extension - try: - apc = net.get_extensions("activePowerControl") - except Exception: - apc = None - if apc is not None and len(apc) and "participate" in apc.columns: - # a generator listed in the extension uses its flag; one absent from it - # participates by default (OLF treats a missing extension as participating) - participate = apc["participate"].reindex(names).fillna(True).to_numpy(bool) - if "droop" in apc.columns: - droop = apc["droop"].reindex(names).to_numpy(float) - else: - droop = np.full(n, np.nan) - else: - participate = np.ones(n, bool) # no (or empty) extension -> everything participates - droop = np.full(n, np.nan) - - # sharing key (PROPORTIONAL_TO_GENERATION_P_MAX): max_p / droop, OLF's own - # DEFAULT_DROOP=4 when the extension does not set a droop of its own (pypowsybl - # reports an unset droop as 0.0, not NaN, hence the `> 0.` guard rather than - # `np.isfinite`). - droop_used = np.where(np.isfinite(droop) & (droop > 0.), droop, _OLF_DEFAULT_DROOP) - weight = max_p / droop_used - - # participants: connected, *started* (positive target P -- OLF does not - # distribute on zero-MW generators), participating, with a usable positive weight. - mask = connected & participate & (target_p > 0.) & np.isfinite(weight) & (weight > 0.) - - # main-component filter: a slack generator must sit in the main component, - # otherwise lightsim2grid's `consider_only_main_component` deactivates its - # (islanded) bus and the solver then trips on a disconnected slack. "Main" is - # the largest synchronous component, which matches the line + transformer - # connectivity lightsim2grid keeps (HVDC excluded) and mirrors OLF's own - # `LfSynchronousNetwork.getBuses()` restriction. Real grids carry many small - # boundary islands that satisfy the participation tests above. - df_bus = net.get_buses(attributes=["synchronous_component", "voltage_level_id"]) - main_sync = df_bus["synchronous_component"].value_counts().idxmax() - gen_sync = df_gen["bus_id"].map(df_bus["synchronous_component"]).to_numpy() - mask = mask & (gen_sync == main_sync) - - if not mask.any(): - return None - - # reference (angle datum): follow the step-up transformer(s) to the - # highest-voltage node, take the node with the most generator active power, then - # the generator injecting the most into it. - vls = net.get_voltage_levels() - nomv = net.get_buses()["voltage_level_id"].map(vls["nominal_v"]).to_dict() - tw = net.get_2_windings_transformers(attributes=["bus1_id", "bus2_id", "connected1", "connected2"]) - tc = tw[tw["connected1"] & tw["connected2"]] - adj = {} - for b1, b2 in zip(tc["bus1_id"], tc["bus2_id"]): - adj.setdefault(b1, []).append(b2) - adj.setdefault(b2, []).append(b1) - - def follow_gsu(bus, max_hops=4): - seen = {bus} - q = deque([(bus, 0)]) - best = bus - while q: - b, d = q.popleft() - if nomv.get(b, 0.) > nomv.get(best, 0.): - best = b - if d < max_hops: - for nb in adj.get(b, []): - if nb not in seen: - seen.add(nb) - q.append((nb, d + 1)) - return best - - # candidate pool for the *reference* (angle datum) only: restricted to the main - # country when available. This is a lightsim2grid-only heuristic proxy for OLF's - # own `slackBusSelectionMode=MOST_MESHED` reference pick (unrelated to country), - # added because a cross-border tie's equivalent generator (*eg* a single "whole - # neighbouring country" aggregate with a very large `max_p`) otherwise dominates - # the "most generation at the highest voltage reached" heuristic below and gets - # picked as reference, which is a poor MOST_MESHED proxy and was empirically much - # worse than restricting to the domestic candidates. Unlike this reference pick, - # the *participant* set (`mask` above) is intentionally left unrestricted, since - # OLF's actual active-power distribution (`ActivePowerDistribution. - # filterParticipatingBuses`) has no country logic at all. - ref_mask = mask - subs = net.get_substations() - if "country" in subs.columns and subs["country"].notna().any(): - vl2sub = vls["substation_id"] - bus_country = df_bus["voltage_level_id"].map(vl2sub).map(subs["country"]) - main_country = bus_country.value_counts().idxmax() - gen_country = df_gen["voltage_level_id"].map(vl2sub).map(subs["country"]).to_numpy() - if (mask & (gen_country == main_country)).any(): - ref_mask = mask & (gen_country == main_country) - - cand = np.where(mask)[0] - ref_cand = np.where(ref_mask)[0] - conn = {i: follow_gsu(gen_bus[i]) for i in ref_cand} - cvolt = {i: nomv.get(conn[i], 0.) for i in ref_cand} - vmax = max(cvolt.values()) - node_p = {} - for i in ref_cand: - if cvolt[i] == vmax: - node_p[conn[i]] = node_p.get(conn[i], 0.) + target_p[i] - ref_node = max(node_p, key=node_p.get) - ref_i = max((i for i in ref_cand if cvolt[i] == vmax and conn[i] == ref_node), - key=lambda i: target_p[i]) - - order = [int(ref_i)] + [int(i) for i in cand if i != ref_i] - return {names[i]: float(weight[i]) for i in order} - - -def init(net : pypo.network.Network, - gen_slack_id: Union[int, str, Iterable[str], Dict[str, float]] = None, - slack_bus_id: int = None, - sn_mva : float = 100., # only used if not present in the grid - sort_index : bool =True, - f_hz : float = 50., # unused - net_pu : Optional[pypo.network.Network] = None, - only_main_component : bool =True, - return_sub_id: bool=False, - n_busbar_per_sub: Optional[int]=None, # new in 0.9.1 - buses_for_sub:Optional[bool]=None, # new in 0.9.1 - init_vm_pu:float=1.06, - keep_half_open_lines: bool=False, - convert_dangling_lines: bool=False, - fuse_zero_impedance_branches: bool=False, - zero_impedance_threshold_pu: float=1e-8, - ) -> LSGrid: - """ - This function is available under the `init_from_pypowsybl` in lightsim2grid - - - .. code-block:: python - - from lightsim2grid.network import init_from_pypowsybl - - .. warning:: - It is not available if the `pypowsybl` python package is not installed. - - :param net: The pypowsybl network - :type net: pypo.network.Network - - :param gen_slack_id: The id of the generator that should be used as the slack - (either it's given by id (int) or by name (str)) - :type gen_slack_id: Union[int, str] - - :param slack_bus_id: If you don't provide a generator ID as a slack bus, you can - provide a bus id (int). We do not recommend setting the slack this way. - :type slack_bus_id: int - - :param sn_mva: The nominal apparent power used when converting the grid to - per unit. It is only used if the pypowsybl grid - has no `_nominal_apparent_power` attribute. - **Advanced usage**. - :type sn_mva: float - - :param sort_index: Whether you want to sort the indexes of all the - pypowsybl tables (*eg* get_loads() or *get_buses()*) or not. - Sorting the grid tables is preferable if you want to be - "future proof" and don't want to depend on pandas version - (same order is guaranteed). Not sorting the grid will give - easier comparison of results with pypowsybl. - :type sn_mva: bool - - :param f_hz: Not used currently (frequency of the grid) - :type net_pu: float - - :param net_pu: If you have already converted the grid in "per unit" then - you can pass it as the `net_pu` argument. Otherwise this - function will do it. - **Advanced usage**. - :type net_pu: Optional[pypo.network.Network] - - :param only_main_component: If this is True, then only the main component (*ie* - the one containing the slack bus) will be used. All - equipments not part of this component will be - deactivated (switched-off). **NB** currently - lightsim2grid will diverge if the grid is not connected, - this option might then "hide" some equipements from - the grid (silently) but you have higher chances of - convergence. - :type only_main_component: bool - - :param return_sub_id: **Advanced usage**. If you want to retrieve the id of the - equipments as "tables". Used only for `LightSimBackend` - :type return_sub_id: bool - - :param n_busbar_per_sub: Currently, lightsim2grid works well with a constant - number of independant buses that can be made at each - substations. It can be infered from the grid or - set with this attribute. We recommend to leave it - to `None` (which corresponds to the "infer it from - the grid" behaviour) in most cases. - :type n_busbar_per_sub: Opional[int] - - :param buses_for_sub: Whether the lightsim2grid substation will correspond to buses - of the pypowsybl grid (if buses_for_sub is `True`). - Alternatively, if buses_for_sub is `False`, the - lightsim2grid susbtation will correspond to - pypowsybl voltage level (read from net.get_voltage_levels()). - buses_for_sub==`True` is a "legacy" behaviour. - :type buses_for_sub: bool - - :param init_vm_pu: The voltage magnitude with which the init vector of AC powerflow - will be set. - :type init_vm_pu: float - - :param keep_half_open_lines: If True, a powerline or transformer connected on only - one terminal (``connected1 != connected2``, *eg* a dangling - boundary stub in a real grid) is modeled as "half-open": the - energized side is kept in the admittance matrix and the open end - is Kron-reduced out, instead of deactivating the whole branch. - This sets ``synch_status_both_side=False`` on the returned model, - so a later one-sided topology change is no longer mirrored to the - other side. Branches disconnected on *both* sides are still fully - deactivated. Default False (whole-branch deactivation, as before). - :type keep_half_open_lines: bool - - :param convert_dangling_lines: If True, every IIDM ``DanglingLine`` (*eg* - produced by ``network.reduce_by_ids_and_depths(..., - with_boundary_lines=True)`` when zooming into a - sub-area) is converted to its equivalent branch + - constant-power load: a fictitious 1-bus "substation" at - the boundary end, a line carrying the dangling line's own - r/x/g/b (shunt entirely on the local side, matching - transformers' single ``h``), and a load consuming its - p0/q0. Without this (the default), dangling lines are - silently ignored -- fine for real full grids, which - never have any, but it drops a real boundary injection - (can be hundreds of MW) whenever they do appear. Off by - default to keep existing behaviour unchanged; the - reduce/validate debug scripts turn it on. - :type convert_dangling_lines: bool - - :param fuse_zero_impedance_branches: If True, a line or 2-winding transformer - whose per-unit impedance is (near-)zero -- ``|r_pu| < - zero_impedance_threshold_pu`` and ``|x_pu| < - zero_impedance_threshold_pu`` -- has its two terminal buses - fused into a single electrical node instead of contributing - a ``1/Z`` admittance (which is ``Inf`` for an exact zero, and - breaks the sparse LU factorization outright). This mirrors - OpenLoadFlow's ``lowImpedanceBranchMode`` / - ``lowImpedanceThreshold``. A zero-impedance transformer is - only fused if it is also at (near-)neutral tap (``rho`` close - to 1 and ``alpha`` close to 0) *and* its two sides are at the - same nominal voltage: pypowsybl's per-unit ``rho`` is the - deviation from the transformer's *own* rated ratio (the - tap-changer effect), not its absolute turns ratio, so - ``rho~=1`` alone does not mean "no transformation" for a - genuine step-down/up transformer -- otherwise it is a real - ideal ratio/phase-shifting element, not a same-node short, and - is left untouched. A zero-impedance *line* spanning two - *different* nominal voltages raises a ``RuntimeError`` - (inconsistent grid data) -- unlike for transformers, this is - never legitimate for a line. The fusing branch itself is kept in - the model (for topology-vector bookkeeping) but deactivated, - same as any other disconnected branch; substation/topology - identity (``glop_sub_id``) of elements on the two original - buses is *not* changed, only the internal solver bus id -- - grid2op topology actions still see the original, distinct - substations. Off by default to keep existing behaviour - unchanged. - - .. warning:: - This is a static, import-time decision. If a fused - transformer's tap is later moved away from neutral - during a simulation, the two buses stay fused in - lightsim2grid even though the transformer is no longer - electrically a plain wire. Best suited for static / - diagnostic use, or grid2op environments known not to - actuate the affected transformer's tap. - :type fuse_zero_impedance_branches: bool - - :param zero_impedance_threshold_pu: Per-unit impedance magnitude threshold used - by ``fuse_zero_impedance_branches`` (ignored otherwise). - Matches OpenLoadFlow's ``lowImpedanceThreshold`` default. - :type zero_impedance_threshold_pu: float - - :return: The properly initialized network. - :rtype: :class:`LSGrid` - """ - model = LSGrid() - if hasattr(net, "_nominal_apparent_power"): - sn_mva_used = getattr(net, "_nominal_apparent_power") - else: - sn_mva_used = float(sn_mva) - model.set_sn_mva(sn_mva_used) - model.set_init_vm_pu(float(init_vm_pu)) - if keep_half_open_lines: - # allow branches connected on a single terminal (the open end is Kron-reduced - # in the C++ model); a one-sided disconnection is no longer mirrored to the - # other side. - model.set_synch_status_both_side(False) - - if gen_slack_id is not None and slack_bus_id is not None: - raise RuntimeError("Impossible to intialize a grid with both gen_slack_id and slack_bus_id") - - # assign unique id to the buses - bus_df_orig = net.get_buses() - if sort_index: - bus_df = bus_df_orig.sort_index().copy() - else: - bus_df = bus_df_orig.copy() - bus_df["orig_id"] = np.arange(bus_df.shape[0]) - - if sort_index: - voltage_levels = net.get_voltage_levels().sort_index() - else: - voltage_levels = net.get_voltage_levels() - - # dangling lines (only present when the grid was cut with - # `reduce_by_ids_and_depths(..., with_boundary_lines=True)`, see - # `_aux_dangling_lines_fictitious`) each get their own fictitious 1-bus - # "substation" at their boundary end, appended *after* the real buses so - # `orig_id` / row-position based mappings (eg `_ls_to_orig`, used by - # `lightsim_bus_to_iidm`) keep pointing at real pypowsybl buses only. - if convert_dangling_lines: - bus_extra, vl_extra, df_dl = _aux_dangling_lines_fictitious(net, sort_index) - else: - raw_dl = net.get_dangling_lines() - bus_extra, vl_extra, df_dl = None, None, raw_dl.iloc[0:0] - if raw_dl.shape[0] > 0: - warnings.warn(f"{raw_dl.shape[0]} dangling line(s) found in the grid (eg from " - "`network.reduce_by_ids_and_depths(..., with_boundary_lines=True)`) " - "but `convert_dangling_lines=False` (the default): their boundary " - "injection is ignored. Pass `convert_dangling_lines=True` to model " - "them as an equivalent branch + constant-power load instead.") - if df_dl.shape[0] > 0: - if buses_for_sub is not None and buses_for_sub: - raise RuntimeError("Dangling lines (eg from `network.reduce_by_ids_and_depths(" - "..., with_boundary_lines=True)`) are not supported " - "together with buses_for_sub=True (legacy mode). Use " - "buses_for_sub=False (or None).") - bus_extra = bus_extra.copy() - bus_extra["orig_id"] = np.arange(bus_df.shape[0], bus_df.shape[0] + bus_extra.shape[0]) - bus_df = pd.concat([bus_df, bus_extra]) - voltage_levels = pd.concat([voltage_levels, vl_extra]) - - all_buses_vn_kv = voltage_levels.loc[bus_df["voltage_level_id"].values]["nominal_v"].values - nb_bus_per_vl = bus_df[["voltage_level_id", "name"]].groupby("voltage_level_id").count() - - if buses_for_sub is not None and buses_for_sub: - # I am in a compatibility mode, - # the "substation" in lightsim2grid will be read - # from the buses in the original grid (and not from the - # voltage levels) - if n_busbar_per_sub is None: - # setting automatically n_busbar_per_sub - # to 1 - # TODO logger here - n_busbar_per_sub = 1 - - all_buses_vn_kv = voltage_levels.loc[bus_df["voltage_level_id"], "nominal_v"].values - all_buses_vmin_kv = voltage_levels.loc[bus_df["voltage_level_id"], "low_voltage_limit"].values - all_buses_vmax_kv = voltage_levels.loc[bus_df["voltage_level_id"], "high_voltage_limit"].values - if n_busbar_per_sub > 1: - all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) - all_buses_vmin_kv = np.concatenate([all_buses_vmin_kv for _ in range(n_busbar_per_sub)]) - all_buses_vmax_kv = np.concatenate([all_buses_vmax_kv for _ in range(n_busbar_per_sub)]) - n_sub_ls = bus_df.shape[0] - ls_to_orig = np.zeros(all_buses_vn_kv.shape[0], dtype=int) - 1 - ls_to_orig[:n_sub_ls] = np.arange(n_sub_ls) - n_busbar_per_sub_ls = n_busbar_per_sub - bus_df["bus_global_id"] = np.arange(n_sub_ls) - bus_df["glop_sub_id"] = np.arange(n_sub_ls) # np.concatenate([np.arange(n_sub_ls) for _ in range(n_busbar_per_sub)]) - sub_names = bus_df.index.values.astype(str) - voltage_levels["vl_id"] = bus_df[["voltage_level_id", "bus_global_id"]].groupby("voltage_level_id").min() - first_bus_per_vl = None - else: - # the "substation" in lightsim2grid - voltage_levels["nb_bus_per_vl"] = nb_bus_per_vl["name"] - bus_df["name"] = [[el] for el in bus_df.index] - voltage_levels["bus_names"] = bus_df[["name", "voltage_level_id"]].groupby("voltage_level_id").sum() - - bus_df["local_id"] = [voltage_levels.loc[el, "bus_names"].index(id_) + 1 - for id_, el in zip(bus_df.index, - bus_df["voltage_level_id"].values)] - n_vl = voltage_levels.shape[0] - voltage_levels["vl_id"] = np.arange(n_vl) - bus_df["bus_global_id"] = [(loc_id - 1) * n_vl + voltage_levels.loc[vl, "vl_id"] - for loc_id, vl in zip( - bus_df["local_id"], - bus_df["voltage_level_id"] - )] - nb_bus_per_vl_in_grid = nb_bus_per_vl.values.max() - if n_busbar_per_sub is None: - # setting automatically n_busbar_per_sub - # to the value read from the grid - # TODO logger here - n_busbar_per_sub = int(nb_bus_per_vl_in_grid) - elif n_busbar_per_sub < nb_bus_per_vl_in_grid: - raise RuntimeError(f"The input pypowsybl grid counts some voltage levels " - f"with {nb_bus_per_vl_in_grid} independant buses, " - f"which is not compatible with the n_busbar_per_sub={n_busbar_per_sub} " - "given as input.") - all_buses_vn_kv = voltage_levels["nominal_v"].values - all_buses_vmin_kv = voltage_levels["low_voltage_limit"].values - all_buses_vmax_kv = voltage_levels["high_voltage_limit"].values - if n_busbar_per_sub > 1: - all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) - all_buses_vmin_kv = np.concatenate([all_buses_vmin_kv for _ in range(n_busbar_per_sub)]) - all_buses_vmax_kv = np.concatenate([all_buses_vmax_kv for _ in range(n_busbar_per_sub)]) - n_sub_ls = voltage_levels.shape[0] - voltage_levels["glop_sub_id"] = np.arange(voltage_levels.shape[0]) - n_busbar_per_sub_ls = n_busbar_per_sub - ls_to_orig = np.zeros(all_buses_vn_kv.shape[0], dtype=int) - 1 - ls_to_orig[bus_df["bus_global_id"].values] = np.arange(bus_df.shape[0]) - sub_names = voltage_levels.index.values.astype(str) - bus_df["glop_sub_id"] = voltage_levels.loc[bus_df["voltage_level_id"].values, "glop_sub_id"].values - - # retrieve the "first bus of every substation" - # this is used to connected reconnected element - first_bus_per_vl = bus_df[["glop_sub_id", "voltage_level_id", "name"]].groupby("glop_sub_id").first().reset_index().set_index("voltage_level_id") - first_bus_per_vl["first_bus_name"] = [el[0] for el in first_bus_per_vl["name"].values] - first_bus_per_vl.drop(["glop_sub_id", "name"], axis=1, inplace=True) - - # make sure every voltage level as a "first bus" - # I would raise an error in the case a voltage is fully disconnected but... - check_grid_ok = voltage_levels.index.isin(first_bus_per_vl.index) - if np.any(~check_grid_ok): - warnings.warn("There are some voltage levels without any connected buses. " - f"Check voltage levels {voltage_levels[~check_grid_ok].index}") - # name_added_bus = voltage_levels[~check_grid_ok].index - nm_vl_without_bus = voltage_levels.loc[~check_grid_ok, ["vl_id", "glop_sub_id"]] - to_add = voltage_levels.loc[~check_grid_ok, ["vl_id", "glop_sub_id"]].copy() - to_add.index = to_add.index.astype(str) + "added_bus" - to_add["name"] = [[el] for el in to_add.index.astype(str)] - to_add["v_mag"] = np.nan - to_add["v_angle"] = np.nan - to_add["connected_component"] = 99999 - to_add["synchronous_component"] = 99999 - to_add["voltage_level_id"] = nm_vl_without_bus.index - to_add["orig_id"] = 9999 - to_add["local_id"] = 1 - to_add["bus_global_id"] = to_add["glop_sub_id"] - for added_bus_nm in to_add.index: - bus_df.loc[added_bus_nm] = to_add.loc[added_bus_nm] - for vl_bus_added in nm_vl_without_bus.index: - first_bus_per_vl.loc[vl_bus_added, "first_bus_name"] = vl_bus_added + "added_bus" - - # all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) - model.init_bus(n_sub_ls, - n_busbar_per_sub_ls, - all_buses_vn_kv, - 0, 0 # unused - ) - model._ls_to_orig = ls_to_orig - model._max_nb_bus_per_sub = n_busbar_per_sub_ls - # relevant kwargs downstream consumers (eg a pypowsybl-shaped result view) need to - # recover conversion-time settings that are otherwise plain Python arguments lost - # after this function returns -- see LSGrid._init_kwargs. - model._init_kwargs = {"sort_index": str(sort_index), "buses_for_sub": str(buses_for_sub)} - model.set_substation_names(sub_names) - model.set_bus_voltage_limits(all_buses_vmin_kv.astype(float), all_buses_vmax_kv.astype(float)) - - # fuse the two terminal buses of (near-)zero-impedance lines / neutral-tap - # transformers, mirroring OpenLoadFlow's `lowImpedanceBranchMode`. Must run - # before any `_aux_get_bus` call (generators, just below) so every element - # type transparently picks up the fused `bus_global_id` for free -- see - # `_aux_get_bus`'s single choke point at `bus_df["bus_global_id"]`. - fused_line_ids = set() - fused_trafo_ids = set() - if fuse_zero_impedance_branches: - net_pu = _aux_ensure_net_pu(net, net_pu) - parent = np.arange(all_buses_vn_kv.shape[0]) - - def _uf_find(x): - while parent[x] != x: - x = parent[x] - return x - - def _uf_union(a, b): - ra, rb = _uf_find(a), _uf_find(b) - if ra != rb: - parent[max(ra, rb)] = min(ra, rb) # deterministic: smaller id wins - - # -- zero-impedance LINES -- - df_line_fuse = net.get_lines() - if sort_index: - df_line_fuse = df_line_fuse.sort_index() - df_line_fuse_pu = net_pu.get_lines().loc[df_line_fuse.index] - both_conn = (df_line_fuse["connected1"].values & df_line_fuse["connected2"].values) - is_zero = ((np.abs(df_line_fuse_pu["r"].values) < zero_impedance_threshold_pu) - & (np.abs(df_line_fuse_pu["x"].values) < zero_impedance_threshold_pu)) - line_candidate = both_conn & is_zero - if line_candidate.any(): - cand = df_line_fuse[line_candidate] - vn1 = voltage_levels.loc[cand["voltage_level1_id"].values, "nominal_v"].values - vn2 = voltage_levels.loc[cand["voltage_level2_id"].values, "nominal_v"].values - bad = ~np.isclose(vn1, vn2) - if bad.any(): - raise RuntimeError( - f"Zero-impedance line(s) {list(cand.index[bad])} connect two buses at " - "different nominal voltages: this is inconsistent grid data, refusing " - "to fuse them (fuse_zero_impedance_branches=True)." - ) - gid1 = bus_df.loc[cand["bus1_id"].values, "bus_global_id"].values - gid2 = bus_df.loc[cand["bus2_id"].values, "bus_global_id"].values - for a, b in zip(gid1, gid2): - _uf_union(int(a), int(b)) - fused_line_ids.update(cand.index) - - # -- zero-impedance, neutral-tap, SAME-NOMINAL-VOLTAGE TRANSFORMERS -- - # NB: pypowsybl's per-unit `rho` is the deviation from the transformer's OWN - # rated_u1/rated_u2 ratio (tap-changer effect), not the absolute turns ratio: - # a genuine step-down transformer (eg rated_u1=225kV/rated_u2=90kV) reports - # rho=1 at neutral tap even though it performs a real voltage transformation - # (implicit in the differing per-unit bus voltage bases on each side, not in - # `rho` itself). So `rho~=1` alone only means "no ADDITIONAL tap deviation" -- - # it does NOT mean "no transformation at all". A transformer is only a true, - # fusable ideal wire when it is ALSO between two buses of the same nominal - # voltage (same requirement as for lines, just not an error here since most - # real transformers legitimately span different nominal voltages). - df_trafo_fuse = _aux_get_2wt_all_attrs(net) - if sort_index: - df_trafo_fuse = df_trafo_fuse.sort_index() - if df_trafo_fuse.shape[0] > 0: - df_trafo_fuse_pu = _aux_get_2wt_all_attrs(net_pu).loc[df_trafo_fuse.index] - ratio_tap_changer_fuse = net_pu.get_ratio_tap_changers() - alpha_fuse = _aux_trafo_alpha(df_trafo_fuse_pu, net) - rho_fuse = _aux_trafo_rho(df_trafo_fuse_pu, ratio_tap_changer_fuse) - both_conn_t = (df_trafo_fuse["connected1"].values & df_trafo_fuse["connected2"].values) - is_zero_t = ((np.abs(df_trafo_fuse_pu["r"].values) < zero_impedance_threshold_pu) - & (np.abs(df_trafo_fuse_pu["x"].values) < zero_impedance_threshold_pu)) - is_ideal_t = (np.abs(rho_fuse - 1.) < 1e-9) & (np.abs(alpha_fuse) < 1e-9) - vn1_t = voltage_levels.loc[df_trafo_fuse["voltage_level1_id"].values, "nominal_v"].values - vn2_t = voltage_levels.loc[df_trafo_fuse["voltage_level2_id"].values, "nominal_v"].values - same_vn_t = np.isclose(vn1_t, vn2_t) - trafo_candidate = both_conn_t & is_zero_t & is_ideal_t & same_vn_t - if trafo_candidate.any(): - cand_t = df_trafo_fuse[trafo_candidate] - gid1 = bus_df.loc[cand_t["bus1_id"].values, "bus_global_id"].values - gid2 = bus_df.loc[cand_t["bus2_id"].values, "bus_global_id"].values - for a, b in zip(gid1, gid2): - _uf_union(int(a), int(b)) - fused_trafo_ids.update(cand_t.index) - - rep = np.array([_uf_find(i) for i in range(parent.shape[0])]) - bus_df["bus_global_id"] = rep[bus_df["bus_global_id"].values] - # a fused-away ("loser") bus keeps its own row in the lightsim2grid model - # (fixed-size bus containers) but loses every element to its representative, - # so it ends up disconnected with no solved voltage of its own. Stash the - # representative lookup so a downstream result view (eg - # `LightsimResultNetwork`) can report that bus's voltage as its - # representative's -- the two are electrically the same node -- instead of - # the disconnected bus's stale/zero value. See `LSGrid._bus_fusion_rep`. - model._bus_fusion_rep = rep.astype(int) - if fused_line_ids or fused_trafo_ids: - # ids of the fused-away branches themselves, so a downstream result view - # (eg `LightsimResultNetwork`) can tell "deactivated because fused" apart - # from "deactivated because genuinely out of service" and, where the - # physics allow it, reconstruct the flow through it (see - # `LightsimResultNetwork._reconstruct_fused_branches`). "\x1f" (ASCII unit - # separator) is used as delimiter: pypowsybl element ids never contain it. - kwargs = dict(model._init_kwargs) - kwargs["fused_line_ids"] = "\x1f".join(sorted(str(i) for i in fused_line_ids)) - kwargs["fused_trafo_ids"] = "\x1f".join(sorted(str(i) for i in fused_trafo_ids)) - model._init_kwargs = kwargs - - # do the generators - gen_attrs = [ - "connected", "max_p", "target_p", "target_v", "target_q", "p", - "voltage_regulator_on", "regulated_element_id", "voltage_level_id", "bus_id", - "min_q", "max_q", "min_q_at_target_p", "max_q_at_target_p", - ] - if sort_index: - df_gen = net.get_generators(attributes=gen_attrs).sort_index() - else: - df_gen = net.get_generators(attributes=gen_attrs) - - # to handle encoding in 32 bits and overflow when "splitting" the Q values among - min_float_value = np.finfo(np.float32).min * 1e-4 + 1. - max_float_value = np.finfo(np.float32).max * 1e-4 + 1. - # "min_q"/"max_q" (the flat MIN_MAX box) are NaN for a generator whose - # reactive_limits_kind is CURVE -- pypowsybl only populates those through - # "min_q_at_target_p"/"max_q_at_target_p" (the capability curve evaluated at the - # generator's own target P, available even before any loadflow has been run, - # unlike "min_q_at_p" which depends on a solved "p"). Without this, every - # CURVE-kind generator silently got the "no limit" float32 sentinel below - # regardless of its real reactive range. - min_q_src = df_gen["min_q_at_target_p"].where(df_gen["min_q_at_target_p"].notna(), df_gen["min_q"]) - max_q_src = df_gen["max_q_at_target_p"].where(df_gen["max_q_at_target_p"].notna(), df_gen["max_q"]) - min_q_aux = 1. * min_q_src.values - max_q_aux = 1. * max_q_src.values - # malformed source curve data (eg a reactive capability curve point entered with - # min_q/max_q swapped) can make the "at target p" interpolation yield min_q > max_q. - # OpenLoadFlow tolerates this silently; lightsim2grid's GeneratorContainer::init - # hard-rejects it (real case found on a real grid snapshot). Restore a valid - # interval by sorting the pair instead of crashing -- this only ever affects the - # (already tiny) width of the interval, never which generators get a reactive - # constraint at all. - swapped = min_q_aux > max_q_aux - if swapped.any(): - min_q_aux[swapped], max_q_aux[swapped] = max_q_aux[swapped], min_q_aux[swapped].copy() - too_small = min_q_aux < min_float_value - min_q_aux[too_small] = min_float_value - min_q = min_q_aux.astype(np.float32) - - too_big = np.abs(max_q_aux) > max_float_value - max_q_aux[too_big] = np.sign(max_q_aux[too_big]) * max_float_value - max_q = max_q_aux.astype(np.float32) - min_q[~np.isfinite(min_q)] = min_float_value - max_q[~np.isfinite(max_q)] = max_float_value - gen_bus, gen_disco, gen_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "gen", df_gen) - - # remote voltage control: a generator may regulate the voltage of a *different* - # bus, identified by `regulated_element_id` (its own id when controlling locally). - # Resolve that element to the bus-view id of its terminal, then to the regulated - # voltage level (used for the target_v -> pu conversion) and, further down, to the - # lightsim2grid global bus id threaded into the C++ container. - bus_reg = copy.deepcopy(df_gen["regulated_element_id"].values) - # for oldest pypowsybl version, we could have "" there - bus_reg = np.where(bus_reg == "", df_gen.index, bus_reg) - vl_reg = copy.deepcopy(df_gen["voltage_level_id"].values) - # a disconnected generator is deactivated below regardless of its (possibly - # itself disconnected / unresolvable) regulated element, so exclude it here: - # otherwise a disconnected generator remotely "regulating" a disconnected - # busbar section (empty bus-view id) crashes the (moot, since deactivated) - # bus resolution below. - mask_remote_gen = (bus_reg != df_gen.index.values) & ~gen_disco - gen_reg_bus_view = None - if mask_remote_gen.any(): - remote_idx = np.nonzero(mask_remote_gen)[0] - gen_reg_bus_view = _aux_regulated_bus_view_ids(net, bus_reg[mask_remote_gen]) - # a *connected* generator can still remotely regulate a busbar section that is - # itself disconnected (found on a real grid snapshot: a de-energized - # voltage level). pypowsybl's bus-view id for a disconnected element is '', - # not NaN, and can't be resolved to any bus_df row. OLF converges fine on such - # grids, so it must fall back to local voltage control in this situation; - # mirror that instead of crashing. - unresolved = gen_reg_bus_view == "" - if unresolved.any(): - mask_remote_gen[remote_idx[unresolved]] = False - gen_reg_bus_view = gen_reg_bus_view[~unresolved] - vl_reg[mask_remote_gen] = bus_df.loc[gen_reg_bus_view, "voltage_level_id"].values - model.init_generators_full(df_gen["target_p"].values, - # df_gen["target_v"].values / voltage_levels.loc[df_gen["voltage_level_id"].values]["nominal_v"].values, - df_gen["target_v"].values / voltage_levels.loc[vl_reg]["nominal_v"].values, - df_gen["target_q"].values, - df_gen["voltage_regulator_on"].values, - min_q, - max_q, - gen_bus - ) - for gen_id, is_disco in enumerate(gen_disco): - if is_disco: - model.deactivate_gen(gen_id) - model.set_gen_names(df_gen.index) - - # thread the regulated bus to the C++ generator container. Local generators keep - # their own bus (already the C++ default), so a grid without any remote control - # stays byte-identical to before this feature. - # TODO: the regulated bus is resolved once, here, from the pypowsybl grid. If the - # regulated element later changes bus inside lightsim2grid, this stays frozen and - # the two grids desynchronise (see `_aux_regulated_bus_view_ids` warning). - if mask_remote_gen.any(): - gen_reg_bus_global = bus_df.loc[gen_reg_bus_view, "bus_global_id"].values - for gen_id, reg_bus in zip(np.nonzero(mask_remote_gen)[0], gen_reg_bus_global): - model.set_gen_regulated_bus(int(gen_id), int(reg_bus)) - - # for loads - if sort_index: - df_load = net.get_loads().sort_index() - else: - df_load = net.get_loads() - if df_dl.shape[0] > 0: - # equivalent constant-power load at each dangling line's boundary bus - # (see `_aux_dangling_lines_fictitious`); p0/q0 are already in MW/MVAr, - # same raw convention as `net.get_loads()`. - df_load_extra = pd.DataFrame({ - "p0": df_dl["p0"].values, - "q0": df_dl["q0"].values, - "bus_id": df_dl["boundary_bus_id"].values, - "connected": True, - "voltage_level_id": df_dl["boundary_vl_id"].values, - }, index=[f"{dl_id}@boundary_load" for dl_id in df_dl.index]) - df_load = pd.concat([df_load, df_load_extra]) - load_bus, load_disco, load_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "load", df_load) - model.init_loads(df_load["p0"].values, - df_load["q0"].values, - load_bus - ) - for load_id, is_disco in enumerate(load_disco): - if is_disco: - model.deactivate_load(load_id) - model.set_load_names(df_load.index) - - # thermal (current) limits for lines / trafos, see `_aux_current_limits` - try: - ol_current = net.get_operational_limits() - ol_current = ol_current[ol_current.index.get_level_values("type") == "CURRENT"] - except Exception: # noqa: BLE001 - not available on legacy pypowsybl - ol_current = None - - # for lines - if sort_index: - df_line = net.get_lines().sort_index() - else: - df_line = net.get_lines() - try: - line_limit_groups = net.get_lines(all_attributes=True)[["selected_limits_group_1", "selected_limits_group_2"]] - except (TypeError, KeyError): - # not available on legacy pypowsybl / grid with no limit group at all - line_limit_groups = pd.DataFrame( - {"selected_limits_group_1": np.nan, "selected_limits_group_2": np.nan}, index=df_line.index - ) - - # per unit - net_pu = _aux_ensure_net_pu(net, net_pu) - df_line_pu = net_pu.get_lines().loc[df_line.index] - if df_dl.shape[0] > 0: - # equivalent branch (local bus -> fictitious boundary bus) for each - # dangling line, see `_aux_dangling_lines_fictitious`. Shunt admittance - # (g, b) sits entirely on the local (network) side, none on the - # boundary side -- same convention already used for the single `h` of - # a transformer (see `trafo_h` below). - df_dl_pu = net_pu.get_dangling_lines().loc[df_dl.index] - line_ids_extra = [f"{dl_id}{_DANGLING_BOUNDARY_LINE_SUFFIX}" for dl_id in df_dl.index] - df_line_extra = pd.DataFrame({ - "bus1_id": df_dl["bus_id"].values, - "bus2_id": df_dl["boundary_bus_id"].values, - "connected1": df_dl["connected"].values, - "connected2": True, - "voltage_level1_id": df_dl["voltage_level_id"].values, - "voltage_level2_id": df_dl["boundary_vl_id"].values, - }, index=line_ids_extra) - df_line_extra_pu = pd.DataFrame({ - "r": df_dl_pu["r"].values, - "x": df_dl_pu["x"].values, - "g1": df_dl_pu["g"].values, - "b1": df_dl_pu["b"].values, - "g2": 0., - "b2": 0., - }, index=line_ids_extra) - df_line = pd.concat([df_line, df_line_extra]) - df_line_pu = pd.concat([df_line_pu, df_line_extra_pu]) - line_r = df_line_pu["r"].values - line_x = df_line_pu["x"].values - line_h_or = (df_line_pu["g1"].values + 1j * df_line_pu["b1"].values) - line_h_ex = (df_line_pu["g2"].values + 1j * df_line_pu["b2"].values) - lor_bus, lor_disco, lor_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "line (side 1)", df_line, conn_key="connected1", bus_key="bus1_id", vl_key="voltage_level1_id") - lex_bus, lex_disco, lex_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "line (side 2)", df_line, conn_key="connected2", bus_key="bus2_id", vl_key="voltage_level2_id") - model.init_powerlines_full(line_r, - line_x, - line_h_or, - line_h_ex, - lor_bus, - lex_bus - ) - for line_id, (is_or_disc, is_ex_disc) in enumerate(zip(lor_disco, lex_disco)): - if is_or_disc and is_ex_disc: - model.deactivate_powerline(line_id) - elif is_or_disc: - model.deactivate_powerline_side1(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) - elif is_ex_disc: - model.deactivate_powerline_side2(line_id) if keep_half_open_lines else model.deactivate_powerline(line_id) - elif fuse_zero_impedance_branches and df_line.index[line_id] in fused_line_ids: - # both terminal buses already fused into one node above: this line - # would otherwise contribute a 1/Z admittance (Inf for an exact zero) - model.deactivate_powerline(line_id) - model.set_line_names(df_line.index) - line_limit_a1_ka, line_limit_a2_ka = _aux_current_limits( - df_line.index, - line_limit_groups["selected_limits_group_1"], - line_limit_groups["selected_limits_group_2"], - ol_current, - ) - model.set_line_current_limit_side1(line_limit_a1_ka) - model.set_line_current_limit_side2(line_limit_a2_ka) - - # for trafo - # I extract trafo with `all_attributes=True` so that I have access to the `rho` - df_trafo_not_sorted = _aux_get_2wt_all_attrs(net) - - if sort_index: - df_trafo = df_trafo_not_sorted.sort_index() - else: - df_trafo = df_trafo_not_sorted - - df_trafo_pu = _aux_get_2wt_all_attrs(net_pu) - df_trafo_pu = df_trafo_pu.loc[df_trafo.index] - ratio_tap_changer = net_pu.get_ratio_tap_changers() - - shift_ = _aux_trafo_alpha(df_trafo_pu, net) - # tap is side 2 in IIDM - is_tap_side1 = np.zeros(df_trafo.shape[0], dtype=bool) - # neutral-tap impedance (the phase-shift -> r/x dependence of phase-shifting - # transformers is handled by lightsim2grid as a function of the shift alpha, see - # the model.set_trafo_shift_dependent_rx(...) call below). - trafo_r = df_trafo_pu["r"].values - trafo_x = df_trafo_pu["x"].values - trafo_h = (df_trafo_pu["g"].values + 1j * df_trafo_pu["b"].values) - - # now get the ratio - # in lightsim2grid (cpp) - ratio = _aux_trafo_rho(df_trafo_pu, ratio_tap_changer) - - tor_bus, tor_disco, tor_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 1)", df_trafo, conn_key="connected1", bus_key="bus1_id", vl_key="voltage_level1_id") - tex_bus, tex_disco, tex_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "trafo (side 2)", df_trafo, conn_key="connected2", bus_key="bus2_id", vl_key="voltage_level2_id") - model.init_trafo(trafo_r, - trafo_x, - trafo_h, - ratio, - shift_, # in degree ! - is_tap_side1, - tor_bus, - tex_bus, - False, # ignore_tap_side_for_phase_shift is False for pypowsybl - ) - for t_id, (is_or_disc, is_ex_disc) in enumerate(zip(tor_disco, tex_disco)): - if is_or_disc and is_ex_disc: - model.deactivate_trafo(t_id) - elif is_or_disc: - model.deactivate_trafo_side1(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) - elif is_ex_disc: - model.deactivate_trafo_side2(t_id) if keep_half_open_lines else model.deactivate_trafo(t_id) - elif fuse_zero_impedance_branches and df_trafo.index[t_id] in fused_trafo_ids: - # both terminal buses already fused into one node above - model.deactivate_trafo(t_id) - model.set_trafo_names(df_trafo.index) - if "selected_limits_group_1" in df_trafo.columns: - trafo_group_1 = df_trafo["selected_limits_group_1"] - trafo_group_2 = df_trafo["selected_limits_group_2"] - else: - # not available on legacy pypowsybl - trafo_group_1 = pd.Series(np.nan, index=df_trafo.index) - trafo_group_2 = pd.Series(np.nan, index=df_trafo.index) - trafo_limit_a1_ka, trafo_limit_a2_ka = _aux_current_limits( - df_trafo.index, trafo_group_1, trafo_group_2, ol_current - ) - model.set_trafo_current_limit_side1(trafo_limit_a1_ka) - model.set_trafo_current_limit_side2(trafo_limit_a2_ka) - # phase-shifting transformers: declare the (alpha -> r/x correction) dependency so - # lightsim2grid keeps the series impedance right when the shift changes, without any - # "tap" concept (the per-step r/x deltas pypowsybl carries only in its tap steps). - ps_alpha, ps_rx_corr = _aux_phase_shift_rx_tables(df_trafo.index, net) - if any(len(a) for a in ps_alpha): - model.set_trafo_shift_dependent_rx(True, ps_alpha, ps_rx_corr) - - # for shunt - if sort_index: - df_shunt = net.get_shunt_compensators().sort_index() - else: - df_shunt = net.get_shunt_compensators() - - sh_bus, sh_disco, sh_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "shunts", df_shunt) - shunt_kv = voltage_levels.loc[df_shunt["voltage_level_id"].values]["nominal_v"].values - model.init_shunt(df_shunt["g"].values * shunt_kv**2, - -df_shunt["b"].values * shunt_kv**2, - sh_bus - ) - for shunt_id, disco in enumerate(sh_disco): - if disco: - model.deactivate_shunt(shunt_id) - model.set_shunt_names(df_shunt.index) - - # for Static Var Compensators (SVC): VOLTAGE (local/remote, optional slope), - # REACTIVE_POWER (fixed Q) or OFF, all solved through the bordered - # VoltageControl NR extension. A grid with no SVC declares no controller and - # stays byte-identical to before this feature. - if sort_index: - df_svc = net.get_static_var_compensators().sort_index() - else: - df_svc = net.get_static_var_compensators() - nb_svc = df_svc.shape[0] - svc_bus, svc_disco, svc_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "svc", df_svc) - - # SvcContainer.RegulationMode: OFF=0, VOLTAGE=1, REACTIVE_POWER=2 - OFF_MODE, VOLTAGE_MODE, REACTIVE_POWER_MODE = 0, 1, 2 - svc_mode = np.zeros(nb_svc, dtype=int) - svc_reg_bus = svc_bus.copy() # regulated bus (own bus unless remote) - svc_reg_vn = np.ones(nb_svc) # nominal v (kV) of the regulated bus - svc_slope_pu = np.zeros(nb_svc) - svc_target_q_inject = np.zeros(nb_svc) - if nb_svc: - mode_str = df_svc["regulation_mode"].values.astype(str) - if "regulating" in df_svc.columns: - regulating = df_svc["regulating"].values.astype(bool) - else: - # legacy pypowsybl: "OFF" is encoded directly in the regulation mode - regulating = np.ones(nb_svc, dtype=bool) - svc_mode[(mode_str == "VOLTAGE") & regulating] = VOLTAGE_MODE - svc_mode[(mode_str == "REACTIVE_POWER") & regulating] = REACTIVE_POWER_MODE - - # resolve the regulated bus, mirroring the generator `regulated_element_id` - # logic above (busbar section in node/breaker grids, or any bus-connected - # element). Local SVCs keep their own bus. - svc_vl = copy.deepcopy(df_svc["voltage_level_id"].values) - if "regulated_element_id" in df_svc.columns: - reg_id = copy.deepcopy(df_svc["regulated_element_id"].values) - reg_id = np.where(reg_id == "", df_svc.index, reg_id) - mask_svc_remote = reg_id != df_svc.index.values - if mask_svc_remote.any(): - # TODO: resolved once at import; if the regulated element later changes - # bus inside lightsim2grid this stays frozen and desynchronises from the - # original grid (see `_aux_regulated_bus_view_ids` warning). - remote_svc_idx = np.nonzero(mask_svc_remote)[0] - svc_reg_bus_view = _aux_regulated_bus_view_ids(net, reg_id[mask_svc_remote]) - # same disconnected-remote-target situation as for generators above: - # fall back to local control rather than crashing on an unresolvable - # (disconnected) regulated element. - unresolved_svc = svc_reg_bus_view == "" - if unresolved_svc.any(): - mask_svc_remote[remote_svc_idx[unresolved_svc]] = False - svc_reg_bus_view = svc_reg_bus_view[~unresolved_svc] - svc_reg_bus[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "bus_global_id"].values - svc_vl[mask_svc_remote] = bus_df.loc[svc_reg_bus_view, "voltage_level_id"].values - svc_reg_vn = voltage_levels.loc[svc_vl, "nominal_v"].values - - # IIDM gives the SVC reactive setpoint in the receptor (load) convention, - # whereas lightsim2grid stamps Q with the generator-injection convention - # (Phase 0 probe: SVC target_q=+30 absorbs 30 MVar) -> negate. - target_q = df_svc["target_q"].values.astype(float) - target_q = np.where(np.isfinite(target_q), target_q, 0.) - svc_target_q_inject = -target_q - - # optional voltage/reactive-power slope ("droop"), in kV/MVar: - # s_pu = slope[kV/MVar] * sn_mva / vn_kv(regulated bus) (Phase 0 probe #1) - # Read from `net` (not `net_pu`): pypowsybl's per-unit view (native - # `per_unit=True` and the legacy `PerUnitView`) does not per-unit this - # extension at all -- `slope` comes back numerically identical whether - # or not `per_unit` is set (checked empirically) -- so there is no - # `net_pu` value to defer to here; the conversion has to be done by hand. - try: - df_slope = net.get_extensions("voltagePerReactivePowerControl") - except Exception: - # extension tables may be unavailable on (very) old pypowsybl versions - df_slope = None - if df_slope is not None and df_slope.shape[0]: - for svc_pos, svc_id in enumerate(df_svc.index): - if svc_id in df_slope.index: - slope_kv_per_mvar = float(df_slope.loc[svc_id, "slope"]) - svc_slope_pu[svc_pos] = slope_kv_per_mvar * sn_mva_used / svc_reg_vn[svc_pos] - - if nb_svc: - target_v = df_svc["target_v"].values.astype(float) - # target_v (kV) -> pu at the regulated bus; NaN (REACTIVE_POWER / OFF) -> 1 pu - target_vm_pu = np.where(np.isfinite(target_v), target_v, svc_reg_vn) / svc_reg_vn - # pypowsybl/IIDM gives b_min/b_max in SIEMENS (physical susceptance), while - # lightsim2grid's SvcContainer expects them in per unit (base sn_mva, at the - # regulated bus's nominal voltage -- same base as target_vm_pu/slope above). - # Without this conversion the SVC's modeled Q range is smaller than its real one - # by a factor of (nominal_v_kv)^2 / sn_mva (eg ~500x on a 225kV/100MVA bus), - # making a perfectly healthy SVC collapse to a near-zero Q range: it saturates - # (or, since check_solution never enforces SVC Q limits, silently hits a "hard" - # voltage pin at its own target_vm_pu regardless of what Q that would truly take) - # long before the real device would. - # This CANNOT be replaced by reading `net_pu.get_static_var_compensators()` - # instead: checked empirically that pypowsybl's per-unit view (native - # `per_unit=True` and the legacy `PerUnitView`) leaves `b_min`/`b_max` - # in SIEMENS even under `per_unit=True` -- it only per-units `target_v` - # (and, generically, elements it fully models) -- so relying on it here - # would silently reintroduce this exact bug. - b_min = df_svc["b_min"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used - b_max = df_svc["b_max"].values.astype(float) * (svc_reg_vn ** 2) / sn_mva_used - else: - target_vm_pu = np.zeros(0) - b_min = np.zeros(0) - b_max = np.zeros(0) - - model.init_svcs([int(m) for m in svc_mode], - target_vm_pu, - svc_target_q_inject, - svc_slope_pu, - b_min, - b_max, - svc_reg_bus.astype(np.int32), - svc_bus.astype(np.int32)) - for svc_id, disco in enumerate(svc_disco): - if disco: - model.deactivate_svc(svc_id) - model.set_svc_names(df_svc.index) - - # for hvdc: vsc / lcc converter stations + hvdc lines, possibly carrying - # the angle-droop ("AC emulation") extension - if sort_index: - df_dc = net.get_hvdc_lines().sort_index() - else: - df_dc = net.get_hvdc_lines() - df_vsc = net.get_vsc_converter_stations() - df_lcc = net.get_lcc_converter_stations() - # the vsc / lcc frames have different columns (target_v / power_factor...): - # the concatenation puts NaN where an attribute does not exist for a type - df_stations = pd.concat([df_vsc, df_lcc]) - nb_dc = df_dc.shape[0] - _max_hvdc_mva = 1.0e7 # when pypowsybl exposes NaN limits - - df_station1 = df_stations.loc[df_dc["converter_station1_id"].values] - df_station2 = df_stations.loc[df_dc["converter_station2_id"].values] - hvdc_bus_from_id, hvdc_from_disco, hvdc_sub_from_id = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "hvdc (side 1)", df_station1) - hvdc_bus_to_id, hvdc_to_disco, hvdc_sub_to_id = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "hvdc (side 2)", df_station2) - - def _aux_hvdc_station_data(df_side): - # type: 0 = VSC, 1 = LCC (ConverterStationContainer convention) - is_lcc = df_side.index.isin(df_lcc.index) - types = np.where(is_lcc, 1, 0).astype(int) - loss_factor = df_side["loss_factor"].values / 100. # pypowsybl % -> fraction - loss_factor = np.where(np.isfinite(loss_factor), loss_factor, 0.) - vreg_on = df_side["voltage_regulator_on"].values.astype(bool) if nb_dc else np.zeros(0, dtype=bool) - vreg_on = vreg_on & ~is_lcc # lcc never regulates (NaN -> random bool otherwise) - vl_kv = voltage_levels.loc[df_side["voltage_level_id"].values]["nominal_v"].values - vset_pu = df_side["target_v"].values / vl_kv - vset_pu = np.where(np.isfinite(vset_pu), vset_pu, 1.0) - qset = df_side["target_q"].values - qset = np.where(np.isfinite(qset), qset, 0.) - min_q = df_side["min_q"].values - min_q = np.where(np.isfinite(min_q), min_q, -_max_hvdc_mva) - max_q = df_side["max_q"].values - max_q = np.where(np.isfinite(max_q), max_q, _max_hvdc_mva) - power_factor = df_side["power_factor"].values - power_factor = np.where(np.isfinite(power_factor), power_factor, 1.0) - return types, loss_factor, vreg_on, vset_pu, qset, min_q, max_q, power_factor - - type1, lf1, vreg1, vset1, qset1, min_q1, max_q1, pf1 = _aux_hvdc_station_data(df_station1) - type2, lf2, vreg2, vset2, qset2, min_q2, max_q2, pf2 = _aux_hvdc_station_data(df_station2) - - # 0 = side 1 rectifier, 1 = side 2 rectifier (HvdcLineContainer convention) - converters_mode = np.where(df_dc["converters_mode"].values.astype(str) == "SIDE_1_RECTIFIER_SIDE_2_INVERTER", 0, 1).astype(int) - p_setpoint_mw = df_dc["target_p"].values.astype(float).copy() - if (~np.isfinite(p_setpoint_mw)).any(): - warnings.warn("Some non finite values are found for hvdc target_p, they have been replaced by 0.") - p_setpoint_mw[~np.isfinite(p_setpoint_mw)] = 0. - r_ohm = df_dc["r"].values.astype(float) - nominal_v_kv = df_dc["nominal_v"].values.astype(float) - max_p_mw = df_dc["max_p"].values.astype(float) - max_p_mw = np.where(np.isfinite(max_p_mw), max_p_mw, _max_hvdc_mva) - - # the angle-droop active power control ("AC emulation"), an IIDM extension - droop_enabled = np.zeros(nb_dc, dtype=bool) - droop_p0_mw = np.zeros(nb_dc) - droop_mw_per_deg = np.zeros(nb_dc) - try: - df_droop = net.get_extensions("hvdcAngleDroopActivePowerControl") - except Exception: - # extension tables may be unavailable on (very) old pypowsybl versions - df_droop = None - if df_droop is not None and df_droop.shape[0]: - for hvdc_pos, line_id in enumerate(df_dc.index): - if line_id not in df_droop.index: - continue - if not bool(df_droop.loc[line_id, "enabled"]): - continue - droop_enabled[hvdc_pos] = True - droop_p0_mw[hvdc_pos] = float(df_droop.loc[line_id, "p0"]) - droop_mw_per_deg[hvdc_pos] = float(df_droop.loc[line_id, "droop"]) - - model.init_hvdc_lines(hvdc_bus_from_id.astype(np.int32), - hvdc_bus_to_id.astype(np.int32), - [int(el) for el in type1], - [int(el) for el in type2], - lf1, lf2, - [bool(el) for el in vreg1], - [bool(el) for el in vreg2], - vset1, vset2, - qset1, qset2, - min_q1, max_q1, min_q2, max_q2, - pf1, pf2, - [int(el) for el in converters_mode], - p_setpoint_mw, - r_ohm, - nominal_v_kv, - [bool(el) for el in droop_enabled], - droop_p0_mw, - droop_mw_per_deg, - max_p_mw, # pmax 1 -> 2: IIDM has a single max_p (open-loadflow convention) - max_p_mw, # pmax 2 -> 1 - ) - for hvdc_id, (is_or_disc, is_ex_disc, line_conn1, line_conn2) in enumerate( - zip(hvdc_from_disco, hvdc_to_disco, df_dc["connected1"].values, df_dc["connected2"].values)): - # a converter station with its own terminal open (eg its DC partner is - # switched off, or its whole substation is dead) is NOT a dead branch: real - # VSC stations (and OpenLoadFlow) keep the still-connected converter - # injecting its scheduled P / regulating Q-V as a local device. Only fully - # deactivate when BOTH stations are disconnected. - or_disc = is_or_disc or (not line_conn1) - ex_disc = is_ex_disc or (not line_conn2) - if or_disc and ex_disc: - model.deactivate_dcline(hvdc_id) - elif or_disc: - model.deactivate_dcline_side1(hvdc_id) - elif ex_disc: - model.deactivate_dcline_side2(hvdc_id) - model.set_dcline_names(df_dc.index) - - # storage units (IIDM batteries). IIDM gives the battery setpoints in the - # *generator* convention (positive target_p = power produced / injected) while - # lightsim2grid stores storage as PQ in the *load* convention (positive = power - # drawn from the grid, *ie* charging), same as pandapower and grid2op. We negate - # to convert, and sanitize NaN (IIDM allows an unset target_q). - if sort_index: - df_batt = net.get_batteries().sort_index() - else: - df_batt = net.get_batteries() - batt_bus, batt_disco, batt_sub = _aux_get_bus(voltage_levels, bus_df, first_bus_per_vl, "storage", df_batt) - batt_p = df_batt["target_p"].values.astype(float) - batt_q = df_batt["target_q"].values.astype(float) - batt_p = np.where(np.isfinite(batt_p), batt_p, 0.) - batt_q = np.where(np.isfinite(batt_q), batt_q, 0.) - model.init_storages(-batt_p, # IIDM generator convention -> lightsim2grid load convention - -batt_q, - batt_bus - ) - for batt_id, disco in enumerate(batt_disco): - if disco: - model.deactivate_storage(batt_id) - model.set_storage_names(df_batt.index) - - if gen_slack_id is None and slack_bus_id is None: - # Default: reproduce OpenLoadFlow's distributed slack, sharing the - # active-power mismatch over the participating generators (see - # _default_distributed_slack). Returns None -- handled by the single - # most-connected slack fallback below -- when no participating generator - # is found. - gen_slack_id = _default_distributed_slack(net, df_gen) - - if gen_slack_id is not None: - if slack_bus_id is not None: - raise RuntimeError("You provided both gen_slack_id and slack_bus_id " - "which is not possible.") - if isinstance(gen_slack_id, (str, int, np.int32, np.int64, np.str_, tuple)): - single_slack = True - fun_slack = handle_slack_one_el - else: - single_slack = False - fun_slack = handle_slack_iterable - gen_slack_ids_int, gen_slack_weights = fun_slack(df_gen, gen_slack_id) - if single_slack: - if gen_slack_weights is None: - raise RuntimeError(f"The slack {gen_slack_id} is disconnected.") - gen_slack_ids_int = [gen_slack_ids_int] - gen_slack_weights_fixed = [1.] - else: - gen_slack_weights_fixed = np.asarray([el if el is not None else np.nan for el in gen_slack_weights]) - mask_finite = np.isfinite(gen_slack_weights_fixed) - if not mask_finite.any(): - raise RuntimeError(f"No connected generators match the slack {gen_slack_id}") - gen_slack_weights_fixed[mask_finite] /= gen_slack_weights_fixed[mask_finite].sum() - - for gen_slack_id_int, gen_slack_weight in zip(gen_slack_ids_int, gen_slack_weights_fixed): - if np.isfinite(gen_slack_weight): - model.add_gen_slackbus(gen_slack_id_int, gen_slack_weight) - elif slack_bus_id is not None: - gen_bus = np.array([el.bus_id for el in model.get_generators()]) - gen_is_conn_slack = gen_bus == model._orig_to_ls[slack_bus_id] - nb_conn = gen_is_conn_slack.sum() - if nb_conn == 0: - raise RuntimeError(f"There is no generator connected to bus {slack_bus_id}. It cannot be the slack") - gen_slack_ids_int = [] - for gen_id, is_slack in enumerate(gen_is_conn_slack): - if is_slack: - gen_slack_ids_int.append(gen_id) - model.add_gen_slackbus(gen_id, 1. / nb_conn) - else: - # nothing provided and the default distributed slack found no participating - # generator: fall back to a single slack on the most-connected generator bus - bus_id, gen_id = model.assign_slack_to_most_connected() - gen_slack_ids_int = [gen_id] - - # TODO checks - # no 3windings trafo and other exotic stuff - - # and now deactivate all elements and nodes not in the main component - if only_main_component: - model.consider_only_main_component() - else: - # automatically disconnect non connected buses - # (this is automatically done by consider_only_main_component) - model.init_bus_status() - - gen_sub = pd.DataFrame(index=df_gen.index, data={"sub_id": gen_sub}) - gen_sub["desired_slack"] = False - gen_sub.loc[gen_sub.index[gen_slack_ids_int], "desired_slack"] = True - load_sub = pd.DataFrame(index=df_load.index, data={"sub_id": load_sub}) - lor_sub = pd.DataFrame(index=df_line.index, data={"sub_id": lor_sub}) - lex_sub = pd.DataFrame(index=df_line.index, data={"sub_id": lex_sub}) - tor_sub = pd.DataFrame(index=df_trafo.index, data={"sub_id": tor_sub}) - tex_sub = pd.DataFrame(index=df_trafo.index, data={"sub_id": tex_sub}) - batt_sub = pd.DataFrame(index=df_batt.index, data={"sub_id": batt_sub}) - sh_sub = pd.DataFrame(index=df_shunt.index, data={"sub_id": sh_sub}) - hvdc_sub_from_id = pd.DataFrame(index=df_dc.index, data={"sub_id": hvdc_sub_from_id}) - hvdc_sub_to_id = pd.DataFrame(index=df_dc.index, data={"sub_id": hvdc_sub_to_id}) - - # set the substation ID to which each object belong - model.set_gen_to_subid(gen_sub["sub_id"].values) - model.set_load_to_subid(load_sub["sub_id"].values) - model.set_storage_to_subid(batt_sub["sub_id"].values) - model.set_shunt_to_subid(sh_sub["sub_id"].values) - model.set_line_to_sub1_id(lor_sub["sub_id"].values) - model.set_line_to_sub2_id(lex_sub["sub_id"].values) - model.set_trafo_to_sub1_id(tor_sub["sub_id"].values) - model.set_trafo_to_sub2_id(tex_sub["sub_id"].values) - if not return_sub_id: - return model - else: - return model, (gen_sub, load_sub, (lor_sub, tor_sub), (lex_sub, tex_sub), batt_sub, sh_sub, hvdc_sub_from_id, hvdc_sub_to_id) diff --git a/lightsim2grid/network/from_pypowsybl/_my_const.py b/lightsim2grid/network/from_pypowsybl/_my_const.py new file mode 100644 index 00000000..46b34311 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/_my_const.py @@ -0,0 +1,11 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +# suffix of the synthetic branch lightsim2grid line id for a dangling line's +# equivalent boundary branch (see `_aux_add_buses.py::_aux_dangling_lines_fictitious`) +_DANGLING_BOUNDARY_LINE_SUFFIX = "@boundary_line" diff --git a/lightsim2grid/network/from_pypowsybl/_olf_bake.py b/lightsim2grid/network/from_pypowsybl/_olf_bake.py index 39fb96ed..a4403421 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_bake.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_bake.py @@ -700,7 +700,7 @@ def _bake_remote_voltage_control(network, keep_only_main_comp=True): gen = _keep_only_main_comp(gen, df_bus) if not len(gen): return - # "remote" matches the converter's own test (see _from_pypowsybl.init): a non-empty + # "remote" matches the converter's own test (see _aux_add_generators.py): a non-empty # regulated element that is not the generator's own id. reg = gen["regulated_element_id"].fillna("") remote = gen["voltage_regulator_on"] & gen["connected"] & (reg != "") & (reg != gen.index) diff --git a/lightsim2grid/network/from_pypowsybl/_olf_compare.py b/lightsim2grid/network/from_pypowsybl/_olf_compare.py index 1e2febd9..2c2d8c7e 100644 --- a/lightsim2grid/network/from_pypowsybl/_olf_compare.py +++ b/lightsim2grid/network/from_pypowsybl/_olf_compare.py @@ -41,7 +41,7 @@ import pypowsybl as pp import pypowsybl.loadflow as lf -from ._from_pypowsybl import init as init_from_pypowsybl +from .initLSGrid import init as init_from_pypowsybl from ._olf_bake import bake_outer_loops from ._olf_params import remove_outer_loops diff --git a/lightsim2grid/network/from_pypowsybl/_result_network.py b/lightsim2grid/network/from_pypowsybl/_result_network.py index 9abd41ca..e838d953 100644 --- a/lightsim2grid/network/from_pypowsybl/_result_network.py +++ b/lightsim2grid/network/from_pypowsybl/_result_network.py @@ -15,14 +15,14 @@ analysis code written against a solved pypowsybl network runs unmodified against a solved lightsim2grid one. -Only supports grids built by :func:`_from_pypowsybl.init` (``init_from_pypowsybl``): +Only supports grids built by :func:`initLSGrid.init` (``init_from_pypowsybl``): it relies on that function's invariants -- every non-bus element's ``.name`` set verbatim to its pypowsybl id, and the grid's ``_init_kwargs``/``_orig_to_ls`` properties -- which do not hold for pandapower- or powermodels-built grids. Sign conventions (lightsim2grid vs pypowsybl's post-solve ``p``/``q``, both in the "load convention": positive = power flowing *into* the equipment): -loads, shunts and storage units already match (no flip needed: `_from_pypowsybl` +loads, shunts and storage units already match (no flip needed: `_aux_add_storage.py` feeds storages a load-convention target_p/target_q, negating IIDM's own generator-convention target_p on the way in). Generators and HVDC converter stations use lightsim2grid's generation convention internally (positive = @@ -67,7 +67,7 @@ class LightsimResultNetwork: lines, on the ``LSGrid`` itself): dangling lines -- no ``get_dangling_lines`` method, including when the grid was built with ``init_from_pypowsybl(..., convert_dangling_lines=True)`` -- and - three-winding transformers, which `_from_pypowsybl.init` does not model + three-winding transformers, which `initLSGrid.init` does not model at all (not just unexposed here). **Column provenance**, for every ``get_*`` method's DataFrame: @@ -87,7 +87,7 @@ class LightsimResultNetwork: ``converter_station1_id`` / ``converter_station2_id`` on :meth:`get_hvdc_lines`. - every DataFrame's index (``id``) mirrors the element's pypowsybl id by - construction (`_from_pypowsybl.init` sets every non-bus element's + construction (`initLSGrid.init` sets every non-bus element's lightsim2grid ``name`` verbatim to it, see the module docstring), even though it is technically sourced from ``LSGrid``, not read from ``net``. """ @@ -157,7 +157,7 @@ def _ls_sub_to_vl(self, sub_id: int) -> Optional[str]: id. Since the grid was built with ``buses_for_sub`` not ``True`` (enforced in ``__init__``), a lightsim2grid substation *is* a pypowsybl voltage level, and ``LSGrid.get_substations()[k].name`` is exactly the string - `_from_pypowsybl.init()` set it to (``model.set_substation_names(...)``). + `_aux_add_buses.py` set it to (``model.set_substation_names(...)``). """ if self._sub_names is None: self._sub_names = [s.name for s in self._grid.get_substations()] @@ -189,7 +189,7 @@ def _build_buses(self) -> pd.DataFrame: # `_olf_compare.py::lightsim_bus_to_iidm`. orig_to_ls = np.asarray(grid._orig_to_ls)[:n_bus] - # a bus fused away by `fuse_zero_impedance_branches` (see `_from_pypowsybl.py`) + # a bus fused away by `fuse_zero_impedance_branches` (see `_aux_add_buses.py`) # keeps its own row here (fixed-size bus containers) but lost every element to # its representative, so it is disconnected with no solved voltage of its own # -- redirect it to the representative bus, which is electrically the same node @@ -299,7 +299,7 @@ def _ensure_lines_trafos(self) -> None: def _fused_ids(self): """(fused_line_ids, fused_trafo_ids): ids of the branches `fuse_zero_impedance_branches` fused away when the grid was built (see - `_from_pypowsybl.py`), stashed by that function into `_init_kwargs` as + `_aux_add_buses.py`), stashed by that function into `_init_kwargs` as `"\\x1f"`-joined strings. Empty frozensets for a grid built without fusion (or not through `init_from_pypowsybl` at all). """ @@ -348,7 +348,7 @@ def _bus_balance(self, bus_id, one_sided_topo, two_sided_topo, exclude): def _reconstruct_fused_branches(self, lines_df, trafos_df, fused_line_ids, fused_trafo_ids) -> None: """Recover the flow through a fused (near-)zero-impedance branch (see - `_from_pypowsybl.py`'s `fuse_zero_impedance_branches`) wherever the + `_aux_add_buses.py`'s `fuse_zero_impedance_branches`) wherever the physics make it unambiguous, and patch `lines_df`/`trafos_df` in place. A fused branch is deactivated in the lightsim2grid model (its two @@ -550,7 +550,7 @@ def _build_hvdc(self) -> None: # only stores them as numeric bus references), so they are recovered # here from the original network, keyed by the hvdc line's own `.name` # (== its pypowsybl id). Side 1 pairs with `converter_station1_id` and - # side 2 with `converter_station2_id`: `_from_pypowsybl.init()` builds + # side 2 with `converter_station2_id`: `_aux_add_hvdc.py` builds # its per-side station data from those two columns in that same order. orig_hvdc = self._net.get_hvdc_lines() diff --git a/lightsim2grid/network/from_pypowsybl/initLSGrid.py b/lightsim2grid/network/from_pypowsybl/initLSGrid.py new file mode 100644 index 00000000..59d21974 --- /dev/null +++ b/lightsim2grid/network/from_pypowsybl/initLSGrid.py @@ -0,0 +1,311 @@ +# Copyright (c) 2023-2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Use the pypowsybl converter to properly initialize a LSGrid c++ object. + +``init`` orchestrates one phase per element type, each factored into its own +``_aux_add_*.py`` module (mirroring the ``from_pandapower`` / ``from_powermodels`` +converters' layout): buses/substations first (every other phase depends on its +``voltage_levels``/``bus_df``/``first_bus_per_vl`` output to resolve its own +elements' buses), then generators, loads, lines, transformers, shunts, SVCs, +HVDC lines, storage units, and finally slack assignment. +""" + +from typing import Dict, Iterable, Optional, Union + +import numpy as np +import pandas as pd +import pypowsybl as pypo + +from ...lightsim2grid_cpp import LSGrid # type: ignore + +from ._aux_common import _aux_ensure_net_pu, _aux_get_operational_limits_current +from ._aux_add_buses import _aux_add_buses +from ._aux_add_generators import _aux_add_generators +from ._aux_add_loads import _aux_add_loads +from ._aux_add_lines import _aux_add_lines +from ._aux_add_trafos import _aux_add_trafos +from ._aux_add_shunts import _aux_add_shunts +from ._aux_add_svc import _aux_add_svc +from ._aux_add_hvdc import _aux_add_hvdc +from ._aux_add_storage import _aux_add_storage +from ._aux_add_slack import _aux_add_slack + + +def init(net : pypo.network.Network, + gen_slack_id: Union[int, str, Iterable[str], Dict[str, float]] = None, + slack_bus_id: int = None, + sn_mva : float = 100., # only used if not present in the grid + sort_index : bool =True, + f_hz : float = 50., # unused + net_pu : Optional[pypo.network.Network] = None, + only_main_component : bool =True, + return_sub_id: bool=False, + n_busbar_per_sub: Optional[int]=None, # new in 0.9.1 + buses_for_sub:Optional[bool]=None, # new in 0.9.1 + init_vm_pu:float=1.06, + keep_half_open_lines: bool=False, + convert_dangling_lines: bool=False, + fuse_zero_impedance_branches: bool=False, + zero_impedance_threshold_pu: float=1e-8, + ) -> LSGrid: + """ + This function is available under the `init_from_pypowsybl` in lightsim2grid + + + .. code-block:: python + + from lightsim2grid.network import init_from_pypowsybl + + .. warning:: + It is not available if the `pypowsybl` python package is not installed. + + :param net: The pypowsybl network + :type net: pypo.network.Network + + :param gen_slack_id: The id of the generator that should be used as the slack + (either it's given by id (int) or by name (str)) + :type gen_slack_id: Union[int, str] + + :param slack_bus_id: If you don't provide a generator ID as a slack bus, you can + provide a bus id (int). We do not recommend setting the slack this way. + :type slack_bus_id: int + + :param sn_mva: The nominal apparent power used when converting the grid to + per unit. It is only used if the pypowsybl grid + has no `_nominal_apparent_power` attribute. + **Advanced usage**. + :type sn_mva: float + + :param sort_index: Whether you want to sort the indexes of all the + pypowsybl tables (*eg* get_loads() or *get_buses()*) or not. + Sorting the grid tables is preferable if you want to be + "future proof" and don't want to depend on pandas version + (same order is guaranteed). Not sorting the grid will give + easier comparison of results with pypowsybl. + :type sn_mva: bool + + :param f_hz: Not used currently (frequency of the grid) + :type net_pu: float + + :param net_pu: If you have already converted the grid in "per unit" then + you can pass it as the `net_pu` argument. Otherwise this + function will do it. + **Advanced usage**. + :type net_pu: Optional[pypo.network.Network] + + :param only_main_component: If this is True, then only the main component (*ie* + the one containing the slack bus) will be used. All + equipments not part of this component will be + deactivated (switched-off). **NB** currently + lightsim2grid will diverge if the grid is not connected, + this option might then "hide" some equipements from + the grid (silently) but you have higher chances of + convergence. + :type only_main_component: bool + + :param return_sub_id: **Advanced usage**. If you want to retrieve the id of the + equipments as "tables". Used only for `LightSimBackend` + :type return_sub_id: bool + + :param n_busbar_per_sub: Currently, lightsim2grid works well with a constant + number of independant buses that can be made at each + substations. It can be infered from the grid or + set with this attribute. We recommend to leave it + to `None` (which corresponds to the "infer it from + the grid" behaviour) in most cases. + :type n_busbar_per_sub: Opional[int] + + :param buses_for_sub: Whether the lightsim2grid substation will correspond to buses + of the pypowsybl grid (if buses_for_sub is `True`). + Alternatively, if buses_for_sub is `False`, the + lightsim2grid susbtation will correspond to + pypowsybl voltage level (read from net.get_voltage_levels()). + buses_for_sub==`True` is a "legacy" behaviour. + :type buses_for_sub: bool + + :param init_vm_pu: The voltage magnitude with which the init vector of AC powerflow + will be set. + :type init_vm_pu: float + + :param keep_half_open_lines: If True, a powerline or transformer connected on only + one terminal (``connected1 != connected2``, *eg* a dangling + boundary stub in a real grid) is modeled as "half-open": the + energized side is kept in the admittance matrix and the open end + is Kron-reduced out, instead of deactivating the whole branch. + This sets ``synch_status_both_side=False`` on the returned model, + so a later one-sided topology change is no longer mirrored to the + other side. Branches disconnected on *both* sides are still fully + deactivated. Default False (whole-branch deactivation, as before). + :type keep_half_open_lines: bool + + :param convert_dangling_lines: If True, every IIDM ``DanglingLine`` (*eg* + produced by ``network.reduce_by_ids_and_depths(..., + with_boundary_lines=True)`` when zooming into a + sub-area) is converted to its equivalent branch + + constant-power load: a fictitious 1-bus "substation" at + the boundary end, a line carrying the dangling line's own + r/x/g/b (shunt entirely on the local side, matching + transformers' single ``h``), and a load consuming its + p0/q0. Without this (the default), dangling lines are + silently ignored -- fine for real full grids, which + never have any, but it drops a real boundary injection + (can be hundreds of MW) whenever they do appear. Off by + default to keep existing behaviour unchanged; the + reduce/validate debug scripts turn it on. + :type convert_dangling_lines: bool + + :param fuse_zero_impedance_branches: If True, a line or 2-winding transformer + whose per-unit impedance is (near-)zero -- ``|r_pu| < + zero_impedance_threshold_pu`` and ``|x_pu| < + zero_impedance_threshold_pu`` -- has its two terminal buses + fused into a single electrical node instead of contributing + a ``1/Z`` admittance (which is ``Inf`` for an exact zero, and + breaks the sparse LU factorization outright). This mirrors + OpenLoadFlow's ``lowImpedanceBranchMode`` / + ``lowImpedanceThreshold``. A zero-impedance transformer is + only fused if it is also at (near-)neutral tap (``rho`` close + to 1 and ``alpha`` close to 0) *and* its two sides are at the + same nominal voltage: pypowsybl's per-unit ``rho`` is the + deviation from the transformer's *own* rated ratio (the + tap-changer effect), not its absolute turns ratio, so + ``rho~=1`` alone does not mean "no transformation" for a + genuine step-down/up transformer -- otherwise it is a real + ideal ratio/phase-shifting element, not a same-node short, and + is left untouched. A zero-impedance *line* spanning two + *different* nominal voltages raises a ``RuntimeError`` + (inconsistent grid data) -- unlike for transformers, this is + never legitimate for a line. The fusing branch itself is kept in + the model (for topology-vector bookkeeping) but deactivated, + same as any other disconnected branch; substation/topology + identity (``glop_sub_id``) of elements on the two original + buses is *not* changed, only the internal solver bus id -- + grid2op topology actions still see the original, distinct + substations. Off by default to keep existing behaviour + unchanged. + + .. warning:: + This is a static, import-time decision. If a fused + transformer's tap is later moved away from neutral + during a simulation, the two buses stay fused in + lightsim2grid even though the transformer is no longer + electrically a plain wire. Best suited for static / + diagnostic use, or grid2op environments known not to + actuate the affected transformer's tap. + :type fuse_zero_impedance_branches: bool + + :param zero_impedance_threshold_pu: Per-unit impedance magnitude threshold used + by ``fuse_zero_impedance_branches`` (ignored otherwise). + Matches OpenLoadFlow's ``lowImpedanceThreshold`` default. + :type zero_impedance_threshold_pu: float + + :return: The properly initialized network. + :rtype: :class:`LSGrid` + """ + model = LSGrid() + if hasattr(net, "_nominal_apparent_power"): + sn_mva_used = getattr(net, "_nominal_apparent_power") + else: + sn_mva_used = float(sn_mva) + model.set_sn_mva(sn_mva_used) + model.set_init_vm_pu(float(init_vm_pu)) + if keep_half_open_lines: + # allow branches connected on a single terminal (the open end is Kron-reduced + # in the C++ model); a one-sided disconnection is no longer mirrored to the + # other side. + model.set_synch_status_both_side(False) + + if gen_slack_id is not None and slack_bus_id is not None: + raise RuntimeError("Impossible to intialize a grid with both gen_slack_id and slack_bus_id") + + # buses / substations: every other phase below depends on this phase's + # voltage_levels / bus_df / first_bus_per_vl to resolve its own elements' buses. + voltage_levels, bus_df, first_bus_per_vl, df_dl, net_pu, fused_line_ids, fused_trafo_ids = _aux_add_buses( + model, net, net_pu, sort_index, buses_for_sub, n_busbar_per_sub, + convert_dangling_lines, fuse_zero_impedance_branches, zero_impedance_threshold_pu, + ) + + # generators + df_gen, gen_sub = _aux_add_generators(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl) + + # loads + df_load, load_sub = _aux_add_loads(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl, df_dl) + + # thermal (current) limits for lines / trafos, shared by both phases below + ol_current = _aux_get_operational_limits_current(net) + + # per unit view, shared by both phases below (built once: the bus-fusion phase + # above may already have built it if fuse_zero_impedance_branches=True) + net_pu = _aux_ensure_net_pu(net, net_pu) + + # lines + df_line, lor_sub, lex_sub = _aux_add_lines( + model, net, net_pu, sort_index, voltage_levels, bus_df, first_bus_per_vl, + df_dl, ol_current, keep_half_open_lines, fuse_zero_impedance_branches, fused_line_ids, + ) + + # trafos + df_trafo, tor_sub, tex_sub = _aux_add_trafos( + model, net, net_pu, sort_index, voltage_levels, bus_df, first_bus_per_vl, + ol_current, keep_half_open_lines, fuse_zero_impedance_branches, fused_trafo_ids, + ) + + # shunts + df_shunt, sh_sub = _aux_add_shunts(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl) + + # SVCs + df_svc = _aux_add_svc(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl, sn_mva_used) + + # HVDC lines + df_dc, hvdc_sub_from_id, hvdc_sub_to_id = _aux_add_hvdc( + model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl, + ) + + # storage units + df_batt, batt_sub = _aux_add_storage(model, net, sort_index, voltage_levels, bus_df, first_bus_per_vl) + + # slack bus(es) + gen_slack_ids_int = _aux_add_slack(model, net, df_gen, gen_slack_id, slack_bus_id) + + # TODO checks + # no 3windings trafo and other exotic stuff + + # and now deactivate all elements and nodes not in the main component + if only_main_component: + model.consider_only_main_component() + else: + # automatically disconnect non connected buses + # (this is automatically done by consider_only_main_component) + model.init_bus_status() + + gen_sub = pd.DataFrame(index=df_gen.index, data={"sub_id": gen_sub}) + gen_sub["desired_slack"] = False + gen_sub.loc[gen_sub.index[gen_slack_ids_int], "desired_slack"] = True + load_sub = pd.DataFrame(index=df_load.index, data={"sub_id": load_sub}) + lor_sub = pd.DataFrame(index=df_line.index, data={"sub_id": lor_sub}) + lex_sub = pd.DataFrame(index=df_line.index, data={"sub_id": lex_sub}) + tor_sub = pd.DataFrame(index=df_trafo.index, data={"sub_id": tor_sub}) + tex_sub = pd.DataFrame(index=df_trafo.index, data={"sub_id": tex_sub}) + batt_sub = pd.DataFrame(index=df_batt.index, data={"sub_id": batt_sub}) + sh_sub = pd.DataFrame(index=df_shunt.index, data={"sub_id": sh_sub}) + hvdc_sub_from_id = pd.DataFrame(index=df_dc.index, data={"sub_id": hvdc_sub_from_id}) + hvdc_sub_to_id = pd.DataFrame(index=df_dc.index, data={"sub_id": hvdc_sub_to_id}) + + # set the substation ID to which each object belong + model.set_gen_to_subid(gen_sub["sub_id"].values) + model.set_load_to_subid(load_sub["sub_id"].values) + model.set_storage_to_subid(batt_sub["sub_id"].values) + model.set_shunt_to_subid(sh_sub["sub_id"].values) + model.set_line_to_sub1_id(lor_sub["sub_id"].values) + model.set_line_to_sub2_id(lex_sub["sub_id"].values) + model.set_trafo_to_sub1_id(tor_sub["sub_id"].values) + model.set_trafo_to_sub2_id(tex_sub["sub_id"].values) + if not return_sub_id: + return model + else: + return model, (gen_sub, load_sub, (lor_sub, tor_sub), (lex_sub, tex_sub), batt_sub, sh_sub, hvdc_sub_from_id, hvdc_sub_to_id) diff --git a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py index a8a13cf5..bea2450a 100644 --- a/lightsim2grid/tests/test_bus_fusion_pypowsybl.py +++ b/lightsim2grid/tests/test_bus_fusion_pypowsybl.py @@ -309,8 +309,8 @@ def test_leaf_branch_flow_and_voltage_reconstructed(self): V = model.ac_pf(Vdc, 30, 1e-8) self.assertGreater(V.shape[0], 0) - # `net.get_buses()` (the *bus view*, used throughout `_from_pypowsybl.py`/ - # `_result_network.py`) computes its own ids from the voltage level for a + # `net.get_buses()` (the *bus view*, used throughout the `from_pypowsybl` + # converter / `_result_network.py`) computes its own ids from the voltage level for a # BUS_BREAKER-topology network -- "VL1_0", not the bus-breaker-view id # ("B1") passed to `create_buses`/`create_lines(bus1_id=...)` above. bus1, bus2 = "VL1_0", "VL2_0" From 8eeb893510e69fe73b2ceed8e4241ce2ea483275 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 16 Jul 2026 10:33:44 +0200 Subject: [PATCH 129/166] add an example solver for distributing slack with outer loops Signed-off-by: DONNOT Benjamin --- examples/dist_slack_algorithm/CMakeLists.txt | 86 +++++++ .../dist_slack_algorithm/NRAlgoDistSlack.hpp | 219 ++++++++++++++++++ .../NRAlgoDistSlack_plugin.cpp | 35 +++ 3 files changed, 340 insertions(+) create mode 100644 examples/dist_slack_algorithm/CMakeLists.txt create mode 100644 examples/dist_slack_algorithm/NRAlgoDistSlack.hpp create mode 100644 examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp diff --git a/examples/dist_slack_algorithm/CMakeLists.txt b/examples/dist_slack_algorithm/CMakeLists.txt new file mode 100644 index 00000000..b76864c2 --- /dev/null +++ b/examples/dist_slack_algorithm/CMakeLists.txt @@ -0,0 +1,86 @@ +cmake_minimum_required(VERSION 3.15) +project(dist_slack_algorithm CXX) +set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ----------------------------------------------------------------------- +# Strategy 1: find via installed CMake config +# pip install lightsim2grid → lightsim2grid.get_cmake_dir() +# cmake --install → _install/lib/cmake/lightsim2grid_core/ +# Pass -DLIGHTSIM2GRID_CMAKE_DIR= to hint the location, or rely on +# CMAKE_PREFIX_PATH. +# ----------------------------------------------------------------------- +find_package(lightsim2grid_core CONFIG QUIET + HINTS "${LIGHTSIM2GRID_CMAKE_DIR}") + +# ----------------------------------------------------------------------- +# Strategy 2: fall back to source tree (in-repo development builds). +# ----------------------------------------------------------------------- +if(NOT lightsim2grid_core_FOUND) + if(NOT DEFINED LIGHTSIM2GRID_SRC) + set(LIGHTSIM2GRID_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../src/core") + endif() + if(NOT DEFINED Eigen3_INCLUDE) + set(Eigen3_INCLUDE "${CMAKE_CURRENT_SOURCE_DIR}/../../eigen") + endif() + if(NOT EXISTS "${LIGHTSIM2GRID_SRC}/AlgorithmRegistry.hpp") + message(FATAL_ERROR + "lightsim2grid_core not found.\n" + "Either install lightsim2grid and pass:\n" + " -DLIGHTSIM2GRID_CMAKE_DIR=\n" + "(obtain the path via: python -c \"import lightsim2grid; print(lightsim2grid.get_cmake_dir())\")\n" + "or pass -DLIGHTSIM2GRID_SRC= for a source-tree build.") + endif() + add_library(lightsim2grid_core_iface INTERFACE) + target_include_directories(lightsim2grid_core_iface INTERFACE + "${LIGHTSIM2GRID_SRC}" + "${Eigen3_INCLUDE}" + ) + add_library(lightsim2grid::core ALIAS lightsim2grid_core_iface) + message(STATUS "lightsim2grid_core: using source tree at ${LIGHTSIM2GRID_SRC}") +endif() + +# The plugin is a MODULE (loadable at runtime via dlopen/LoadLibrary). +add_library(dist_slack_algorithm MODULE NRAlgoDistSlack_plugin.cpp) +target_link_libraries(dist_slack_algorithm PRIVATE lightsim2grid::core) + +# ----------------------------------------------------------------------- +# KLUSolver.hpp (pulled in transitively via Solvers.hpp) needs SuiteSparse's +# own headers (cs.h, klu.h, amd.h, colamd.h, btf.h, SuiteSparse_config.h) to +# parse the KLULinearSolver class declaration -- but neither an installed +# lightsim2grid_core CMake package nor a plain source-tree checkout exposes +# those on its public include path (they're a private implementation detail +# of the core .so, already fully compiled in). We only need the *headers* +# here (the plugin never re-instantiates/re-links KLU code, see the +# `extern template` note in NRAlgoDistSlack.hpp) so point at the SuiteSparse +# submodule directly, header-only, as a repo-relative fallback. +# ----------------------------------------------------------------------- +if(NOT DEFINED SUITESPARSE_SRC) + set(SUITESPARSE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../SuiteSparse") +endif() +if(EXISTS "${SUITESPARSE_SRC}/KLU/Include/klu.h") + target_include_directories(dist_slack_algorithm PRIVATE + "${SUITESPARSE_SRC}/KLU/Include" + "${SUITESPARSE_SRC}/AMD/Include" + "${SUITESPARSE_SRC}/COLAMD/Include" + "${SUITESPARSE_SRC}/BTF/Include" + "${SUITESPARSE_SRC}/SuiteSparse_config" + "${SUITESPARSE_SRC}/CXSparse/Include" + ) +endif() + +if(WIN32) + # find_package provides the import lib via the IMPORTED target — nothing extra needed. +elseif(UNIX AND NOT APPLE) + if(NOT lightsim2grid_core_FOUND) + # Source-tree mode: no shared library to link against. + # Symbols are resolved from the already-loaded lightsim2grid_cpp.so at runtime. + target_link_options(dist_slack_algorithm PRIVATE -Wl,--allow-shlib-undefined) + endif() + set_target_properties(dist_slack_algorithm PROPERTIES PREFIX "lib" SUFFIX ".so") +elseif(APPLE) + if(NOT lightsim2grid_core_FOUND) + target_link_options(dist_slack_algorithm PRIVATE -undefined dynamic_lookup) + endif() + set_target_properties(dist_slack_algorithm PROPERTIES PREFIX "lib" SUFFIX ".so") +endif() diff --git a/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp b/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp new file mode 100644 index 00000000..c04937ad --- /dev/null +++ b/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp @@ -0,0 +1,219 @@ +// Copyright (c) 2026 +// Experimental plugin, not part of the lightsim2grid core distribution. +// +// NRAlgoDistSlack: distributed slack implemented as an OUTER loop around a +// single-slack `NRAlgo` (SingleSlackNRSystem = NRSystem), instead of lightsim2grid's usual in-Jacobian MultiSlack extension. +// +// Rationale (see lightsim2grid session notes / memory +// project_orell_multislack_voltagecontrol_nr_divergence): PowSyBl OpenLoadFlow +// implements distributed slack as a classic outer loop +// (DistributedSlackOuterLoop.java) around a clean single-reference-slack inner +// Newton-Raphson solve -- the MAX_VOLTAGE_CHANGE step-damping policy never sees +// a distributed-slack unknown in the same linearization as VoltageControl's +// Q-sharing/voltage-pinning equations. lightsim2grid's MultiSlackNRSystem bakes +// both into the same Jacobian simultaneously, which was found to diverge under +// MaxVoltageChange damping specifically when a >=2-controller VoltageControl +// sharing group is also entirely composed of distributed-slack participants. +// This class tests whether mimicking OLF's architecture avoids the issue. +// +// Only ACTIVE power is redistributed across outer rounds (mirroring OLF's +// DistributedSlackOuterLoop, which only ever touches targetP) -- reactive +// power / voltage control is handled entirely by the (unchanged) VoltageControl +// extension inside each inner solve. + +#ifndef NR_ALGO_DIST_SLACK_H +#define NR_ALGO_DIST_SLACK_H + +// Include Solvers.hpp (not powerflow_algorithm/NRAlgo.hpp directly): it pulls +// in SparseLULinearSolver/KLULinearSolver plus, when LS2G_BUILDING_CORE is +// NOT defined (true for any out-of-tree plugin build), `extern template` +// declarations for NRAlgo -- so `inner_` below links against the symbols +// already compiled into the main lightsim2grid_cpp library instead of being +// re-instantiated (and re-emitted) inside this plugin's .so. +#include +#include +#include + +namespace ls2g { + +template +class NRAlgoDistSlack final : public BaseAlgo { +public: + NRAlgoDistSlack() noexcept : + BaseAlgo(/*is_ac=*/true), + slack_absorbed_accum_(static_cast(0.)), + outer_iter_(0), + max_outer_iter_(20), + outer_tol_(static_cast(1e-4)) {} + + ~NRAlgoDistSlack() noexcept override = default; + + void set_lsgrid(const LSGrid* gridmodel) override { + BaseAlgo::set_lsgrid(gridmodel); + inner_.set_lsgrid(gridmodel); + } + + // ----- Jacobian / VoltageControl introspection: forward to the last inner round ----- + Eigen::Ref> get_J() const override { return inner_.get_J(); } + IntVect get_theta_to_J_col_python() const override { return inner_.get_theta_to_J_col_python(); } + IntVect get_vm_to_J_col_python() const override { return inner_.get_vm_to_J_col_python(); } + IntVect get_q_to_J_col_python() const override { return inner_.get_q_to_J_col_python(); } + IntVect get_p_to_J_row_python() const override { return inner_.get_p_to_J_row_python(); } + IntVect get_q_to_J_row_python() const override { return inner_.get_q_to_J_row_python(); } + IntVect get_p_buses_python() const override { return inner_.get_p_buses_python(); } + IntVect get_p_rows_python() const override { return inner_.get_p_rows_python(); } + IntVect get_q_buses_python() const override { return inner_.get_q_buses_python(); } + IntVect get_q_rows_python() const override { return inner_.get_q_rows_python(); } + IntVect get_theta_buses_python() const override { return inner_.get_theta_buses_python(); } + IntVect get_theta_cols_python() const override { return inner_.get_theta_cols_python(); } + IntVect get_vm_buses_python() const override { return inner_.get_vm_buses_python(); } + IntVect get_vm_cols_python() const override { return inner_.get_vm_cols_python(); } + RealVect get_controller_q() const override { return inner_.get_controller_q(); } + IntVect get_controller_kind() const override { return inner_.get_controller_kind(); } + IntVect get_controller_elem_id() const override { return inner_.get_controller_elem_id(); } + + // No in-Jacobian slack column (that's the whole point) -- but there IS a + // meaningful total: the accumulated active power redistributed by the outer + // loop across all its rounds, for THIS compute_pf call. + int get_slack_col() const override { return -1; } + real_type get_slack_absorbed() const override { return slack_absorbed_accum_; } + + // The inner NRAlgo includes both the + // Hvdc and VoltageControl extensions (see NRAlgo.hpp), forwarded to + // unconditionally on every outer round -- so this wrapper genuinely + // supports both, unlike a generic plugin (BaseAlgo's conservative + // default of `false` for both). + static constexpr bool SUPPORTS_HVDC_DROOP = true; + bool supports_hvdc_droop() const noexcept override { return SUPPORTS_HVDC_DROOP; } + static constexpr bool SUPPORTS_REMOTE_VOLTAGE_CONTROL = true; + bool supports_remote_voltage_control() const noexcept override { return SUPPORTS_REMOTE_VOLTAGE_CONTROL; } + + // Number of outer redistribution rounds performed by the last compute_pf call. + int get_outer_iter() const { return outer_iter_; } + + TimerJac get_timers_jacobian() const override { return inner_.get_timers_jacobian(); } + + bool supports_bus_masking() const override { return inner_.supports_bus_masking(); } + void set_masked_buses(const std::vector& solver_bus_ids) override { + inner_.set_masked_buses(solver_bus_ids); + } + + // int_params[0] = max outer rounds; real_params[0] = outer active-power + // mismatch tolerance (same units as Sbus, i.e. per-unit on Sn). Delegates + // everything else to the inner NRAlgo's own config (scaling/refactor policy). + AlgoConfig get_config() const override { + AlgoConfig cfg = inner_.get_config(); + cfg.int_params.insert(cfg.int_params.begin(), max_outer_iter_); + cfg.real_params.insert(cfg.real_params.begin(), static_cast(outer_tol_)); + return cfg; + } + void set_config(const AlgoConfig& cfg) override { + if (cfg.int_params.empty() || cfg.real_params.empty()) + throw std::runtime_error("NRAlgoDistSlack::set_config: expects int_params[0]=max_outer_iter, " + "real_params[0]=outer_tol, followed by the inner NRAlgo's own config."); + max_outer_iter_ = cfg.int_params[0]; + outer_tol_ = static_cast(cfg.real_params[0]); + AlgoConfig inner_cfg; + inner_cfg.int_params.assign(cfg.int_params.begin() + 1, cfg.int_params.end()); + inner_cfg.real_params.assign(cfg.real_params.begin() + 1, cfg.real_params.end()); + if (!inner_cfg.int_params.empty() || !inner_cfg.real_params.empty()) inner_.set_config(inner_cfg); + } + + void reset() override { + BaseAlgo::reset(); + inner_.reset(); + slack_absorbed_accum_ = static_cast(0.); + outer_iter_ = 0; + } + + bool compute_pf(const Eigen::SparseMatrix& Ybus, + CplxVect& V, + Eigen::Ref Sbus, + Eigen::Ref slack_ids, + Eigen::Ref slack_weights, + Eigen::Ref pv, + Eigen::Ref pq, + int max_iter, + real_type tol) override + { + err_ = ErrorType::NoError; + slack_absorbed_accum_ = static_cast(0.); + outer_iter_ = 0; + nr_iter_ = 0; + + // Every distributed-slack participant except the reference (slack_ids(0)) + // becomes an ordinary PV bus for the inner single-slack solve; Base's own + // `free_vm_slack_buses_` mechanism (driven by LSGrid's persistent slack + // participant set, independent of the slack_ids we pass here) still + // correctly gives any non-locally-pinned bus among them a free Vm unknown + // + Q equation, so remote voltage control (incl. multi-controller sharing + // groups) keeps working exactly as it does for the multi-slack case. + IntVect my_pv = retrieve_pv_with_slack(slack_ids, pv); + IntVect ref_slack_id(1); + ref_slack_id(0) = slack_ids(0); + + CplxVect Sbus_working = Sbus; + CplxVect V_round = V; + + AlgoControl round_control = _solver_control; + + const int n_participants = static_cast(slack_ids.size()); + const real_type w_ref = slack_weights(slack_ids(0)); + const real_type one_minus_w_ref = static_cast(1.) - w_ref; + + bool conv = false; + for (outer_iter_ = 0; outer_iter_ < max_outer_iter_; ++outer_iter_) { + inner_.tell_solver_control(round_control); + conv = inner_.compute_pf(Ybus, V_round, Sbus_working, ref_slack_id, slack_weights, + my_pv, pq, max_iter, tol); + nr_iter_ += inner_.get_nb_iter(); + if (!conv) { err_ = inner_.get_error(); break; } + + // Subsequent inner rounds only ever change Sbus (redistributed P): + // reuse the symbolic AND numeric factorization / Jacobian sparsity. + round_control.tell_none_changed(); + V_round = inner_.get_V(); + + // Full-vector mismatch (same formula as LSGrid::compute_results), + // we only need the reference bus' component. + const CplxVect mismatch = V_round.array() * (Ybus * V_round).conjugate().array() - Sbus_working.array(); + const real_type mis = std::real(mismatch(slack_ids(0))); + + if (std::abs(mis) < outer_tol_) break; + + if (n_participants > 1 && one_minus_w_ref > BaseConstants::_tol_equal_float) { + for (int k = 1; k < n_participants; ++k) { + const int bus = slack_ids(k); + const real_type w = slack_weights(bus); + if (w <= BaseConstants::my_zero_) continue; + Sbus_working(bus) += cplx_type(w / one_minus_w_ref * mis, static_cast(0.)); + } + } + slack_absorbed_accum_ += mis; + } + + V_ = inner_.get_V(); + Va_ = inner_.get_Va(); + Vm_ = inner_.get_Vm(); + n_ = static_cast(V_.size()); + return conv; + } + +private: + NRAlgo inner_; + real_type slack_absorbed_accum_; + int outer_iter_; + int max_outer_iter_; + real_type outer_tol_; + + NRAlgoDistSlack(const NRAlgoDistSlack&) = delete; + NRAlgoDistSlack(NRAlgoDistSlack&&) = delete; + NRAlgoDistSlack& operator=(NRAlgoDistSlack&&) = delete; + NRAlgoDistSlack& operator=(const NRAlgoDistSlack&) = delete; +}; + +} // namespace ls2g + +#endif // NR_ALGO_DIST_SLACK_H diff --git a/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp b/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp new file mode 100644 index 00000000..1896c03a --- /dev/null +++ b/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp @@ -0,0 +1,35 @@ +// NRAlgoDistSlack plugin registration. +// +// Registers two names with the C++ AlgorithmRegistry at dlopen() time (via a +// static AlgorithmRegistrar in an anonymous namespace -- same mechanism as +// examples/external_algorithm/DummyExternalAlgo.cpp): +// "NRDistSlack_SparseLU" -> NRAlgoDistSlack +// "NRDistSlack_KLU" -> NRAlgoDistSlack (only if KLU is available) +// +// Build (after pip install lightsim2grid, or from a source-tree checkout): +// LS2G_CMAKE=$(python -c "import lightsim2grid; print(lightsim2grid.get_cmake_dir())") +// cmake -S examples/dist_slack_algorithm -B examples/dist_slack_algorithm/build -DLIGHTSIM2GRID_CMAKE_DIR="$LS2G_CMAKE" +// cmake --build examples/dist_slack_algorithm/build +// +// Python usage: +// import lightsim2grid +// lightsim2grid.load_algorithm_plugin("examples/dist_slack_algorithm/build/libdist_slack_algorithm.so") +// grid.change_algorithm("NRDistSlack_KLU") + +#include + +#include "NRAlgoDistSlack.hpp" + +namespace { + ls2g::AlgorithmRegistrar _dist_slack_sparselu_registrar( + "NRDistSlack_SparseLU", + []{ return std::unique_ptr(new ls2g::NRAlgoDistSlack()); } + ); + +#ifdef KLU_SOLVER_AVAILABLE + ls2g::AlgorithmRegistrar _dist_slack_klu_registrar( + "NRDistSlack_KLU", + []{ return std::unique_ptr(new ls2g::NRAlgoDistSlack()); } + ); +#endif +} From 5deda3d3e899e42b11c1ce94dfdd8047d8dc5013 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 16 Jul 2026 10:35:16 +0200 Subject: [PATCH 130/166] add a way to generate a 'text fixture' : a case14 grid with some exotic elements Signed-off-by: DONNOT Benjamin --- .../network/from_pypowsybl/_aux_add_buses.py | 3 +- .../tests/_exotic_elements_case_ls.py | 135 ++++++ .../tests/_exotic_elements_fixture.py | 19 + .../tests/_gen_exotic_elements_case.py | 440 ++++++++++++++++++ .../tests/test_binary_serialization.py | 30 +- .../tests/test_exotic_elements_case_ls.py | 51 ++ lightsim2grid/tests/test_pickleable.py | 30 +- src/core/AlgorithmSelector.hpp | 24 +- src/core/LSGrid.cpp | 2 +- src/core/powerflow_algorithm/BaseAlgo.hpp | 30 ++ src/core/powerflow_algorithm/BaseDCAlgo.hpp | 3 + src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 3 + src/core/powerflow_algorithm/NRAlgo.hpp | 12 + src/tests/CMakeLists.txt | 1 + src/tests/case_exotic_elements.hpp | 203 ++++++++ src/tests/test_case_exotic_elements.cpp | 129 +++++ src/tests/test_lsgrid.cpp | 11 +- 17 files changed, 1095 insertions(+), 31 deletions(-) create mode 100644 lightsim2grid/tests/_exotic_elements_case_ls.py create mode 100644 lightsim2grid/tests/_gen_exotic_elements_case.py create mode 100644 lightsim2grid/tests/test_exotic_elements_case_ls.py create mode 100644 src/tests/case_exotic_elements.hpp create mode 100644 src/tests/test_case_exotic_elements.cpp diff --git a/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py b/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py index e4fa9ea6..fe1aa306 100644 --- a/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py +++ b/lightsim2grid/network/from_pypowsybl/_aux_add_buses.py @@ -250,7 +250,8 @@ def _aux_add_buses(model, net, net_pu, sort_index, buses_for_sub, n_busbar_per_s bus_df.loc[added_bus_nm] = to_add.loc[added_bus_nm] for vl_bus_added in nm_vl_without_bus.index: first_bus_per_vl.loc[vl_bus_added, "first_bus_name"] = vl_bus_added + "added_bus" - + # import pdb + # pdb.set_trace() # all_buses_vn_kv = np.concatenate([all_buses_vn_kv for _ in range(n_busbar_per_sub)]) model.init_bus(n_sub_ls, n_busbar_per_sub_ls, diff --git a/lightsim2grid/tests/_exotic_elements_case_ls.py b/lightsim2grid/tests/_exotic_elements_case_ls.py new file mode 100644 index 00000000..75bbe656 --- /dev/null +++ b/lightsim2grid/tests/_exotic_elements_case_ls.py @@ -0,0 +1,135 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""AUTO-GENERATED by lightsim2grid/tests/_gen_exotic_elements_case.py -- DO NOT EDIT BY HAND. + +Reproduces the grid built by `_exotic_elements_fixture.build_exotic_elements_grid()` (IEEE14 + +SVC + storage + 3 HVDC lines + a phase-shifting transformer) via literal `LSGrid.init_*`/ +`add_*`/`set_*` calls captured from a real `init_from_pypowsybl` run -- no pypowsybl/ +pandapower/grid2op dependency at runtime, only numpy + lightsim2grid. + +Generated on 2026-07-16 using `python -m lightsim2grid.tests._gen_exotic_elements_case` (lightsim2grid 1.0.0.rc2, pypowsybl 1.15.0). +""" + +import numpy as np + +from lightsim2grid.network import LSGrid + + +def build_exotic_elements_case_grid() -> LSGrid: + """Unsolved: callers run their own ac_pf / dc_pf, same contract as + `_exotic_elements_fixture.build_exotic_elements_grid(solve=False)`.""" + model = LSGrid() + + model.set_sn_mva( + 100.0 + ) + model.set_init_vm_pu( + 1.06 + ) + model.init_bus( + 14, + 1, + np.array([135.0, 12.0, 12.0, 12.0, 12.0, 12.0, 135.0, 135.0, 135.0, 135.0, 12.0, 14.0, 20.0, 12.0], dtype=np.float64), + 0, + 0 + ) + model.init_generators_full( + np.array([232.4, 40.0, 0.0, 0.0, 0.0], dtype=np.float64), + np.array([1.06, 1.045, 1.01, 1.07, 1.09], dtype=np.float64), + np.array([-16.9, 42.4, 23.4, 12.2, 17.4], dtype=np.float64), + [True, True, True, True, True], + np.array([-3.4028233356588204e+34, -40.0, 0.0, -6.0, -6.0], dtype=np.float32), + np.array([3.4028233356588204e+34, 50.0, 40.0, 24.0, 24.0], dtype=np.float32), + np.array([0, 6, 7, 10, 12], dtype=np.int64) + ) + model.init_loads( + np.array([9.0, 3.5, 6.1, 13.5, 14.9, 21.7, 94.2, 47.8, 7.6, 11.2, 29.5], dtype=np.float64), + np.array([5.8, 1.8, 1.6, 5.8, 5.0, 12.7, 19.0, -3.9, 1.6, 7.5, 16.6], dtype=np.float64), + np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13], dtype=np.int64) + ) + model.init_powerlines_full( + np.array([0.01938, 0.054029999999999995, 0.08205, 0.22091999999999998, 0.17093, 0.04698999999999999, 0.058109999999999995, 0.05694999999999999, 0.06701, 0.01335, 0.09497999999999998, 0.12290999999999999, 0.06615, 0.0, 0.0, 0.03180999999999999, 0.12711], dtype=np.float64), + np.array([0.05916999999999999, 0.22304, 0.19206999999999996, 0.19987999999999995, 0.34801999999999994, 0.19797, 0.17632, 0.17388, 0.17102999999999996, 0.042109999999999995, 0.1989, 0.25581, 0.13026999999999997, 0.17615000000000003, 0.11001, 0.08449999999999999, 0.27038], dtype=np.float64), + np.array([complex(0.0, 0.0264), complex(0.0, 0.0246), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0219), complex(0.0, 0.017), complex(0.0, 0.0173), complex(0.0, 0.0064), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 5.684341886080802e-16), complex(0.0, 0.0), complex(0.0, 0.0)], dtype=np.complex128), + np.array([complex(0.0, 0.0264), complex(0.0, 0.0246), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0219), complex(0.0, 0.017), complex(0.0, 0.0173), complex(0.0, 0.0064), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 2.842170943040401e-16), complex(0.0, -5.684341886080802e-16), complex(0.0, 0.0), complex(0.0, 0.0)], dtype=np.complex128), + np.array([0, 0, 1, 3, 4, 6, 6, 6, 7, 8, 10, 10, 10, 11, 11, 13, 13], dtype=np.int64), + np.array([6, 9, 2, 4, 5, 7, 8, 9, 8, 9, 2, 3, 4, 12, 13, 1, 5], dtype=np.int64) + ) + model.init_trafo( + np.array([0.0, 0.0, 0.0], dtype=np.float64), + np.array([0.20912, 0.55618, 0.25202], dtype=np.float64), + np.array([complex(0.0, 0.0), complex(0.0, 0.0), complex(0.0, 0.0)], dtype=np.complex128), + np.array([1.0224948875255624, 1.0319917440660473, 1.0729613733905579], dtype=np.float64), + np.array([0.0, 0.0, 0.0], dtype=np.float64), + [False, False, False], + np.array([8, 8, 9], dtype=np.int64), + np.array([11, 13, 10], dtype=np.int64), + False + ) + model.set_trafo_shift_dependent_rx( + True, + [[-0.08726646259971647, 0.0, 0.08726646259971647], [], []], + [[0.0, 0.0, 0.0], [], []] + ) + model.init_shunt( + np.array([0.0], dtype=np.float64), + np.array([-19.0], dtype=np.float64), + np.array([13], dtype=np.int64) + ) + model.init_svcs( + [1], + np.array([1.0], dtype=np.float64), + np.array([-0.0], dtype=np.float64), + np.array([0.08333333333333333], dtype=np.float64), + np.array([-0.0288], dtype=np.float64), + np.array([0.0288], dtype=np.float64), + np.array([5], dtype=np.int32), + np.array([5], dtype=np.int32) + ) + model.init_hvdc_lines( + np.array([5, 1, 3], dtype=np.int32), + np.array([4, 2, 13], dtype=np.int32), + [1, 0, 0], + [1, 0, 0], + np.array([0.01100000023841858, 0.01100000023841858, 0.01100000023841858], dtype=np.float64), + np.array([0.01100000023841858, 0.01100000023841858, 0.01100000023841858], dtype=np.float64), + [False, True, True], + [False, True, True], + np.array([1.0, 1.0, 1.0], dtype=np.float64), + np.array([1.0, 1.0, 1.0], dtype=np.float64), + np.array([0.0, 0.0, 0.0], dtype=np.float64), + np.array([0.0, 0.0, 0.0], dtype=np.float64), + np.array([-10000000.0, -1.7976931348623157e+308, -1.7976931348623157e+308], dtype=np.float64), + np.array([10000000.0, 1.7976931348623157e+308, 1.7976931348623157e+308], dtype=np.float64), + np.array([-10000000.0, -1.7976931348623157e+308, -1.7976931348623157e+308], dtype=np.float64), + np.array([10000000.0, 1.7976931348623157e+308, 1.7976931348623157e+308], dtype=np.float64), + np.array([0.8999999761581421, 1.0, 1.0], dtype=np.float64), + np.array([0.8999999761581421, 1.0, 1.0], dtype=np.float64), + [0, 0, 0], + np.array([1.0, 2.0, 2.0], dtype=np.float64), + np.array([1.0, 1.0, 1.0], dtype=np.float64), + np.array([12.0, 12.0, 12.0], dtype=np.float64), + [False, True, False], + np.array([0.0, 1.0, 0.0], dtype=np.float64), + np.array([0.0, 180.0, 0.0], dtype=np.float64), + np.array([20.0, 20.0, 20.0], dtype=np.float64), + np.array([20.0, 20.0, 20.0], dtype=np.float64) + ) + model.init_storages( + np.array([-5.0], dtype=np.float64), + np.array([-1.0], dtype=np.float64), + np.array([4], dtype=np.int64) + ) + model.add_gen_slackbus( + 0, + 1.0 + ) + model.consider_only_main_component() + + return model diff --git a/lightsim2grid/tests/_exotic_elements_fixture.py b/lightsim2grid/tests/_exotic_elements_fixture.py index 3043f1c6..435f39a7 100644 --- a/lightsim2grid/tests/_exotic_elements_fixture.py +++ b/lightsim2grid/tests/_exotic_elements_fixture.py @@ -25,6 +25,25 @@ otherwise) -- an LCC line with droop is not a configuration lightsim2grid can ever load. Substituted for equivalent regime coverage: HVDC VSC-VSC *with* droop, HVDC VSC-VSC *without* droop, and HVDC LCC-LCC (never has droop). + +This module itself requires pypowsybl (it builds and converts a real IIDM network). +Two dependency-free reproductions of the *same* grid are generated from it and +checked in, for callers that should not need pypowsybl installed: + +* ``lightsim2grid/tests/_exotic_elements_case_ls.py`` -- ``build_exotic_elements_case_grid()``, + numpy + lightsim2grid only. +* ``src/tests/case_exotic_elements.hpp`` -- ``ls2g_test::make_exotic_elements_grid()``, + pure C++ (Eigen + lightsim2grid core), for the Catch2 suite. + +Both are produced by ``_gen_exotic_elements_case.py`` (see that module's docstring +for how it works and how to regenerate them -- needed whenever +``build_exotic_elements_network()`` here changes) and carry a +"Generated on YYYY-MM-DD using ..." header stamped with the lightsim2grid / +pypowsybl versions used. ``test_exotic_elements_case_ls.py`` checks the generated +case stays bit-identical to this module's ``build_exotic_elements_grid()``. Prefer +the generated case in any test that does not specifically need pypowsybl for +something else (see ``test_binary_serialization.py`` / ``test_pickleable.py``, +which switched to it so those files can be imported and run without pypowsybl). """ import pandas as pd diff --git a/lightsim2grid/tests/_gen_exotic_elements_case.py b/lightsim2grid/tests/_gen_exotic_elements_case.py new file mode 100644 index 00000000..675eefda --- /dev/null +++ b/lightsim2grid/tests/_gen_exotic_elements_case.py @@ -0,0 +1,440 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Dev-only generator: captures the `LSGrid.init_*`/`add_*`/`set_*` calls made by +`init_from_pypowsybl` while building `_exotic_elements_fixture.build_exotic_elements_grid()`, +and emits two "case files" that reproduce the same grid via hardcoded literals, with no +pypowsybl/pandapower/grid2op dependency at runtime: + +* `lightsim2grid/tests/_exotic_elements_case_ls.py` (numpy + lightsim2grid only) +* `src/tests/case_exotic_elements.hpp` (pure C++, Eigen + lightsim2grid core only) + +How it works: `LSGrid` (`lightsim2grid.network.LSGrid`) is a plain, mutable pybind11 +class, so its grid-construction methods can be monkeypatched at the class level for the +duration of a single real `init_from_pypowsybl(...)` call -- every allowlisted method +records its (name, args) before delegating to the real implementation, and the patch is +reverted immediately after. This means the actual `initLSGrid.init()` orchestration is +never duplicated here: if it changes, this generator picks up the new call sequence +automatically the next time it is rerun. + +Only calls that affect the solve are kept (the allowlist below); purely cosmetic / +bookkeeping calls (`set_*_names`, `set_bus_voltage_limits`, `set_*_current_limit_*`, +`set_*_to_subid`) are recognized and dropped -- matching how `src/tests/test_lsgrid.cpp`'s +own hand-written helpers never touch any of those either. Any call observed that is +neither allowlisted nor in the explicit skip set raises immediately, so a future change to +`initLSGrid.py` cannot silently produce an incomplete case file. + +Regenerate with (requires pypowsybl; NOT part of CI, NOT imported by any test module): + + venv_ls/bin/python -m lightsim2grid.tests._gen_exotic_elements_case + +...whenever `_exotic_elements_fixture.build_exotic_elements_network()` changes. +""" + +import datetime +import os + +import numpy as np +import pypowsybl + +import lightsim2grid +from lightsim2grid.network import LSGrid, init_from_pypowsybl + +from ._exotic_elements_fixture import build_exotic_elements_network + +# --------------------------------------------------------------------------- +# 1. call capture +# --------------------------------------------------------------------------- + +# Calls that affect the AC/DC solve: kept, emitted verbatim into both case files. +ALLOWLIST = { + "set_sn_mva", "set_init_vm_pu", + "init_bus", + "init_generators_full", "deactivate_gen", "reactivate_gen", "set_gen_regulated_bus", + "init_loads", "deactivate_load", "reactivate_load", + "init_powerlines_full", + "deactivate_powerline", "deactivate_powerline_side1", "deactivate_powerline_side2", + "reactivate_powerline", + "init_trafo", + "deactivate_trafo", "deactivate_trafo_side1", "deactivate_trafo_side2", + "reactivate_trafo", + "set_trafo_shift_dependent_rx", + "init_shunt", "deactivate_shunt", "reactivate_shunt", + "init_svcs", "deactivate_svc", "reactivate_svc", + "init_hvdc_lines", + "deactivate_dcline", "deactivate_dcline_side1", "deactivate_dcline_side2", + "reactivate_dcline", + "init_storages", "deactivate_storage", "reactivate_storage", + "add_gen_slackbus", "remove_gen_slackbus", + "consider_only_main_component", "init_bus_status", +} + +# Purely cosmetic / grid2op-bookkeeping calls: recognized and silently dropped. +SKIP_SET = { + "set_substation_names", "set_bus_voltage_limits", + "set_gen_names", "set_load_names", + "set_line_names", "set_line_current_limit_side1", "set_line_current_limit_side2", + "set_trafo_names", "set_trafo_current_limit_side1", "set_trafo_current_limit_side2", + "set_shunt_names", "set_svc_names", "set_dcline_names", "set_storage_names", + "set_gen_to_subid", "set_load_to_subid", "set_storage_to_subid", "set_shunt_to_subid", + "set_line_to_sub1_id", "set_line_to_sub2_id", "set_trafo_to_sub1_id", "set_trafo_to_sub2_id", + "tell_solver_need_reset", +} + + +class _RecordingLSGrid: + """Context manager: monkeypatches every `ALLOWLIST`/`SKIP_SET` method found on the + `LSGrid` class to log `(name, args)` (kwargs are asserted empty -- the C++ bindings + are positional-only) before delegating to the real implementation, and restores the + originals on exit. `self.call_log` only ever contains allowlisted calls.""" + + def __init__(self): + self.call_log = [] + self._originals = {} + + def __enter__(self): + for name in ALLOWLIST | SKIP_SET: + if hasattr(LSGrid, name): + self._originals[name] = getattr(LSGrid, name) + for name, original in self._originals.items(): + setattr(LSGrid, name, self._make_wrapper(name, original)) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + for name, original in self._originals.items(): + setattr(LSGrid, name, original) + return False + + def _make_wrapper(self, name, original): + keep = name in ALLOWLIST + + def wrapper(this, *args, **kwargs): + assert not kwargs, ( + f"{name}: unexpected kwargs {kwargs!r} -- the generator assumes every " + "LSGrid call from init_from_pypowsybl is positional-only") + if keep: + self.call_log.append((name, args)) + return original(this, *args) + + return wrapper + + +def _capture_exotic_elements_calls(): + net = build_exotic_elements_network() + with _RecordingLSGrid() as recorder: + init_from_pypowsybl(net, gen_slack_id="B1-G") + seen_names = {name for name, _ in recorder.call_log} + unexpected = seen_names - ALLOWLIST + assert not unexpected, f"unclassified LSGrid calls observed: {unexpected}" + return recorder.call_log + + +# --------------------------------------------------------------------------- +# 2. value formatting shared by both languages +# --------------------------------------------------------------------------- + +def _double_literal(x): + """`repr()` on a python float is the shortest decimal string that round-trips to + the exact same IEEE-754 double, and that string is valid literal syntax in both + Python and C++ -- so a single helper backs both formatters and guarantees the + regenerated grid reaches the bit-identical Newton-Raphson fixed point.""" + x = float(x) + assert np.isfinite(x), f"non-finite value {x!r} encountered; formatter does not handle nan/inf" + return repr(x) + + +def _value_kind(value): + """Classify a captured argument into one of the shapes the LSGrid C++ API accepts, + driving both the Python and the C++ formatter.""" + if isinstance(value, (bool, np.bool_)): + return "scalar_bool" + if isinstance(value, (int, np.integer)): + return "scalar_int" + if isinstance(value, (float, np.floating)): + return "scalar_real" + if isinstance(value, np.ndarray): + if np.issubdtype(value.dtype, np.complexfloating): + return "cplx_vect" + if np.issubdtype(value.dtype, np.bool_): + return "vector_bool" + if np.issubdtype(value.dtype, np.integer): + return "int_vect" + if np.issubdtype(value.dtype, np.floating): + return "real_vect" + raise TypeError(f"unhandled ndarray dtype {value.dtype}") + if isinstance(value, list): + assert len(value) > 0, "empty python list argument: ambiguous element type, not handled" + if isinstance(value[0], list): + return "nested_real" + if isinstance(value[0], bool): + return "vector_bool" + if isinstance(value[0], int): + return "vector_int" + raise TypeError(f"unhandled list element type {type(value[0])}") + raise TypeError(f"unhandled argument type {type(value)}") + + +def _py_format_value(value): + kind = _value_kind(value) + if kind == "scalar_bool": + return repr(bool(value)) + if kind == "scalar_int": + return repr(int(value)) + if kind == "scalar_real": + return _double_literal(value) + if kind == "real_vect": + elems = ", ".join(_double_literal(v) for v in value) + return f"np.array([{elems}], dtype=np.{value.dtype.name})" + if kind == "int_vect": + elems = ", ".join(str(int(v)) for v in value) + return f"np.array([{elems}], dtype=np.{value.dtype.name})" + if kind == "cplx_vect": + elems = ", ".join(f"complex({_double_literal(v.real)}, {_double_literal(v.imag)})" for v in value) + return f"np.array([{elems}], dtype=np.{value.dtype.name})" + if kind == "vector_bool": + return "[" + ", ".join(repr(bool(v)) for v in value) + "]" + if kind == "vector_int": + return "[" + ", ".join(repr(int(v)) for v in value) + "]" + if kind == "nested_real": + rows = ("[" + ", ".join(_double_literal(v) for v in row) + "]" for row in value) + return "[" + ", ".join(rows) + "]" + raise AssertionError(kind) # pragma: no cover + + +def _cpp_format_arg(varname, value): + """Returns (decl_lines, expr): zero or more C++ statements to declare/fill `varname`, + and the expression to use for this argument in the call itself.""" + kind = _value_kind(value) + if kind == "scalar_bool": + return [], ("true" if value else "false") + if kind == "scalar_int": + return [], str(int(value)) + if kind == "scalar_real": + return [], _double_literal(value) + if kind == "real_vect": + n = len(value) + decl = [f"RealVect {varname}({n});"] + if n > 0: + decl.append(f"{varname} << " + ", ".join(_double_literal(v) for v in value) + ";") + return decl, varname + if kind == "int_vect": + n = len(value) + decl = [f"Eigen::VectorXi {varname}({n});"] + if n > 0: + decl.append(f"{varname} << " + ", ".join(str(int(v)) for v in value) + ";") + return decl, varname + if kind == "cplx_vect": + n = len(value) + decl = [f"CplxVect {varname}({n});"] + if n > 0: + elems = ", ".join( + f"std::complex({_double_literal(v.real)}, {_double_literal(v.imag)})" for v in value) + decl.append(f"{varname} << {elems};") + return decl, varname + if kind == "vector_bool": + elems = ", ".join("true" if v else "false" for v in value) + return [f"const std::vector {varname}{{{elems}}};"], varname + if kind == "vector_int": + elems = ", ".join(str(int(v)) for v in value) + return [f"const std::vector {varname}{{{elems}}};"], varname + if kind == "nested_real": + decl = [f"const std::vector> {varname} = {{"] + for row in value: + row_lits = ", ".join(_double_literal(v) for v in row) + decl.append(f" {{{row_lits}}},") + decl.append("};") + return decl, varname + raise AssertionError(kind) # pragma: no cover + + +# Readable C++ variable names, taken straight from the LSGrid.hpp parameter names, for +# every allowlisted method with array-typed arguments. Methods not listed here (all +# scalar args, e.g. the deactivate_* family) fall back to `f"{method}_arg{i}"`. +CPP_PARAM_NAMES = { + "init_bus": ["n_sub", "n_busbar_per_sub", "bus_vn_kv", "nb_line", "nb_trafo"], + "init_generators_full": [ + "gen_p", "gen_v_pu", "gen_q", "gen_voltage_regulator_on", "gen_min_q", "gen_max_q", "gen_bus_id"], + "init_loads": ["load_p", "load_q", "load_bus_id"], + "init_powerlines_full": ["line_r", "line_x", "line_h1", "line_h2", "line_from_id", "line_to_id"], + "init_trafo": [ + "trafo_r", "trafo_x", "trafo_b", "trafo_ratio", "trafo_shift_degree", + "trafo_tap_hv", "trafo_bus1_id", "trafo_bus2_id", "trafo_ignore_tap_side_for_shift"], + "set_trafo_shift_dependent_rx": ["trafo_shift_rx_enable", "trafo_shift_alpha_rad", "trafo_shift_rx_corr_pct"], + "init_shunt": ["shunt_p_mw", "shunt_q_mvar", "shunt_bus_id"], + "init_svcs": [ + "svc_regulation_mode", "svc_target_vm_pu", "svc_q_setpoint_mvar", "svc_slope_pu", + "svc_b_min", "svc_b_max", "svc_regulated_bus_id", "svc_bus_id"], + "init_hvdc_lines": [ + "hvdc_bus1_id", "hvdc_bus2_id", "hvdc_type1", "hvdc_type2", + "hvdc_loss_factor1", "hvdc_loss_factor2", "hvdc_vreg1_on", "hvdc_vreg2_on", + "hvdc_vm1_pu", "hvdc_vm2_pu", "hvdc_q1_setpoint_mvar", "hvdc_q2_setpoint_mvar", + "hvdc_min_q1", "hvdc_max_q1", "hvdc_min_q2", "hvdc_max_q2", + "hvdc_power_factor1", "hvdc_power_factor2", "hvdc_converters_mode", + "hvdc_p_setpoint_mw", "hvdc_r_ohm", "hvdc_nominal_v_kv", + "hvdc_droop_enabled", "hvdc_droop_p0_mw", "hvdc_droop_mw_per_deg", + "hvdc_pmax_1to2_mw", "hvdc_pmax_2to1_mw"], + "init_storages": ["storage_p", "storage_q", "storage_bus_id"], +} + + +# --------------------------------------------------------------------------- +# 3. rendering +# --------------------------------------------------------------------------- + +_LICENSE_PY = '''\ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. +''' + +_LICENSE_CPP = '''\ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. +''' + +_REGEN_COMMAND = "python -m lightsim2grid.tests._gen_exotic_elements_case" + + +def _generation_stamp(): + """One line, reused verbatim (same day, same tool versions) in both generated + files, so a diff of either one always shows when, how, and against which + lightsim2grid/pypowsybl versions it was last regenerated.""" + today = datetime.date.today().isoformat() + ls_version = lightsim2grid.__version__ + pp_version = pypowsybl.__version__ + return (f"Generated on {today} using `{_REGEN_COMMAND}` " + f"(lightsim2grid {ls_version}, pypowsybl {pp_version}).") + + +def render_python(call_log, stamp): + lines = [_LICENSE_PY] + lines.append('"""AUTO-GENERATED by lightsim2grid/tests/_gen_exotic_elements_case.py -- DO NOT EDIT BY HAND.') + lines.append('') + lines.append('Reproduces the grid built by `_exotic_elements_fixture.build_exotic_elements_grid()` (IEEE14 +') + lines.append('SVC + storage + 3 HVDC lines + a phase-shifting transformer) via literal `LSGrid.init_*`/') + lines.append('`add_*`/`set_*` calls captured from a real `init_from_pypowsybl` run -- no pypowsybl/') + lines.append('pandapower/grid2op dependency at runtime, only numpy + lightsim2grid.') + lines.append('') + lines.append(stamp) + lines.append('"""') + lines.append('') + lines.append('import numpy as np') + lines.append('') + lines.append('from lightsim2grid.network import LSGrid') + lines.append('') + lines.append('') + lines.append('def build_exotic_elements_case_grid() -> LSGrid:') + lines.append(' """Unsolved: callers run their own ac_pf / dc_pf, same contract as') + lines.append(' `_exotic_elements_fixture.build_exotic_elements_grid(solve=False)`."""') + lines.append(' model = LSGrid()') + lines.append('') + for name, args in call_log: + arg_strs = [_py_format_value(a) for a in args] + if not arg_strs: + lines.append(f" model.{name}()") + continue + lines.append(f" model.{name}(") + for i, a in enumerate(arg_strs): + comma = "," if i < len(arg_strs) - 1 else "" + lines.append(f" {a}{comma}") + lines.append(" )") + lines.append('') + lines.append(' return model') + lines.append('') + return "\n".join(lines) + + +def render_cpp(call_log, stamp): + lines = [_LICENSE_CPP] + lines.append('#ifndef LS2G_CASE_EXOTIC_ELEMENTS_H') + lines.append('#define LS2G_CASE_EXOTIC_ELEMENTS_H') + lines.append('') + lines.append('// AUTO-GENERATED by lightsim2grid/tests/_gen_exotic_elements_case.py -- DO NOT EDIT BY HAND.') + lines.append('//') + lines.append('// Reproduces the grid built by lightsim2grid.tests._exotic_elements_fixture.') + lines.append('// build_exotic_elements_grid() (IEEE14 + SVC + storage + 3 HVDC lines + a phase-shifting') + lines.append('// transformer) via literal LSGrid init_*/add_*/set_* calls captured from a real') + lines.append('// init_from_pypowsybl run -- no pypowsybl/pandapower/grid2op dependency, pure C++.') + lines.append('//') + lines.append(f'// {stamp}') + lines.append('') + lines.append('#include ') + lines.append('#include ') + lines.append('') + lines.append('#include "LSGrid.hpp"') + lines.append('') + lines.append('namespace ls2g_test {') + lines.append('') + lines.append('inline ls2g::LSGrid make_exotic_elements_grid()') + lines.append('{') + lines.append(' using ls2g::LSGrid;') + lines.append(' using ls2g::RealVect;') + lines.append(' using ls2g::CplxVect;') + lines.append(' using ls2g::real_type;') + lines.append('') + lines.append(' LSGrid grid;') + lines.append('') + for name, args in call_log: + param_names = CPP_PARAM_NAMES.get(name) + call_arg_exprs = [] + for i, a in enumerate(args): + varname = param_names[i] if param_names and i < len(param_names) else f"{name}_arg{i}" + decl_lines, expr = _cpp_format_arg(varname, a) + for decl_line in decl_lines: + lines.append(f" {decl_line}") + call_arg_exprs.append(expr) + if call_arg_exprs: + lines.append(f" grid.{name}(" + ", ".join(call_arg_exprs) + ");") + else: + lines.append(f" grid.{name}();") + lines.append('') + lines.append(' return grid;') + lines.append('}') + lines.append('') + lines.append('} // namespace ls2g_test') + lines.append('') + lines.append('#endif // LS2G_CASE_EXOTIC_ELEMENTS_H') + lines.append('') + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# 4. entry point +# --------------------------------------------------------------------------- + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PY_CASE_PATH = os.path.join(_REPO_ROOT, "lightsim2grid", "tests", "_exotic_elements_case_ls.py") +CPP_CASE_PATH = os.path.join(_REPO_ROOT, "src", "tests", "case_exotic_elements.hpp") + + +def main(): + call_log = _capture_exotic_elements_calls() + stamp = _generation_stamp() + + py_src = render_python(call_log, stamp) + with open(PY_CASE_PATH, "w") as f: + f.write(py_src) + print(f"wrote {PY_CASE_PATH} ({len(call_log)} calls)") + + cpp_src = render_cpp(call_log, stamp) + with open(CPP_CASE_PATH, "w") as f: + f.write(cpp_src) + print(f"wrote {CPP_CASE_PATH} ({len(call_log)} calls)") + + +if __name__ == "__main__": + main() diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 7a683f21..681d4ce4 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -19,7 +19,10 @@ from lightsim2grid.lightSimBackend import LightSimBackend sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _exotic_elements_fixture import build_exotic_elements_grid # noqa: E402 +# generated, dependency-free reproduction of _exotic_elements_fixture's grid (see +# _gen_exotic_elements_case.py) -- deliberately NOT `_exotic_elements_fixture` itself, +# so this whole test module stays importable and runnable without pypowsybl installed +from _exotic_elements_case_ls import build_exotic_elements_case_grid # noqa: E402 from lightsim2grid.network.compare_lsgrid import ( compare_network_input, @@ -35,6 +38,19 @@ _compare_svcs) +def _solved_exotic_case_grid(): + """`build_exotic_elements_case_grid()` is unsolved by construction (callers run + their own powerflow); the comparisons below only read input attributes, but a + flat-start ac_pf is run anyway to match the pypowsybl-based fixture's own + default (`build_exotic_elements_grid(solve=True)`) as closely as possible.""" + grid = build_exotic_elements_case_grid() + v0 = np.ones(grid.total_bus(), dtype=complex) + conv = grid.ac_pf(v0, 20, 1e-7) + if len(conv) == 0: + raise RuntimeError("_solved_exotic_case_grid: ac_pf did not converge; this is a bug") + return grid + + class TestBinarySerialization(unittest.TestCase): """Round-trip tests for LSGrid.save_binary / LSGrid.load_binary, the fast additive alternative to pickle. Mirrors the structure of test_pickleable.py @@ -149,9 +165,9 @@ def test_save_load(self): def _aux_test_binary(self, fun_name, fun_comp, grid=None): """`grid` defaults to a full l2rpn_idf_2023 grid2op env's LSGrid; pass one - explicitly (eg `build_exotic_elements_grid()`) to test an element type + explicitly (eg `_solved_exotic_case_grid()`) to test an element type l2rpn_idf_2023 does not carry any of (SVC, HVDC -- see - `_exotic_elements_fixture.py`).""" + `_exotic_elements_fixture.py` / `_exotic_elements_case_ls.py`).""" if grid is None: with warnings.catch_warnings(): warnings.filterwarnings("ignore") @@ -195,12 +211,12 @@ def test_binary_sgens(self): def test_binary_hvdc(self): # l2rpn_idf_2023 carries no HVDC line at all -- use the exotic-elements - # fixture instead, which has 3 (VSC droop, VSC no-droop, LCC). - self._aux_test_binary("get_dclines", _compare_dclines, grid=build_exotic_elements_grid()) + # case grid instead, which has 3 (VSC droop, VSC no-droop, LCC). + self._aux_test_binary("get_dclines", _compare_dclines, grid=_solved_exotic_case_grid()) def test_binary_svcs(self): - # l2rpn_idf_2023 carries no SVC at all -- use the exotic-elements fixture. - self._aux_test_binary("get_svcs", _compare_svcs, grid=build_exotic_elements_grid()) + # l2rpn_idf_2023 carries no SVC at all -- use the exotic-elements case grid. + self._aux_test_binary("get_svcs", _compare_svcs, grid=_solved_exotic_case_grid()) def test_binary_algo_config(self): with warnings.catch_warnings(): diff --git a/lightsim2grid/tests/test_exotic_elements_case_ls.py b/lightsim2grid/tests/test_exotic_elements_case_ls.py new file mode 100644 index 00000000..7ab9414f --- /dev/null +++ b/lightsim2grid/tests/test_exotic_elements_case_ls.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Faithfulness check for the generated, dependency-free exotic-elements case +(`_exotic_elements_case_ls.build_exotic_elements_case_grid`, produced by +`_gen_exotic_elements_case.py`): its converged AC powerflow must match, to (near) +machine precision, the grid built the normal way through `init_from_pypowsybl` +(`_exotic_elements_fixture.build_exotic_elements_grid`). Requires pypowsybl only for +that reference build -- `build_exotic_elements_case_grid` itself does not. +""" + +import unittest + +import numpy as np + +try: + import pypowsybl # noqa: F401 + HAS_PYPOWSYBL = True +except ImportError: + HAS_PYPOWSYBL = False + +from lightsim2grid.tests._exotic_elements_case_ls import build_exotic_elements_case_grid + + +class TestExoticElementsCaseLS(unittest.TestCase): + def test_case_builds_and_converges_without_pypowsybl(self): + grid = build_exotic_elements_case_grid() + v0 = np.ones(grid.total_bus(), dtype=complex) + conv = grid.ac_pf(v0, 20, 1e-7) + self.assertNotEqual(len(conv), 0, "the generated case grid did not converge") + + @unittest.skipUnless(HAS_PYPOWSYBL, "pypowsybl is not installed") + def test_case_matches_pypowsybl_build(self): + from lightsim2grid.tests._exotic_elements_fixture import build_exotic_elements_grid + + grid_ref = build_exotic_elements_grid(solve=True) + grid_case = build_exotic_elements_case_grid() + v0 = np.ones(grid_case.total_bus(), dtype=complex) + conv = grid_case.ac_pf(v0, 20, 1e-7) + self.assertNotEqual(len(conv), 0, "the generated case grid did not converge") + + np.testing.assert_allclose(grid_case.get_V(), grid_ref.get_V(), rtol=0, atol=1e-10) + + +if __name__ == "__main__": + unittest.main() diff --git a/lightsim2grid/tests/test_pickleable.py b/lightsim2grid/tests/test_pickleable.py index 0610f974..d544d076 100644 --- a/lightsim2grid/tests/test_pickleable.py +++ b/lightsim2grid/tests/test_pickleable.py @@ -20,7 +20,10 @@ from lightsim2grid.lightSimBackend import LightSimBackend sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _exotic_elements_fixture import build_exotic_elements_grid # noqa: E402 +# generated, dependency-free reproduction of _exotic_elements_fixture's grid (see +# _gen_exotic_elements_case.py) -- deliberately NOT `_exotic_elements_fixture` itself, +# so this whole test module stays importable and runnable without pypowsybl installed +from _exotic_elements_case_ls import build_exotic_elements_case_grid # noqa: E402 from lightsim2grid.network.compare_lsgrid import ( compare_network_input, @@ -36,6 +39,19 @@ _compare_svcs) +def _solved_exotic_case_grid(): + """`build_exotic_elements_case_grid()` is unsolved by construction (callers run + their own powerflow); the comparisons below only read input attributes, but a + flat-start ac_pf is run anyway to match the pypowsybl-based fixture's own + default (`build_exotic_elements_grid(solve=True)`) as closely as possible.""" + grid = build_exotic_elements_case_grid() + v0 = np.ones(grid.total_bus(), dtype=complex) + conv = grid.ac_pf(v0, 20, 1e-7) + if len(conv) == 0: + raise RuntimeError("_solved_exotic_case_grid: ac_pf did not converge; this is a bug") + return grid + + class TestPickle(unittest.TestCase): def _aux_test_2sides(self, grid1, grid2, method_name, test_results=False): assert len(getattr(grid1, method_name)()) == len(getattr(grid2, method_name)()) @@ -196,9 +212,9 @@ def test_cannot_load_unfit_ls_version(self): def _aux_test_pickle(self, fun_name, fun_comp, check_old_version=True, grid=None): """`grid` defaults to a full l2rpn_idf_2023 grid2op env's LSGrid; pass one - explicitly (eg `build_exotic_elements_grid()`) to test an element type + explicitly (eg `_solved_exotic_case_grid()`) to test an element type l2rpn_idf_2023 does not carry any of (SVC, HVDC -- see - `_exotic_elements_fixture.py`). Only meaningful with `check_old_version=False`: + `_exotic_elements_fixture.py` / `_exotic_elements_case_ls.py`). Only meaningful with `check_old_version=False`: the `old_pickle/` legacy fixtures below were generated from an l2rpn_idf_2023 grid and have nothing to do with `grid`. """ @@ -263,14 +279,14 @@ def test_pickle_sgens(self): def test_pickle_hvdc(self): # l2rpn_idf_2023 carries no HVDC line at all -- use the exotic-elements - # fixture instead, which has 3 (VSC droop, VSC no-droop, LCC). + # case grid instead, which has 3 (VSC droop, VSC no-droop, LCC). self._aux_test_pickle("get_dclines", _compare_dclines, check_old_version=False, - grid=build_exotic_elements_grid()) + grid=_solved_exotic_case_grid()) def test_pickle_svcs(self): - # l2rpn_idf_2023 carries no SVC at all -- use the exotic-elements fixture. + # l2rpn_idf_2023 carries no SVC at all -- use the exotic-elements case grid. self._aux_test_pickle("get_svcs", _compare_svcs, check_old_version=False, - grid=build_exotic_elements_grid()) + grid=_solved_exotic_case_grid()) if __name__ == "__main__": diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 365744e7..cc3ce4a9 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -100,18 +100,6 @@ class LS2G_API AlgorithmSelector final (type == AlgorithmType::DC_NICSLU) || (type == AlgorithmType::DC_CKTSO); } - // only the Newton-Raphson algorithms implement the hvdc angle-droop - // ("AC emulation") equations; plugin (Custom) solvers are assumed not to - bool supports_hvdc_droop(const AlgorithmType& type) const noexcept { - return (type == AlgorithmType::NR_SparseLU) || - (type == AlgorithmType::NR_KLU) || - (type == AlgorithmType::NR_NICSLU) || - (type == AlgorithmType::NR_CKTSO) || - (type == AlgorithmType::NRSing_SparseLU) || - (type == AlgorithmType::NRSing_KLU) || - (type == AlgorithmType::NRSing_NICSLU) || - (type == AlgorithmType::NRSing_CKTSO); - } bool is_fdpf(const AlgorithmType& type) const noexcept { return (type == AlgorithmType::FDPF_XB_SparseLU) || (type == AlgorithmType::FDPF_BX_SparseLU) || @@ -125,6 +113,18 @@ class LS2G_API AlgorithmSelector final AlgorithmType get_type() const { return _algo_type; } + // Polymorphic (instance-level) capability queries: unlike the + // type-keyed overloads above (needed by LSGrid::change_algorithm to + // route BEFORE a solver is constructed), these ask the CURRENTLY + // active `_algo` -- so they work for plugin (Custom) solvers too, + // via BaseAlgo's virtual capability accessors (see BaseAlgo.hpp). + bool supports_hvdc_droop() const { + return get_prt_solver("supports_hvdc_droop", false)->supports_hvdc_droop(); + } + bool supports_remote_voltage_control() const { + return get_prt_solver("supports_remote_voltage_control", false)->supports_remote_voltage_control(); + } + // Convenience accessors for the two FDPF SparseLU variants. // These exist mainly for internal diagnostic use (exposed as AlgorithmSelector.get_fdpf_*). // Defined in AlgorithmSelector.cpp to avoid implicit instantiation of BaseFDPFAlgo in every TU. diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 24539c7c..03aef778 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -400,7 +400,7 @@ CplxVect LSGrid::ac_pf(const Eigen::Ref & Vinit, throw std::runtime_error(exc_.str()); } BaseAlgo::check_iter_tol("LSGrid::ac_pf", max_iter, tol); - if(hvdc_lines_.has_droop_active() && !_algo.supports_hvdc_droop(_algo.get_type())){ + if(hvdc_lines_.has_droop_active() && !_algo.supports_hvdc_droop()){ std::ostringstream exc_; exc_ << "LSGrid::ac_pf: the grid counts hvdc lines with the angle-droop (AC emulation) enabled, "; exc_ << "which is only supported by the Newton-Raphson algorithms (not by the Gauss-Seidel / "; diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index d439b503..78848a04 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -230,6 +230,36 @@ class LS2G_API BaseAlgo : public BaseConstants // initial guess of 0 -- this is the ground truth after convergence. virtual real_type get_slack_absorbed() const { return static_cast(0.); } + // ---- capability flags ------------------------------------------------- + // Each concrete solver family declares its own `static constexpr bool` + // (queryable at compile time even without an instance, e.g. + // `NRAlgo::SUPPORTS_HVDC_DROOP`) + // and overrides the matching virtual accessor below with `return + // ITS_OWN_CONSTANT;` so that code holding only a `unique_ptr` + // (AlgorithmSelector, including plugin/Custom solvers) can query the + // capability polymorphically. Defaults here are the conservative + // "capability absent" value -- a plugin that does not override stays + // maximally restricted, exactly like today's built-in Gauss-Seidel. + static constexpr bool IS_DC = false; + static constexpr bool SUPPORTS_HVDC_DROOP = false; + static constexpr bool IS_FDPF = false; + static constexpr bool SUPPORTS_REMOTE_VOLTAGE_CONTROL = false; + + virtual bool is_dc() const noexcept { return IS_DC; } + // Only the Newton-Raphson algorithms implement the hvdc angle-droop + // ("AC emulation") equations (their NRSystem includes the Hvdc + // extension); Gauss-Seidel / Fast-Decoupled do not, and a plugin + // (Custom) solver defaults to `false` unless it overrides this -- + // see LSGrid::ac_pf's angle-droop guard. + virtual bool supports_hvdc_droop() const noexcept { return SUPPORTS_HVDC_DROOP; } + virtual bool is_fdpf() const noexcept { return IS_FDPF; } + // Only the Newton-Raphson algorithms implement remote voltage control + // (generators/SVCs regulating a bus other than their own -- their + // NRSystem includes the VoltageControl extension, see + // LSGrid::fill_voltage_control_solver_data). Gauss-Seidel / + // Fast-Decoupled / DC do not consume that data at all. + virtual bool supports_remote_voltage_control() const noexcept { return SUPPORTS_REMOTE_VOLTAGE_CONTROL; } + Eigen::Ref get_Va() const{ return Va_; } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index 93adf648..a45cb336 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -40,6 +40,9 @@ class BaseDCAlgo final: public BaseAlgo ~BaseDCAlgo() noexcept override = default; + static constexpr bool IS_DC = true; + bool is_dc() const noexcept override { return IS_DC; } + void reset() override; void reset_timer() override{ BaseAlgo::reset_timer(); diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 3a766cda..fb57268c 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -23,6 +23,9 @@ class BaseFDPFAlgo final: public BaseAlgo BaseFDPFAlgo() noexcept :BaseAlgo(true), need_factorize_(true) {} ~BaseFDPFAlgo() noexcept override = default; + static constexpr bool IS_FDPF = true; + bool is_fdpf() const noexcept override { return IS_FDPF; } + bool compute_pf(const Eigen::SparseMatrix & Ybus, CplxVect & V, Eigen::Ref Sbus, diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 5f392cfa..61944f4a 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -54,6 +54,18 @@ class NRAlgo final : public BaseAlgo ~NRAlgo() noexcept override = default; + // Both SingleSlackNRSystem and MultiSlackNRSystem include the Hvdc + // extension (see NRSystem.hpp), so every NRAlgo instantiation supports + // the angle-droop ("AC emulation") equations regardless of NRSystem. + static constexpr bool SUPPORTS_HVDC_DROOP = true; + bool supports_hvdc_droop() const noexcept override { return SUPPORTS_HVDC_DROOP; } + + // Both SingleSlackNRSystem and MultiSlackNRSystem also include the + // VoltageControl extension (remote gen / SVC regulation), so every + // NRAlgo instantiation supports it regardless of NRSystem. + static constexpr bool SUPPORTS_REMOTE_VOLTAGE_CONTROL = true; + bool supports_remote_voltage_control() const noexcept override { return SUPPORTS_REMOTE_VOLTAGE_CONTROL; } + // ----- Jacobian accessor --------------------------------------------------- Eigen::Ref> get_J() const override { diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 3fff646d..9156934f 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -54,6 +54,7 @@ add_executable(lightsim2grid_unit_tests test_binary_archive_stateres.cpp test_binary_archive_corruption.cpp test_lsgrid.cpp + test_case_exotic_elements.cpp ) target_link_libraries(lightsim2grid_unit_tests PRIVATE lightsim2grid_core Catch2::Catch2WithMain) diff --git a/src/tests/case_exotic_elements.hpp b/src/tests/case_exotic_elements.hpp new file mode 100644 index 00000000..87a12f01 --- /dev/null +++ b/src/tests/case_exotic_elements.hpp @@ -0,0 +1,203 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef LS2G_CASE_EXOTIC_ELEMENTS_H +#define LS2G_CASE_EXOTIC_ELEMENTS_H + +// AUTO-GENERATED by lightsim2grid/tests/_gen_exotic_elements_case.py -- DO NOT EDIT BY HAND. +// +// Reproduces the grid built by lightsim2grid.tests._exotic_elements_fixture. +// build_exotic_elements_grid() (IEEE14 + SVC + storage + 3 HVDC lines + a phase-shifting +// transformer) via literal LSGrid init_*/add_*/set_* calls captured from a real +// init_from_pypowsybl run -- no pypowsybl/pandapower/grid2op dependency, pure C++. +// +// Generated on 2026-07-16 using `python -m lightsim2grid.tests._gen_exotic_elements_case` (lightsim2grid 1.0.0.rc2, pypowsybl 1.15.0). + +#include +#include + +#include "LSGrid.hpp" + +namespace ls2g_test { + +inline ls2g::LSGrid make_exotic_elements_grid() +{ + using ls2g::LSGrid; + using ls2g::RealVect; + using ls2g::CplxVect; + using ls2g::real_type; + + LSGrid grid; + + grid.set_sn_mva(100.0); + + grid.set_init_vm_pu(1.06); + + RealVect bus_vn_kv(14); + bus_vn_kv << 135.0, 12.0, 12.0, 12.0, 12.0, 12.0, 135.0, 135.0, 135.0, 135.0, 12.0, 14.0, 20.0, 12.0; + grid.init_bus(14, 1, bus_vn_kv, 0, 0); + + RealVect gen_p(5); + gen_p << 232.4, 40.0, 0.0, 0.0, 0.0; + RealVect gen_v_pu(5); + gen_v_pu << 1.06, 1.045, 1.01, 1.07, 1.09; + RealVect gen_q(5); + gen_q << -16.9, 42.4, 23.4, 12.2, 17.4; + const std::vector gen_voltage_regulator_on{true, true, true, true, true}; + RealVect gen_min_q(5); + gen_min_q << -3.4028233356588204e+34, -40.0, 0.0, -6.0, -6.0; + RealVect gen_max_q(5); + gen_max_q << 3.4028233356588204e+34, 50.0, 40.0, 24.0, 24.0; + Eigen::VectorXi gen_bus_id(5); + gen_bus_id << 0, 6, 7, 10, 12; + grid.init_generators_full(gen_p, gen_v_pu, gen_q, gen_voltage_regulator_on, gen_min_q, gen_max_q, gen_bus_id); + + RealVect load_p(11); + load_p << 9.0, 3.5, 6.1, 13.5, 14.9, 21.7, 94.2, 47.8, 7.6, 11.2, 29.5; + RealVect load_q(11); + load_q << 5.8, 1.8, 1.6, 5.8, 5.0, 12.7, 19.0, -3.9, 1.6, 7.5, 16.6; + Eigen::VectorXi load_bus_id(11); + load_bus_id << 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13; + grid.init_loads(load_p, load_q, load_bus_id); + + RealVect line_r(17); + line_r << 0.01938, 0.054029999999999995, 0.08205, 0.22091999999999998, 0.17093, 0.04698999999999999, 0.058109999999999995, 0.05694999999999999, 0.06701, 0.01335, 0.09497999999999998, 0.12290999999999999, 0.06615, 0.0, 0.0, 0.03180999999999999, 0.12711; + RealVect line_x(17); + line_x << 0.05916999999999999, 0.22304, 0.19206999999999996, 0.19987999999999995, 0.34801999999999994, 0.19797, 0.17632, 0.17388, 0.17102999999999996, 0.042109999999999995, 0.1989, 0.25581, 0.13026999999999997, 0.17615000000000003, 0.11001, 0.08449999999999999, 0.27038; + CplxVect line_h1(17); + line_h1 << std::complex(0.0, 0.0264), std::complex(0.0, 0.0246), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0219), std::complex(0.0, 0.017), std::complex(0.0, 0.0173), std::complex(0.0, 0.0064), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 5.684341886080802e-16), std::complex(0.0, 0.0), std::complex(0.0, 0.0); + CplxVect line_h2(17); + line_h2 << std::complex(0.0, 0.0264), std::complex(0.0, 0.0246), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0219), std::complex(0.0, 0.017), std::complex(0.0, 0.0173), std::complex(0.0, 0.0064), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 2.842170943040401e-16), std::complex(0.0, -5.684341886080802e-16), std::complex(0.0, 0.0), std::complex(0.0, 0.0); + Eigen::VectorXi line_from_id(17); + line_from_id << 0, 0, 1, 3, 4, 6, 6, 6, 7, 8, 10, 10, 10, 11, 11, 13, 13; + Eigen::VectorXi line_to_id(17); + line_to_id << 6, 9, 2, 4, 5, 7, 8, 9, 8, 9, 2, 3, 4, 12, 13, 1, 5; + grid.init_powerlines_full(line_r, line_x, line_h1, line_h2, line_from_id, line_to_id); + + RealVect trafo_r(3); + trafo_r << 0.0, 0.0, 0.0; + RealVect trafo_x(3); + trafo_x << 0.20912, 0.55618, 0.25202; + CplxVect trafo_b(3); + trafo_b << std::complex(0.0, 0.0), std::complex(0.0, 0.0), std::complex(0.0, 0.0); + RealVect trafo_ratio(3); + trafo_ratio << 1.0224948875255624, 1.0319917440660473, 1.0729613733905579; + RealVect trafo_shift_degree(3); + trafo_shift_degree << 0.0, 0.0, 0.0; + const std::vector trafo_tap_hv{false, false, false}; + Eigen::VectorXi trafo_bus1_id(3); + trafo_bus1_id << 8, 8, 9; + Eigen::VectorXi trafo_bus2_id(3); + trafo_bus2_id << 11, 13, 10; + grid.init_trafo(trafo_r, trafo_x, trafo_b, trafo_ratio, trafo_shift_degree, trafo_tap_hv, trafo_bus1_id, trafo_bus2_id, false); + + const std::vector> trafo_shift_alpha_rad = { + {-0.08726646259971647, 0.0, 0.08726646259971647}, + {}, + {}, + }; + const std::vector> trafo_shift_rx_corr_pct = { + {0.0, 0.0, 0.0}, + {}, + {}, + }; + grid.set_trafo_shift_dependent_rx(true, trafo_shift_alpha_rad, trafo_shift_rx_corr_pct); + + RealVect shunt_p_mw(1); + shunt_p_mw << 0.0; + RealVect shunt_q_mvar(1); + shunt_q_mvar << -19.0; + Eigen::VectorXi shunt_bus_id(1); + shunt_bus_id << 13; + grid.init_shunt(shunt_p_mw, shunt_q_mvar, shunt_bus_id); + + const std::vector svc_regulation_mode{1}; + RealVect svc_target_vm_pu(1); + svc_target_vm_pu << 1.0; + RealVect svc_q_setpoint_mvar(1); + svc_q_setpoint_mvar << -0.0; + RealVect svc_slope_pu(1); + svc_slope_pu << 0.08333333333333333; + RealVect svc_b_min(1); + svc_b_min << -0.0288; + RealVect svc_b_max(1); + svc_b_max << 0.0288; + Eigen::VectorXi svc_regulated_bus_id(1); + svc_regulated_bus_id << 5; + Eigen::VectorXi svc_bus_id(1); + svc_bus_id << 5; + grid.init_svcs(svc_regulation_mode, svc_target_vm_pu, svc_q_setpoint_mvar, svc_slope_pu, svc_b_min, svc_b_max, svc_regulated_bus_id, svc_bus_id); + + Eigen::VectorXi hvdc_bus1_id(3); + hvdc_bus1_id << 5, 1, 3; + Eigen::VectorXi hvdc_bus2_id(3); + hvdc_bus2_id << 4, 2, 13; + const std::vector hvdc_type1{1, 0, 0}; + const std::vector hvdc_type2{1, 0, 0}; + RealVect hvdc_loss_factor1(3); + hvdc_loss_factor1 << 0.01100000023841858, 0.01100000023841858, 0.01100000023841858; + RealVect hvdc_loss_factor2(3); + hvdc_loss_factor2 << 0.01100000023841858, 0.01100000023841858, 0.01100000023841858; + const std::vector hvdc_vreg1_on{false, true, true}; + const std::vector hvdc_vreg2_on{false, true, true}; + RealVect hvdc_vm1_pu(3); + hvdc_vm1_pu << 1.0, 1.0, 1.0; + RealVect hvdc_vm2_pu(3); + hvdc_vm2_pu << 1.0, 1.0, 1.0; + RealVect hvdc_q1_setpoint_mvar(3); + hvdc_q1_setpoint_mvar << 0.0, 0.0, 0.0; + RealVect hvdc_q2_setpoint_mvar(3); + hvdc_q2_setpoint_mvar << 0.0, 0.0, 0.0; + RealVect hvdc_min_q1(3); + hvdc_min_q1 << -10000000.0, -1.7976931348623157e+308, -1.7976931348623157e+308; + RealVect hvdc_max_q1(3); + hvdc_max_q1 << 10000000.0, 1.7976931348623157e+308, 1.7976931348623157e+308; + RealVect hvdc_min_q2(3); + hvdc_min_q2 << -10000000.0, -1.7976931348623157e+308, -1.7976931348623157e+308; + RealVect hvdc_max_q2(3); + hvdc_max_q2 << 10000000.0, 1.7976931348623157e+308, 1.7976931348623157e+308; + RealVect hvdc_power_factor1(3); + hvdc_power_factor1 << 0.8999999761581421, 1.0, 1.0; + RealVect hvdc_power_factor2(3); + hvdc_power_factor2 << 0.8999999761581421, 1.0, 1.0; + const std::vector hvdc_converters_mode{0, 0, 0}; + RealVect hvdc_p_setpoint_mw(3); + hvdc_p_setpoint_mw << 1.0, 2.0, 2.0; + RealVect hvdc_r_ohm(3); + hvdc_r_ohm << 1.0, 1.0, 1.0; + RealVect hvdc_nominal_v_kv(3); + hvdc_nominal_v_kv << 12.0, 12.0, 12.0; + const std::vector hvdc_droop_enabled{false, true, false}; + RealVect hvdc_droop_p0_mw(3); + hvdc_droop_p0_mw << 0.0, 1.0, 0.0; + RealVect hvdc_droop_mw_per_deg(3); + hvdc_droop_mw_per_deg << 0.0, 180.0, 0.0; + RealVect hvdc_pmax_1to2_mw(3); + hvdc_pmax_1to2_mw << 20.0, 20.0, 20.0; + RealVect hvdc_pmax_2to1_mw(3); + hvdc_pmax_2to1_mw << 20.0, 20.0, 20.0; + grid.init_hvdc_lines(hvdc_bus1_id, hvdc_bus2_id, hvdc_type1, hvdc_type2, hvdc_loss_factor1, hvdc_loss_factor2, hvdc_vreg1_on, hvdc_vreg2_on, hvdc_vm1_pu, hvdc_vm2_pu, hvdc_q1_setpoint_mvar, hvdc_q2_setpoint_mvar, hvdc_min_q1, hvdc_max_q1, hvdc_min_q2, hvdc_max_q2, hvdc_power_factor1, hvdc_power_factor2, hvdc_converters_mode, hvdc_p_setpoint_mw, hvdc_r_ohm, hvdc_nominal_v_kv, hvdc_droop_enabled, hvdc_droop_p0_mw, hvdc_droop_mw_per_deg, hvdc_pmax_1to2_mw, hvdc_pmax_2to1_mw); + + RealVect storage_p(1); + storage_p << -5.0; + RealVect storage_q(1); + storage_q << -1.0; + Eigen::VectorXi storage_bus_id(1); + storage_bus_id << 4; + grid.init_storages(storage_p, storage_q, storage_bus_id); + + grid.add_gen_slackbus(0, 1.0); + + grid.consider_only_main_component(); + + return grid; +} + +} // namespace ls2g_test + +#endif // LS2G_CASE_EXOTIC_ELEMENTS_H diff --git a/src/tests/test_case_exotic_elements.cpp b/src/tests/test_case_exotic_elements.cpp new file mode 100644 index 00000000..42a04849 --- /dev/null +++ b/src/tests/test_case_exotic_elements.cpp @@ -0,0 +1,129 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Exercises `case_exotic_elements.hpp` (an AUTO-GENERATED, dependency-free C++ +// reproduction of lightsim2grid.tests._exotic_elements_fixture.build_exotic_elements_grid(): +// IEEE14 + SVC + storage + 3 HVDC lines + a phase-shifting transformer) purely from C++, +// no pypowsybl / Python involved. The expected bus voltages below were captured once from +// the Python side (lightsim2grid.tests._exotic_elements_case_ls.build_exotic_elements_case_grid, +// which is bit-identical to the pypowsybl-built reference -- see +// lightsim2grid/tests/test_exotic_elements_case_ls.py) with a flat start and the same +// (20, 1e-7) ac_pf tolerance used here. + +#include +#include + +#include +#include + +#include "case_exotic_elements.hpp" + +using Catch::Approx; +using ls2g::CplxVect; +using ls2g::LSGrid; +using ls2g::RealVect; +using ls2g::cplx_type; +using ls2g::real_type; + +TEST_CASE("exotic elements case grid: AC powerflow converges", "[LSGrid][exotic_elements]") +{ + LSGrid grid = ls2g_test::make_exotic_elements_grid(); + const CplxVect v0 = CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); + const CplxVect V = grid.ac_pf(v0, 20, 1e-7); + + REQUIRE(V.size() == 14); + CHECK(grid.get_algo().converged()); + CHECK(grid.get_algo().get_error() == ls2g::ErrorType::NoError); + + // power balance sanity check: every load is served (nothing lost to a botched + // conversion), and the slack generator's output is strictly positive + const real_type total_load_p = std::get<0>(grid.get_loads_res()).sum(); + CHECK(total_load_p > 0.); + const real_type slack_p = std::get<0>(grid.get_gen_res())(0); + CHECK(slack_p > 0.); +} + +TEST_CASE("exotic elements case grid: bus voltages match the Python (pypowsybl) reference", + "[LSGrid][exotic_elements]") +{ + LSGrid grid = ls2g_test::make_exotic_elements_grid(); + const CplxVect v0 = CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); + const CplxVect V = grid.ac_pf(v0, 20, 1e-7); + REQUIRE(V.size() == 14); + + // captured from lightsim2grid.tests._exotic_elements_case_ls.build_exotic_elements_case_grid() + // (bit-identical to the pypowsybl-built reference, see test_exotic_elements_case_ls.py) + CplxVect expected(14); + expected << std::complex(1.06, 0.0), + std::complex(0.9687913157816583, -0.24787776517074517), + std::complex(0.9689940823590732, -0.24708392977508187), + std::complex(0.9711757063452228, -0.23836473607657993), + std::complex(0.99413798106925, -0.26631227837286225), + std::complex(0.9594083364855915, -0.2724673194933637), + std::complex(1.0411254584549987, -0.08990428108198613), + std::complex(0.9854269266425705, -0.22143570680397975), + std::complex(0.9940133137427848, -0.17587393368946724), + std::complex(1.002559976355528, -0.15245213766187748), + std::complex(1.0353772607437275, -0.2699887552006848), + std::complex(1.0079901894554393, -0.22846919125963108), + std::complex(1.0630358198274958, -0.24094573198893576), + std::complex(0.9692026875332204, -0.2462643914137463); + + // tolerance matches ac_pf's own convergence tolerance (1e-7) + for (Eigen::Index i = 0; i < expected.size(); ++i) { + CHECK(V(i).real() == Approx(expected(i).real()).margin(1e-7)); + CHECK(V(i).imag() == Approx(expected(i).imag()).margin(1e-7)); + } +} + +TEST_CASE("exotic elements case grid: per-element SVC / HVDC / storage results match the Python reference", + "[LSGrid][exotic_elements]") +{ + // captured from lightsim2grid.tests._exotic_elements_case_ls.build_exotic_elements_case_grid() + // (grid.get_svcs() / grid.get_dclines() / grid.get_storages(), same solve as the TEST_CASE above) + // -- this exercises the 3 HVDC lines (LCC, VSC+droop, VSC no-droop), the SVC, and the storage + // unit individually, rather than only their combined effect on bus voltages. + LSGrid grid = ls2g_test::make_exotic_elements_grid(); + const CplxVect v0 = CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); + const CplxVect V = grid.ac_pf(v0, 20, 1e-7); + REQUIRE(V.size() == 14); + + // SVC1: VOLTAGE mode, target 1.0 pu -- res_p_mw is always 0 (SVCs are reactive-only), + // res_q_mvar is the reactive injection needed to hold the target + const auto svc_res = grid.get_svcs().get_res(); // (p_mw, q_mvar, v_kv) + REQUIRE(std::get<0>(svc_res).size() == 1); + CHECK(std::get<0>(svc_res)(0) == Approx(0.).margin(1e-7)); + CHECK(std::get<1>(svc_res)(0) == Approx(3.1825424542277485).margin(1e-6)); + + // 3 HVDC lines, in the order they were captured: [0] HVDC_LCC, [1] HVDC_VSC_DROOP, + // [2] HVDC_VSC_NODROOP (see init_hvdc_lines' type1/type2/droop_enabled arrays) + const auto dc_res1 = grid.get_dcline_res1(); // side 1 (p_mw, q_mvar, v_kv) + const auto dc_res2 = grid.get_dcline_res2(); // side 2 + REQUIRE(std::get<0>(dc_res1).size() == 3); + + RealVect expected_p1(3), expected_q1(3), expected_p2(3), expected_q2(3); + expected_p1 << -1.0, 6.914030838034747, -2.0; + expected_q1 << -0.48432217236497815, 6.60537787015234, -34.86344125029247; + expected_p2 << 0.9714032101652108, -7.449867311206058, 1.9293708416040272; + expected_q2 << -0.47047211298952835, -28.13849408754504, -43.2734977392165; + + for (Eigen::Index i = 0; i < 3; ++i) { + CHECK(std::get<0>(dc_res1)(i) == Approx(expected_p1(i)).margin(1e-6)); + CHECK(std::get<1>(dc_res1)(i) == Approx(expected_q1(i)).margin(1e-6)); + CHECK(std::get<0>(dc_res2)(i) == Approx(expected_p2(i)).margin(1e-6)); + CHECK(std::get<1>(dc_res2)(i) == Approx(expected_q2(i)).margin(1e-6)); + } + + // BATT1: fixed setpoint (not solved for), load-sign convention -- negative means + // it is producing (IIDM battery target_p=5.0/target_q=1.0, generator convention, + // negated on conversion, see _aux_add_storage.py) + const auto storage_res = grid.get_storages_res(); // (p_mw, q_mvar, v_kv) + REQUIRE(std::get<0>(storage_res).size() == 1); + CHECK(std::get<0>(storage_res)(0) == Approx(-5.).margin(1e-7)); + CHECK(std::get<1>(storage_res)(0) == Approx(-1.).margin(1e-7)); +} diff --git a/src/tests/test_lsgrid.cpp b/src/tests/test_lsgrid.cpp index 4953f9bf..b09a798e 100644 --- a/src/tests/test_lsgrid.cpp +++ b/src/tests/test_lsgrid.cpp @@ -25,6 +25,7 @@ #include #include "LSGrid.hpp" +#include "case_exotic_elements.hpp" #include "test_helpers.hpp" using Catch::Approx; @@ -269,15 +270,19 @@ TEST_CASE("get_state / set_state round-trips the whole grid", "[LSGrid]") TEST_CASE("save_binary / load_binary round-trips the whole grid", "[LSGrid]") { - LSGrid grid = make_three_bus_grid(); + // the exotic-elements case (IEEE14 + SVC + storage + 3 HVDC lines + a + // phase-shifting transformer, see src/tests/case_exotic_elements.hpp) rather + // than the 3-bus skeleton, so this round trip actually exercises every + // element type LSGrid knows how to serialize, not just lines/loads/a slack gen. + LSGrid grid = ls2g_test::make_exotic_elements_grid(); ls2g_test::TempFile file; grid.save_binary(file.str()); LSGrid loaded = LSGrid::load_binary(file.str()); const CplxVect V = solve_ac(grid); const CplxVect V_loaded = solve_ac(loaded); - REQUIRE(V.size() == 3); - REQUIRE(V_loaded.size() == 3); + REQUIRE(V.size() == 14); + REQUIRE(V_loaded.size() == 14); CHECK((V - V_loaded).norm() < 1e-10); } From 631d5aa9b2bed2a36fd582eb1f2d5c73a4546bc7 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 16 Jul 2026 11:08:05 +0200 Subject: [PATCH 131/166] fix a compilation issue Signed-off-by: DONNOT Benjamin --- .../batch_algorithm/ContingencyAnalysis.cpp | 1 + .../element_container/HvdcLineContainer.hpp | 18 ++++++++-------- src/core/element_container/LoadContainer.hpp | 21 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index 08fc019c..04febc84 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -32,6 +32,7 @@ void check_bus_voltage_violations( const Eigen::Ref & bus_vmin_kv, const Eigen::Ref & bus_vmax_kv, const Eigen::Ref & bus_vn_kv, + const SubstationContainer & subs, const std::vector * masked_solver_ids, std::vector & out) { diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index 371202bb..04264a05 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -348,20 +348,20 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer& bus_pv, - std::vector & has_bus_been_added, - const SolverBusIdVect & slack_bus_id_solver, - const SolverBusIdVect & id_grid_to_solver) const override { + std::vector & has_bus_been_added, + const SolverBusIdVect & slack_bus_id_solver, + const SolverBusIdVect & id_grid_to_solver) const override { side_1_.fillpv(bus_pv, has_bus_been_added, slack_bus_id_solver, id_grid_to_solver); side_2_.fillpv(bus_pv, has_bus_been_added, slack_bus_id_solver, id_grid_to_solver); } void fillBp_Bpp(std::vector > & /*Bp*/, - std::vector > & /*Bpp*/, - const SolverBusIdVect & /*id_grid_to_solver*/, - real_type /*sn_mva*/, - FDPFMethod /*xb_or_bx*/) const override { - // no Bp coeffs for hvdc lines - } + std::vector > & /*Bpp*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + real_type /*sn_mva*/, + FDPFMethod /*xb_or_bx*/) const override { + // no Bp coeffs for hvdc lines + } void init_q_vector(int nb_bus, Eigen::VectorXi & total_gen_per_bus, diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 966a041f..f9caf080 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -84,17 +84,16 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA protected: void _compute_results(const Eigen::Ref & /*Va*/, - const Eigen::Ref & /*Vm*/, - const Eigen::Ref & /*V*/, - const SolverBusIdVect & /*id_grid_to_solver*/, - const Eigen::Ref & /*bus_vn_kv*/, - real_type /*sn_mva*/, - bool ac) override - { - - set_osc_pq_res_p(); - set_osc_pq_res_q(ac); - } + const Eigen::Ref & /*Vm*/, + const Eigen::Ref & /*V*/, + const SolverBusIdVect & /*id_grid_to_solver*/, + const Eigen::Ref & /*bus_vn_kv*/, + real_type /*sn_mva*/, + bool ac) override + { + set_osc_pq_res_p(); + set_osc_pq_res_q(ac); + } }; inline LoadInfo::LoadInfo(const LoadContainer & r_data_load, int my_id) noexcept: From 6dea68640c29fef6c424600c6a0842e3e79b87e1 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 16 Jul 2026 15:14:09 +0200 Subject: [PATCH 132/166] adding some other eigen ref in the codebase Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 23 ++ .../dist_slack_algorithm/NRAlgoDistSlack.hpp | 20 +- .../external_algorithm/DummyExternalAlgo.cpp | 14 +- src/core/AlgorithmSelector.hpp | 25 +-- src/core/LSGrid.cpp | 20 +- src/core/LSGrid.hpp | 74 ++++--- src/core/SubstationContainer.hpp | 4 +- src/core/TaggedIdVec.hpp | 2 +- src/core/Utils.hpp | 4 + .../batch_algorithm/BaseBatchSolverSynch.cpp | 20 +- .../batch_algorithm/BaseBatchSolverSynch.hpp | 34 ++- .../batch_algorithm/ContingencyAnalysis.cpp | 18 +- .../batch_algorithm/ContingencyAnalysis.hpp | 2 +- src/core/batch_algorithm/TimeSeries.cpp | 10 +- src/core/batch_algorithm/TimeSeries.hpp | 12 +- .../ConverterStationContainer.cpp | 20 +- .../ConverterStationContainer.hpp | 20 +- .../element_container/GeneratorContainer.cpp | 24 +-- .../element_container/GeneratorContainer.hpp | 26 +-- .../element_container/GenericContainer.cpp | 4 +- .../element_container/GenericContainer.hpp | 8 +- .../element_container/HvdcLineContainer.cpp | 2 +- .../element_container/HvdcLineContainer.hpp | 24 +-- src/core/element_container/LoadContainer.cpp | 2 +- src/core/element_container/LoadContainer.hpp | 2 +- .../element_container/OneSideContainer.hpp | 8 +- .../OneSideContainer_forBranch.hpp | 6 +- src/core/element_container/SGenContainer.cpp | 2 +- src/core/element_container/SGenContainer.hpp | 2 +- src/core/element_container/ShuntContainer.cpp | 2 +- src/core/element_container/ShuntContainer.hpp | 2 +- .../element_container/StorageContainer.cpp | 2 +- .../element_container/StorageContainer.hpp | 2 +- src/core/element_container/SvcContainer.cpp | 6 +- src/core/element_container/SvcContainer.hpp | 6 +- src/core/element_container/TrafoContainer.cpp | 2 +- src/core/element_container/TrafoContainer.hpp | 2 +- .../element_container/TwoSidesContainer.hpp | 12 +- .../TwoSidesContainer_rxh_A.hpp | 4 +- src/core/linear_solvers/CKTSOSolver.cpp | 2 +- src/core/linear_solvers/CKTSOSolver.hpp | 2 +- src/core/linear_solvers/KLUSolver.cpp | 2 +- src/core/linear_solvers/KLUSolver.hpp | 2 +- src/core/linear_solvers/NICSLUSolver.cpp | 2 +- src/core/linear_solvers/NICSLUSolver.hpp | 2 +- src/core/linear_solvers/SparseLUSolver.cpp | 2 +- src/core/linear_solvers/SparseLUSolver.hpp | 2 +- src/core/powerflow_algorithm/BaseAlgo.cpp | 90 ++++---- src/core/powerflow_algorithm/BaseAlgo.hpp | 165 +++++++++------ src/core/powerflow_algorithm/BaseDCAlgo.hpp | 26 ++- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 25 ++- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 83 ++++---- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 45 ++-- .../powerflow_algorithm/GaussSeidelAlgo.cpp | 30 +-- .../powerflow_algorithm/GaussSeidelAlgo.hpp | 32 +-- .../GaussSeidelSynchAlgo.cpp | 9 +- .../GaussSeidelSynchAlgo.hpp | 11 +- src/core/powerflow_algorithm/NRAlgo.hpp | 21 +- src/core/powerflow_algorithm/NRAlgo.tpp | 18 +- src/core/powerflow_algorithm/NRSystem.hpp | 198 +++++++++--------- src/core/powerflow_algorithm/NRSystem.tpp | 55 ++--- src/core/powerflow_algorithm/NRSystemBase.cpp | 8 +- src/core/powerflow_algorithm/NRSystemHvdc.cpp | 10 +- .../NRSystemMultiSlack.cpp | 10 +- .../NRSystemVoltageControl.cpp | 10 +- .../powerflow_algorithm/ScalingPolicies.hpp | 10 +- .../powerflow_algorithm/SlackPolicies.hpp | 82 ++++---- .../powerflow_algorithm/SlackPolicies.tpp | 82 ++++---- 68 files changed, 820 insertions(+), 688 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 74af4053..ef6ef2ee 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,29 @@ Change Log - code `helm` powerflow method - interface with gridpack (to enforce q limits for example) - maybe have a look at suitesparse "sliplu" tools ? +- pybind11's Eigen support has no ``type_caster`` for ``Eigen::Ref>`` (only for a concrete, owning ``Eigen::SparseMatrix``): + its generic sparse caster default-constructs a ``Type value;`` member internally, + which is impossible for ``Eigen::Ref`` (dense or sparse -- a ``Ref`` must be bound + to something at construction). pybind11 works around this for *dense* ``Eigen::Ref`` + with a hand-written caster (delayed construction via ``std::unique_ptr``), but ships + no equivalent for the sparse case. Because of this, every pybind11-exported function + taking/returning a sparse matrix (``BaseAlgo::compute_pf_with_input_validation``'s + ``Ybus``/``Bbus``, ``BaseFDPFAlgo::debug_get_Bp_python``/``debug_get_Bpp_python``, + ``get_J_python``, etc.) uses a bare ``Eigen::SparseMatrix<...>`` instead of the + ``EigenRefConstCplxSpMat``/``EigenRefConstRealSpMat`` aliases used everywhere else in + the C++-internal API, which costs one matrix copy per python call. Eigen's + ``SparseRef.h`` shows the ``Ref`` *can* bind zero-copy onto an + ``Eigen::Map>`` built straight from a scipy CSR/CSC matrix's + ``data``/``indices``/``indptr`` buffers (our aliases use ``Options=0``, so the + ``StandardCompressedFormat``-triggered copy path in ``Ref``'s + constructor never fires). A custom pybind11 ``type_caster`` specialization for + ``Eigen::Ref>`` (mirroring pybind11's own + dense-``Ref`` ``eigen_map_caster`` pattern: hold the three numpy buffers + a + ``unique_ptr``/``unique_ptr`` built in ``load()``) would remove that copy + on the load (python -> C++) direction, which is what matters for ``compute_pf``'s + hot path. The return (C++ -> python) direction would still copy either way -- python + needs its own owned object there regardless. - `GeneratorContainer::is_pseudo_off` (used when ``turnedoff_no_pv`` / ``LightSimBackend(turned_off_pv=False)`` is active) is not implemented as originally intended: it currently only checks ``target_p == 0`` to decide a diff --git a/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp b/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp index c04937ad..ab54363f 100644 --- a/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp +++ b/examples/dist_slack_algorithm/NRAlgoDistSlack.hpp @@ -128,15 +128,17 @@ class NRAlgoDistSlack final : public BaseAlgo { outer_iter_ = 0; } - bool compute_pf(const Eigen::SparseMatrix& Ybus, - CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol) override + bool compute_pf( + const Eigen::Ref > & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol + ) override { err_ = ErrorType::NoError; slack_absorbed_accum_ = static_cast(0.); diff --git a/examples/external_algorithm/DummyExternalAlgo.cpp b/examples/external_algorithm/DummyExternalAlgo.cpp index 0f6d8b49..18df5b9c 100644 --- a/examples/external_algorithm/DummyExternalAlgo.cpp +++ b/examples/external_algorithm/DummyExternalAlgo.cpp @@ -28,13 +28,13 @@ class DummyExternalAlgo : public ls2g::BaseAlgo { DummyExternalAlgo() : ls2g::BaseAlgo(/*is_ac=*/true) {} bool compute_pf( - const Eigen::SparseMatrix& /*Ybus*/, - ls2g::CplxVect& V, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/, + const Eigen::Ref > & /*Ybus*/, + const Eigen::Ref & V, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/, int /*max_iter*/, ls2g::real_type /*tol*/) override { diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index cc3ce4a9..69d85269 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -152,12 +152,12 @@ class LS2G_API AlgorithmSelector final } bool compute_pf(const Eigen::SparseMatrix& Ybus, - CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol) { @@ -167,13 +167,14 @@ class LS2G_API AlgorithmSelector final } // Native real-valued DC entry point (only valid for DC solvers). + // V stays plain: forwards into ->compute_pf_dc, which resizes/reassigns it. bool compute_pf_dc(const Eigen::SparseMatrix& Bbus, CplxVect& V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { _algo_type_used_for_nr = _algo_type; return get_prt_solver("compute_pf_dc", true)->compute_pf_dc( @@ -202,7 +203,7 @@ class LS2G_API AlgorithmSelector final return get_prt_solver("get_ptdf", true)->get_ptdf(); } - RealMat get_lodf(const IntVect& from_bus, const IntVect& to_bus) { + RealMat get_lodf(const Eigen::Ref& from_bus, const Eigen::Ref& to_bus) { if (!is_dc(_algo_type)) { throw std::runtime_error("AlgorithmSelector::get_lodf: cannot get lodf for a solver that is not DC."); } diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 03aef778..3c215524 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -269,7 +269,7 @@ void LSGrid::fixup_binary_state(LSGrid::StateRes & state) { std::get(state) = VERSION_MINOR; } -void LSGrid::set_ls_to_orig(const IntVect & ls_to_orig){ +void LSGrid::set_ls_to_orig(const Eigen::Ref & ls_to_orig){ if(ls_to_orig.size() == 0){ _ls_to_orig = IntVect(); _orig_to_ls = IntVect(); @@ -281,7 +281,7 @@ void LSGrid::set_ls_to_orig(const IntVect & ls_to_orig){ set_ls_to_orig_internal(ls_to_orig); } -void LSGrid::set_orig_to_ls(const IntVect & orig_to_ls){ +void LSGrid::set_orig_to_ls(const Eigen::Ref & orig_to_ls){ if(orig_to_ls.size() == 0){ _ls_to_orig = IntVect(); _orig_to_ls = IntVect(); @@ -305,7 +305,7 @@ void LSGrid::set_orig_to_ls(const IntVect & orig_to_ls){ } } -void LSGrid::set_ls_to_orig_internal(const IntVect & ls_to_orig) noexcept{ +void LSGrid::set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept{ if(ls_to_orig.size() == 0){ _ls_to_orig = IntVect(); _orig_to_ls = IntVect(); @@ -756,7 +756,7 @@ std::set LSGrid::get_free_vm_slack_solver_buses() const return res; } -void LSGrid::check_solution_q_values_onegen(CplxVect & res, +void LSGrid::check_solution_q_values_onegen(Eigen::Ref res, int bus_id, real_type min_q_mvar, real_type max_q_mvar, @@ -784,7 +784,7 @@ void LSGrid::check_solution_q_values_onegen(CplxVect & res, } } -void LSGrid::check_solution_q_values(CplxVect & res, bool check_q_limits) const{ +void LSGrid::check_solution_q_values(Eigen::Ref res, bool check_q_limits) const{ // test for iterator though generators for(const auto & gen: generators_) { @@ -910,6 +910,8 @@ CplxVect LSGrid::check_solution(const Eigen::Ref & V_proposed, b }; // AC injection: complex Sbus + the reactive-power vectors (Q limits / gen count per bus) +// Sbus stays a plain reference (not Eigen::Ref): it is reassigned below +// (Sbus = CplxVect::Constant(...)) to a size that can change with topology. void LSGrid::prepare_injection(CplxVect & Sbus, bool redo_all, bool converter_changed, const SolverBusIdVect & id_me_to_solver, const GlobalBusIdVect & id_solver_to_me, @@ -942,6 +944,8 @@ void LSGrid::prepare_injection(CplxVect & Sbus, bool redo_all, bool converter_ch } // DC injection: real Pbus, assembled by reusing the (complex) Sbus fills and keeping the real part +// Pbus stays a plain reference (not Eigen::Ref): it is reassigned below +// (Pbus = Sbus_tmp.real()) to a size that can change with topology. void LSGrid::prepare_injection(RealVect & Pbus, bool redo_all, bool converter_changed, const SolverBusIdVect & id_me_to_solver, const GlobalBusIdVect & id_solver_to_me, @@ -1264,7 +1268,7 @@ void LSGrid::fillBdc( res.makeCompressed(); } -void LSGrid::fillSbus_me(CplxVect & Sbus, bool ac, const SolverBusIdVect& id_me_to_solver) +void LSGrid::fillSbus_me(Eigen::Ref Sbus, bool ac, const SolverBusIdVect& id_me_to_solver) { // init the Sbus Sbus.array() = 0.; // reset to 0. @@ -1637,8 +1641,8 @@ void LSGrid::update_storages_p(const Eigen::Ref > has_changed, - Eigen::Ref > new_values) +void LSGrid::update_topo(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values) { loads_.update_topo(has_changed, new_values, algo_controler_, substations_); generators_.update_topo(has_changed, new_values, algo_controler_, substations_); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 8bc972bb..af66f1e3 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -152,8 +152,8 @@ class LS2G_API LSGrid final } ~LSGrid() noexcept = default; - void set_ls_to_orig(const IntVect & ls_to_orig); // set both _ls_to_orig and _orig_to_ls - void set_orig_to_ls(const IntVect & orig_to_ls); // set both _orig_to_ls and _ls_to_orig + void set_ls_to_orig(const Eigen::Ref & ls_to_orig); // set both _ls_to_orig and _orig_to_ls + void set_orig_to_ls(const Eigen::Ref & orig_to_ls); // set both _orig_to_ls and _ls_to_orig [[nodiscard]] const IntVect & get_ls_to_orig(void) const {return _ls_to_orig;} [[nodiscard]] const IntVect & get_orig_to_ls(void) const {return _orig_to_ls;} double timer_last_ac_pf() const {return timer_last_ac_pf_;} @@ -204,7 +204,7 @@ class LS2G_API LSGrid final void update_slack_weights(const Eigen::Ref > & could_be_slack){ generators_.update_slack_weights(could_be_slack, algo_controler_); } - void update_slack_weights_by_id(Eigen::Ref slack_ids){ + void update_slack_weights_by_id(const Eigen::Ref & slack_ids){ generators_.update_slack_weights_by_id(slack_ids, algo_controler_); } @@ -230,7 +230,7 @@ class LS2G_API LSGrid final [[nodiscard]] Eigen::Ref get_bus_vn_kv() const {return substations_.get_bus_vn_kv();} // per-bus min/max operating voltage (kV), optional: empty if never set - void set_bus_voltage_limits(const RealVect & bus_vmin_kv, const RealVect & bus_vmax_kv){ + void set_bus_voltage_limits(const Eigen::Ref & bus_vmin_kv, const Eigen::Ref & bus_vmax_kv){ substations_.init_bus_voltage_limits(bus_vmin_kv, bus_vmax_kv); } [[nodiscard]] Eigen::Ref get_bus_vmin_kv() const {return substations_.get_bus_vmin_kv();} @@ -699,19 +699,19 @@ class LS2G_API LSGrid final } [[nodiscard]] const std::vector & get_trafo_names() const {return trafos_.get_names();} // per-side current limit, in kA, optional: empty if never set - void set_line_current_limit_side1(const RealVect & limit_a1_ka){ + void set_line_current_limit_side1(const Eigen::Ref & limit_a1_ka){ GenericContainer::check_size(limit_a1_ka, powerlines_.nb(), "set_line_current_limit_side1"); powerlines_.set_limit_a1_ka(limit_a1_ka); } - void set_line_current_limit_side2(const RealVect & limit_a2_ka){ + void set_line_current_limit_side2(const Eigen::Ref & limit_a2_ka){ GenericContainer::check_size(limit_a2_ka, powerlines_.nb(), "set_line_current_limit_side2"); powerlines_.set_limit_a2_ka(limit_a2_ka); } - void set_trafo_current_limit_side1(const RealVect & limit_a1_ka){ + void set_trafo_current_limit_side1(const Eigen::Ref & limit_a1_ka){ GenericContainer::check_size(limit_a1_ka, trafos_.nb(), "set_trafo_current_limit_side1"); trafos_.set_limit_a1_ka(limit_a1_ka); } - void set_trafo_current_limit_side2(const RealVect & limit_a2_ka){ + void set_trafo_current_limit_side2(const Eigen::Ref & limit_a2_ka){ GenericContainer::check_size(limit_a2_ka, trafos_.nb(), "set_trafo_current_limit_side2"); trafos_.set_limit_a2_ka(limit_a2_ka); } @@ -1583,69 +1583,69 @@ class LS2G_API LSGrid final * The new_values are given in LocalBusId (-1, 1, 2 etc.) and not in * SolverBusId nor GridModelBusId */ - void update_topo(Eigen::Ref > has_changed, - Eigen::Ref > new_values); + void update_topo(const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values); void update_storages_p(const Eigen::Ref > & has_changed, const Eigen::Ref > & new_values); - void set_load_pos_topo_vect(Eigen::Ref load_pos_topo_vect) + void set_load_pos_topo_vect(const Eigen::Ref & load_pos_topo_vect) { loads_.set_pos_topo_vect(load_pos_topo_vect); } - void set_gen_pos_topo_vect(Eigen::Ref gen_pos_topo_vect) + void set_gen_pos_topo_vect(const Eigen::Ref & gen_pos_topo_vect) { generators_.set_pos_topo_vect(gen_pos_topo_vect); } - void set_storage_pos_topo_vect(Eigen::Ref sto_pos_topo_vect) + void set_storage_pos_topo_vect(const Eigen::Ref & sto_pos_topo_vect) { storages_.set_pos_topo_vect(sto_pos_topo_vect); } - void set_line_pos1_topo_vect(Eigen::Ref line_or_pos_topo_vect) + void set_line_pos1_topo_vect(const Eigen::Ref & line_or_pos_topo_vect) { powerlines_.set_pos_topo_vect_side_1(line_or_pos_topo_vect); } - void set_line_pos2_topo_vect(Eigen::Ref line_ex_pos_topo_vect) + void set_line_pos2_topo_vect(const Eigen::Ref & line_ex_pos_topo_vect) { powerlines_.set_pos_topo_vect_side_2(line_ex_pos_topo_vect); } - void set_trafo_pos1_topo_vect(Eigen::Ref trafo_hv_pos_topo_vect) + void set_trafo_pos1_topo_vect(const Eigen::Ref & trafo_hv_pos_topo_vect) { trafos_.set_pos_topo_vect_side_1(trafo_hv_pos_topo_vect); } - void set_trafo_pos2_topo_vect(Eigen::Ref trafo_lv_pos_topo_vect) + void set_trafo_pos2_topo_vect(const Eigen::Ref & trafo_lv_pos_topo_vect) { trafos_.set_pos_topo_vect_side_2(trafo_lv_pos_topo_vect); } - void set_load_to_subid(Eigen::Ref load_to_subid) + void set_load_to_subid(const Eigen::Ref & load_to_subid) { loads_.set_subid(load_to_subid); } - void set_gen_to_subid(Eigen::Ref gen_to_subid) + void set_gen_to_subid(const Eigen::Ref & gen_to_subid) { generators_.set_subid(gen_to_subid); } - void set_storage_to_subid(Eigen::Ref storage_to_subid) + void set_storage_to_subid(const Eigen::Ref & storage_to_subid) { storages_.set_subid(storage_to_subid); } - void set_shunt_to_subid(Eigen::Ref shunt_to_subid) + void set_shunt_to_subid(const Eigen::Ref & shunt_to_subid) { shunts_.set_subid(shunt_to_subid); } - void set_line_to_sub1_id(Eigen::Ref line_or_to_subid) + void set_line_to_sub1_id(const Eigen::Ref & line_or_to_subid) { powerlines_.set_subid_side_1(line_or_to_subid); } - void set_line_to_sub2_id(Eigen::Ref line_ex_to_subid) + void set_line_to_sub2_id(const Eigen::Ref & line_ex_to_subid) { powerlines_.set_subid_side_2(line_ex_to_subid); } - void set_trafo_to_sub1_id(Eigen::Ref trafo_hv_to_subid) + void set_trafo_to_sub1_id(const Eigen::Ref & trafo_hv_to_subid) { trafos_.set_subid_side_1(trafo_hv_to_subid); } - void set_trafo_to_sub2_id(Eigen::Ref trafo_lv_to_subid) + void set_trafo_to_sub2_id(const Eigen::Ref & trafo_lv_to_subid) { trafos_.set_subid_side_2(trafo_lv_to_subid); } @@ -1697,7 +1697,7 @@ class LS2G_API LSGrid final * solved voltage. */ [[nodiscard]] const IntVect & get_bus_fusion_rep() const { return _bus_fusion_rep;} - void set_bus_fusion_rep(const IntVect & bus_fusion_rep){ + void set_bus_fusion_rep(const Eigen::Ref & bus_fusion_rep){ if(bus_fusion_rep.size() != 0 && static_cast(bus_fusion_rep.size()) != total_bus()){ std::ostringstream exc_; exc_ << "LSGrid::set_bus_fusion_rep: the provided vector has size "; @@ -1707,7 +1707,7 @@ class LS2G_API LSGrid final _bus_fusion_rep = bus_fusion_rep; } - void fillSbus_other(CplxVect & res, bool ac, const SolverBusIdVect& id_me_to_solver){ + void fillSbus_other(Eigen::Ref res, bool ac, const SolverBusIdVect& id_me_to_solver){ fillSbus_me(res, ac, id_me_to_solver); } @@ -1728,6 +1728,9 @@ class LS2G_API LSGrid final // physically-correct gap between that voltage and the local target (the // regulator doing its job) can look like a large spurious power mismatch at a // strongly-meshed bus. + // Sbus stays a plain reference (not Eigen::Ref): forwarded into + // _pre_process_solver_impl's inj, which forwards into prepare_injection, + // which reassigns/resizes it. CplxVect pre_process_solver(const Eigen::Ref & Vinit, CplxVect & Sbus, Eigen::SparseMatrix & Ybus, @@ -1742,6 +1745,8 @@ class LS2G_API LSGrid final // DC-specific pre processing: builds the real Bbus (admittance) matrix and the // real Pbus (active power) vector, reusing the shared bus-mapping helpers. Mirrors // pre_process_solver but keeps the whole DC path real (no complex Ybus / Sbus). + // Pbus stays a plain reference (not Eigen::Ref): same resize-forwarding + // reason as pre_process_solver's Sbus above. CplxVect pre_process_dc_solver(const Eigen::Ref & Vinit, RealVect & Pbus, Eigen::SparseMatrix & Bbus, @@ -1777,7 +1782,7 @@ class LS2G_API LSGrid final } protected: - void set_ls_to_orig_internal(const IntVect & ls_to_orig) noexcept; // set both _ls_to_orig and _orig_to_ls + void set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept; // set both _ls_to_orig and _orig_to_ls // init the Ybus matrix (its size, it is filled up elsewhere) and also the // converter from "my bus id" to the "solver bus id" (id_me_to_solver and id_solver_to_me) @@ -1909,6 +1914,10 @@ class LS2G_API LSGrid final // pre_process_dc_solver (DC: real Bbus / Pbus). The matrix scalar type selects the // family (cplx_type => AC, real_type => DC); the type-specific steps (matrix init / fill, // injection assembly) are tag-dispatched to the overloads just below. + // inj stays a plain reference (not Eigen::Ref): it is forwarded straight into + // prepare_injection (CplxVect&/RealVect& overload), which reassigns/resizes it + // -- not a template-deduction issue here (MatScalar/InjVect are always given + // explicitly at the two call sites below), just resize-forwarding. template CplxVect _pre_process_solver_impl(const Eigen::Ref & Vinit, InjVect & inj, @@ -1936,7 +1945,7 @@ class LS2G_API LSGrid final void fillYbus(Eigen::SparseMatrix & res, bool ac, const SolverBusIdVect& id_me_to_solver); void fillBdc(Eigen::SparseMatrix & res, const SolverBusIdVect& id_me_to_solver); // DC: real admittance matrix - void fillSbus_me(CplxVect & res, bool ac, const SolverBusIdVect& id_me_to_solver); + void fillSbus_me(Eigen::Ref res, bool ac, const SolverBusIdVect& id_me_to_solver); void fillpv_pq(const SolverBusIdVect& id_me_to_solver, const GlobalBusIdVect& id_solver_to_me, const SolverBusIdVect & slack_bus_id_solver, @@ -1945,6 +1954,9 @@ class LS2G_API LSGrid final // results /**process the results from the solver to this instance **/ + // res stays a plain reference (not Eigen::Ref): it is reassigned below + // (res = _get_results_back_to_orig_nodes(...)) to total_bus() size, which can + // differ from the caller's initial (often empty) res. void process_results(bool conv, CplxVect & res, const Eigen::Ref & Vinit, bool ac, SolverBusIdVect & id_me_to_solver); @@ -1991,8 +2003,8 @@ class LS2G_API LSGrid final SolverBusIdVect & id_me_to_solver, int size); - void check_solution_q_values( CplxVect & res, bool check_q_limits) const; - void check_solution_q_values_onegen(CplxVect & res, + void check_solution_q_values( Eigen::Ref res, bool check_q_limits) const; + void check_solution_q_values_onegen(Eigen::Ref res, int bus_id, real_type min_q_mvar, real_type max_q_mvar, diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index fac35b8a..b50d88fe 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -95,7 +95,7 @@ class LS2G_API SubstationContainer final : public IteratorAdder & sub_vn_kv){ if(sub_vn_kv.size() != n_sub_){ throw std::range_error("SubstationContainer::init_sub: sub_vn_kv should have the size of the number of substations on the grid."); } @@ -145,7 +145,7 @@ class LS2G_API SubstationContainer final : public IteratorAdder get_bus_vn_kv() const {return bus_vn_kv_;} // per-bus min/max operating voltage (kV), optional: empty if never set (e.g. pandapower-origin grids) - void init_bus_voltage_limits(const RealVect & bus_vmin_kv, const RealVect & bus_vmax_kv){ + void init_bus_voltage_limits(const Eigen::Ref & bus_vmin_kv, const Eigen::Ref & bus_vmax_kv){ if(static_cast(bus_vmin_kv.size()) != nb_bus()){ throw std::runtime_error("SubstationContainer::init_bus_voltage_limits: bus_vmin_kv does not have the proper size."); } diff --git a/src/core/TaggedIdVec.hpp b/src/core/TaggedIdVec.hpp index a524b8cc..0b51c7ad 100644 --- a/src/core/TaggedIdVec.hpp +++ b/src/core/TaggedIdVec.hpp @@ -166,7 +166,7 @@ class TaggedIdStdVec { } // Construct from an IntVect (eigen vector of int) - explicit TaggedIdStdVec(const IntVect & v) { + explicit TaggedIdStdVec(const Eigen::Ref & v) { data_= std::vector(v.begin(), v.end()); } diff --git a/src/core/Utils.hpp b/src/core/Utils.hpp index c07ef76c..9a567bae 100644 --- a/src/core/Utils.hpp +++ b/src/core/Utils.hpp @@ -15,6 +15,7 @@ Some typedef and other structures define here and used everywhere else #include #include #include "Eigen/Core" +#include "Eigen/Sparse" #include "TaggedIdVec.hpp" #include "ls2g_api.hpp" @@ -46,6 +47,9 @@ using tuple5d = std::tuple, using RealMat = Eigen::Matrix; using CplxMat = Eigen::Matrix ; +using EigenRefConstCplxSpMat = Eigen::Ref >; +using EigenRefConstRealSpMat = Eigen::Ref >; + // type of error in the different solvers enum class ErrorType {NoError, SingularMatrix, diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index 46bb9a1f..0587411d 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -17,11 +17,11 @@ namespace ls2g { bool BaseBatchSolverSynch::compute_one_powerflow( const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ) @@ -43,11 +43,11 @@ bool BaseBatchSolverSynch::compute_one_powerflow( double & timer_solver, const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ) diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index e9b814a6..aebca7f0 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -295,13 +295,17 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // member version: forwards to the explicit overload below using the // member solver / control / accumulators (single-threaded path). + // V stays a plain reference (not Eigen::Ref): it forwards into both + // algo.compute_pf(Ybus, V, ...) (AC) and algo.compute_pf_dc(Bbus, V, ...) (DC) + // depending on ac_solver_used(), and the DC path resizes/reassigns V -- so it + // can't become Eigen::Ref even though the AC path alone would allow it. bool compute_one_powerflow(const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ); @@ -310,17 +314,18 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // its book-keeping into the passed accumulators. This is what the // multi-threaded ContingencyAnalysis uses (one solver per thread). The // read-only member Bbus_ is only read here (safe to share across threads). + // V stays plain -- same AC/DC forwarding reason as the member overload above. bool compute_one_powerflow(AlgorithmSelector & algo, AlgoControl & control, int & nb_solved, double & timer_solver, const Eigen::SparseMatrix & Ybus, CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref bus_pv, - Eigen::Ref bus_pq, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & bus_pv, + const Eigen::Ref & bus_pq, int max_iter, double tol ); @@ -426,10 +431,17 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants return nb_total_bus; } + // Vinit_solver as Eigen::Ref relies on the reassignment below + // (Vinit_solver = _algo.get_V()) always being same-size as the caller's + // Vinit_solver: both trace back to the same Ybus_/Bbus_ solver-space + // dimension (nb_buses_solver_) for the duration of one call, so this holds + // structurally, not by luck. No virtual dispatch here to enforce it -- + // if that invariant is ever broken, Eigen::Ref's operator= will assert + // (debug) or corrupt memory (release), same risk as any Eigen::Ref sink. bool _finish_preprocessing( size_t nb_steps, size_t nb_total_bus, - CplxVect & Vinit_solver, // is modified if _init_from_n_powerflow is true ! + Eigen::Ref Vinit_solver, // is modified if _init_from_n_powerflow is true ! size_t max_iter, real_type tol, CustTimer & timer_preproc // non const because double duration() is not const diff --git a/src/core/batch_algorithm/ContingencyAnalysis.cpp b/src/core/batch_algorithm/ContingencyAnalysis.cpp index bff908ce..daedf848 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.cpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.cpp @@ -27,11 +27,11 @@ namespace { // LOW_VOLTAGE violation). `subs` is used to resolve the violating bus's substation name (see // LimitViolation::name); its names are empty strings if never set on the grid. void check_bus_voltage_violations( - const CplxVect & V, + const Eigen::Ref & V, const SolverBusIdVect & id_me_to_solver, - Eigen::Ref bus_vmin_kv, - Eigen::Ref bus_vmax_kv, - Eigen::Ref bus_vn_kv, + const Eigen::Ref & bus_vmin_kv, + const Eigen::Ref & bus_vmax_kv, + const Eigen::Ref & bus_vn_kv, const SubstationContainer & subs, const std::vector * masked_solver_ids, std::vector & out) @@ -85,13 +85,13 @@ template void check_current_violations( const T & structure_data, ViolationElementType el_type, - const CplxVect & V, + const Eigen::Ref & V, const SolverBusIdVect & id_me_to_solver, - Eigen::Ref bus_vn_kv, + const Eigen::Ref & bus_vn_kv, bool ac_solver_used, real_type sn_mva, - Eigen::Ref limit1, - Eigen::Ref limit2, + const Eigen::Ref & limit1, + const Eigen::Ref & limit2, const std::vector & skip_ids, std::vector & out) { @@ -772,7 +772,7 @@ void ContingencyAnalysis::run_contingency_range( AlgorithmSelector & algo, AlgoControl & control, Eigen::SparseMatrix & Ybus, - const CplxVect & Vinit_solver, + const Eigen::Ref & Vinit_solver, bool ac_solver_used, bool mask_mode, int max_iter, diff --git a/src/core/batch_algorithm/ContingencyAnalysis.hpp b/src/core/batch_algorithm/ContingencyAnalysis.hpp index 9a5627af..6afda037 100644 --- a/src/core/batch_algorithm/ContingencyAnalysis.hpp +++ b/src/core/batch_algorithm/ContingencyAnalysis.hpp @@ -294,7 +294,7 @@ class LS2G_API ContingencyAnalysis final: public BaseBatchSolverSynch AlgorithmSelector & algo, AlgoControl & control, Eigen::SparseMatrix & Ybus, - const CplxVect & Vinit_solver, + const Eigen::Ref & Vinit_solver, bool ac_solver_used, bool mask_mode, int max_iter, diff --git a/src/core/batch_algorithm/TimeSeries.cpp b/src/core/batch_algorithm/TimeSeries.cpp index 84ba72dd..9a75eba5 100644 --- a/src/core/batch_algorithm/TimeSeries.cpp +++ b/src/core/batch_algorithm/TimeSeries.cpp @@ -13,10 +13,10 @@ namespace ls2g { -int TimeSeries::compute_Vs(Eigen::Ref gen_p, - Eigen::Ref sgen_p, - Eigen::Ref load_p, - Eigen::Ref load_q, +int TimeSeries::compute_Vs(const Eigen::Ref & gen_p, + const Eigen::Ref & sgen_p, + const Eigen::Ref & load_p, + const Eigen::Ref & load_q, const Eigen::Ref & Vinit, const int max_iter, const real_type tol) @@ -48,7 +48,7 @@ int TimeSeries::compute_Vs(Eigen::Ref gen_p, // temporal_data.col(el_id) (unchecked Eigen .col()), so a matrix with too few // columns over-reads; and _Sbuses is sized with gen_p.rows(), so mismatched row // counts turn the `Sbuses.col(...) += tmp` into an out-of-bounds read/write. - const auto check_mat = [nb_steps](Eigen::Ref mat, Eigen::Index nb_expected_cols, + const auto check_mat = [nb_steps](const Eigen::Ref & mat, Eigen::Index nb_expected_cols, const std::string & name){ if(static_cast(mat.rows()) != nb_steps){ std::ostringstream exc_; diff --git a/src/core/batch_algorithm/TimeSeries.hpp b/src/core/batch_algorithm/TimeSeries.hpp index 6fc5b472..dfcc3532 100644 --- a/src/core/batch_algorithm/TimeSeries.hpp +++ b/src/core/batch_algorithm/TimeSeries.hpp @@ -50,10 +50,10 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch Each line of `Sbuses` will be a time step, and each column with **/ - int compute_Vs(Eigen::Ref gen_p, - Eigen::Ref sgen_p, - Eigen::Ref load_p, - Eigen::Ref load_q, + int compute_Vs(const Eigen::Ref & gen_p, + const Eigen::Ref & sgen_p, + const Eigen::Ref & load_p, + const Eigen::Ref & load_q, const Eigen::Ref & Vinit, const int max_iter, const real_type tol); @@ -79,7 +79,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch protected: template - void fill_SBus_real(CplxMat & Sbuses, + void fill_SBus_real(Eigen::Ref Sbuses, const T & structure_data, const Eigen::Ref & temporal_data, const SolverBusIdVect & id_me_to_ac_solver, @@ -116,7 +116,7 @@ class LS2G_API TimeSeries final: public BaseBatchSolverSynch } template - void fill_SBus_imag(CplxMat & Sbuses, + void fill_SBus_imag(Eigen::Ref Sbuses, const T & structure_data, const Eigen::Ref & temporal_data, const SolverBusIdVect & id_me_to_ac_solver, diff --git a/src/core/element_container/ConverterStationContainer.cpp b/src/core/element_container/ConverterStationContainer.cpp index e5e97a48..60eeeec1 100644 --- a/src/core/element_container/ConverterStationContainer.cpp +++ b/src/core/element_container/ConverterStationContainer.cpp @@ -157,7 +157,7 @@ void ConverterStationContainer::change_v(int station_id, real_type new_v_pu, Dua } } -void ConverterStationContainer::fillSbus_station(CplxVect & Sbus, +void ConverterStationContainer::fillSbus_station(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/, const std::vector & skip_p) const @@ -236,9 +236,9 @@ void ConverterStationContainer::fillpv(std::vector & bus_pv, } void ConverterStationContainer::init_q_vector(int /*nb_bus*/, - Eigen::VectorXi & total_gen_per_bus, - RealVect & total_q_min_per_bus, - RealVect & total_q_max_per_bus) const + Eigen::Ref total_gen_per_bus, + Eigen::Ref total_q_min_per_bus, + Eigen::Ref total_q_max_per_bus) const { const int nb_station = nb(); for(int station_id = 0; station_id < nb_station; ++station_id) @@ -253,12 +253,12 @@ void ConverterStationContainer::init_q_vector(int /*nb_bus*/, } } -void ConverterStationContainer::set_q(const RealVect & reactive_mismatch, +void ConverterStationContainer::set_q(const Eigen::Ref & reactive_mismatch, const SolverBusIdVect & id_grid_to_solver, bool ac, - const Eigen::VectorXi & total_gen_per_bus, - const RealVect & total_q_min_per_bus, - const RealVect & total_q_max_per_bus) + const Eigen::Ref & total_gen_per_bus, + const Eigen::Ref & total_q_min_per_bus, + const Eigen::Ref & total_q_max_per_bus) { const int nb_station = nb(); if(!ac){ @@ -300,7 +300,7 @@ void ConverterStationContainer::set_q(const RealVect & reactive_mismatch, } } -void ConverterStationContainer::get_vm_for_dc(RealVect & Vm) +void ConverterStationContainer::get_vm_for_dc(Eigen::Ref Vm) { const int nb_station = nb(); GlobalBusId bus_id_me; @@ -314,7 +314,7 @@ void ConverterStationContainer::get_vm_for_dc(RealVect & Vm) } } -void ConverterStationContainer::set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const +void ConverterStationContainer::set_vm(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const { const int nb_station = nb(); GlobalBusId bus_id_me; diff --git a/src/core/element_container/ConverterStationContainer.hpp b/src/core/element_container/ConverterStationContainer.hpp index 5ab1234e..55f8e938 100644 --- a/src/core/element_container/ConverterStationContainer.hpp +++ b/src/core/element_container/ConverterStationContainer.hpp @@ -144,7 +144,7 @@ class LS2G_API ConverterStationContainer final : public OneSideContainer_PQ, pub * (it is handled by the HVDC droop extension of the NR system, or by * the DC algorithm): only the reactive personality is stamped then. */ - void fillSbus_station(CplxVect & Sbus, + void fillSbus_station(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac, const std::vector & skip_p) const; @@ -153,17 +153,17 @@ class LS2G_API ConverterStationContainer final : public OneSideContainer_PQ, pub const SolverBusIdVect & slack_bus_id_solver, const SolverBusIdVect & id_grid_to_solver) const override; void init_q_vector(int nb_bus, - Eigen::VectorXi & total_gen_per_bus, - RealVect & total_q_min_per_bus, - RealVect & total_q_max_per_bus) const; - void set_q(const RealVect & reactive_mismatch, + Eigen::Ref total_gen_per_bus, + Eigen::Ref total_q_min_per_bus, + Eigen::Ref total_q_max_per_bus) const; + void set_q(const Eigen::Ref & reactive_mismatch, const SolverBusIdVect & id_grid_to_solver, bool ac, - const Eigen::VectorXi & total_gen_per_bus, - const RealVect & total_q_min_per_bus, - const RealVect & total_q_max_per_bus); - void get_vm_for_dc(RealVect & Vm); - void set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const; + const Eigen::Ref & total_gen_per_bus, + const Eigen::Ref & total_q_min_per_bus, + const Eigen::Ref & total_q_max_per_bus); + void get_vm_for_dc(Eigen::Ref Vm); + void set_vm(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const; protected: void _compute_results( diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 6f10d5c2..66757932 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -165,7 +165,7 @@ RealVect GeneratorContainer::get_slack_weights_solver( return res; } -void GeneratorContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { +void GeneratorContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_gen = nb(); GlobalBusId bus_id_me; SolverBusId bus_id_solver; @@ -255,7 +255,7 @@ void GeneratorContainer::fillpv(std::vector & bus_pv, } } -void GeneratorContainer::get_vm_for_dc(RealVect & Vm){ +void GeneratorContainer::get_vm_for_dc(Eigen::Ref Vm){ const int nb_gen = nb(); GlobalBusId bus_id_me; for(int gen_id = 0; gen_id < nb_gen; ++gen_id){ @@ -369,7 +369,7 @@ bool GeneratorContainer::_change_bus(int el_id, GridModelBusId new_bus_id, DualA // return true; }; -void GeneratorContainer::set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const +void GeneratorContainer::set_vm(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const { const int nb_gen = nb(); SolverBusId bus_id_solver; @@ -433,7 +433,7 @@ GlobalBusIdVect GeneratorContainer::get_slack_bus_id() const{ return res; } -void GeneratorContainer::set_p_slack(const RealVect& node_mismatch, +void GeneratorContainer::set_p_slack(const Eigen::Ref& node_mismatch, const SolverBusIdVect & id_grid_to_solver) { if(bus_slack_weight_.size() == 0){ @@ -456,9 +456,9 @@ void GeneratorContainer::set_p_slack(const RealVect& node_mismatch, } void GeneratorContainer::init_q_vector(int /*nb_bus*/, - Eigen::VectorXi & total_gen_per_bus, - RealVect & total_q_min_per_bus, - RealVect & total_q_max_per_bus) const + Eigen::Ref total_gen_per_bus, + Eigen::Ref total_q_min_per_bus, + Eigen::Ref total_q_max_per_bus) const { const int nb_gen = nb(); for(int gen_id = 0; gen_id < nb_gen; ++gen_id) @@ -479,12 +479,12 @@ void GeneratorContainer::init_q_vector(int /*nb_bus*/, } void GeneratorContainer::set_q( - const RealVect & reactive_mismatch, + const Eigen::Ref & reactive_mismatch, const SolverBusIdVect & id_grid_to_solver, bool ac, - const Eigen::VectorXi & total_gen_per_bus, - const RealVect & total_q_min_per_bus, - const RealVect & total_q_max_per_bus) + const Eigen::Ref & total_gen_per_bus, + const Eigen::Ref & total_q_min_per_bus, + const Eigen::Ref & total_q_max_per_bus) { const int nb_gen = nb(); if(!ac){ @@ -555,7 +555,7 @@ void GeneratorContainer::update_slack_weights( } void GeneratorContainer::update_slack_weights_by_id( - Eigen::Ref gen_slack_id, + const Eigen::Ref & gen_slack_id, DualAlgoControl & solver_control) { // TODO speed: the solver_control will always tell that the slacks changed diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index e891e7cd..ef3004cb 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -184,7 +184,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter RealVect get_slack_weights_solver(size_t nb_bus_solver, const SolverBusIdVect & id_grid_to_solver); GlobalBusIdVect get_slack_bus_id() const; - void set_p_slack(const RealVect& node_mismatch, const SolverBusIdVect & id_grid_to_solver) override; + void set_p_slack(const Eigen::Ref& node_mismatch, const SolverBusIdVect & id_grid_to_solver) override; // modification void turnedoff_no_pv(DualAlgoControl & solver_control){ @@ -200,7 +200,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter bool get_turnedoff_gen_pv() const {return turnedoff_gen_pv_;} void update_slack_weights(const Eigen::Ref > & could_be_slack, DualAlgoControl & solver_control); - void update_slack_weights_by_id(Eigen::Ref gen_slack_id, DualAlgoControl & solver_control); + void update_slack_weights_by_id(const Eigen::Ref & gen_slack_id, DualAlgoControl & solver_control); // ---- remote voltage control -------------------------------------------- @@ -254,30 +254,30 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter void change_v(int gen_id, real_type new_v_pu, DualAlgoControl & solver_control); void change_v_nothrow(int gen_id, real_type new_v_pu, DualAlgoControl & solver_control); - void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, const SolverBusIdVect & slack_bus_id_solver, const SolverBusIdVect & id_grid_to_solver) const override; void init_q_vector(int nb_bus, - Eigen::VectorXi & total_gen_per_bus, - RealVect & total_q_min_per_bus, - RealVect & total_q_max_per_bus) const; // delta_q_per_gen_ - - void set_q(const RealVect & reactive_mismatch, + Eigen::Ref total_gen_per_bus, + Eigen::Ref total_q_min_per_bus, + Eigen::Ref total_q_max_per_bus) const; // delta_q_per_gen_ + + void set_q(const Eigen::Ref & reactive_mismatch, const SolverBusIdVect & id_grid_to_solver, bool ac, - const Eigen::VectorXi & total_gen_per_bus, - const RealVect & total_q_min_per_bus, - const RealVect & total_q_max_per_bus); + const Eigen::Ref & total_gen_per_bus, + const Eigen::Ref & total_q_min_per_bus, + const Eigen::Ref & total_q_max_per_bus); - void get_vm_for_dc(RealVect & Vm); + void get_vm_for_dc(Eigen::Ref Vm); /** this functions makes sure that the voltage magnitude of every connected bus is properly used to initialize the ac powerflow **/ - void set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const; + void set_vm(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const; void cout_v(){ for(const auto & el : target_vm_pu_){ diff --git a/src/core/element_container/GenericContainer.cpp b/src/core/element_container/GenericContainer.cpp index b933f013..2152c24b 100644 --- a/src/core/element_container/GenericContainer.cpp +++ b/src/core/element_container/GenericContainer.cpp @@ -119,7 +119,7 @@ void GenericContainer::v_kv_from_vpu(const Eigen::Ref & /*Va*/, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, const Eigen::Ref & bus_vn_kv, - RealVect & v) const + Eigen::Ref v) const { for(int el_id = 0; el_id < nb_element; ++el_id){ // if the element is disconnected, i leave it like that @@ -157,7 +157,7 @@ void GenericContainer::v_deg_from_va(const Eigen::Ref & Va, const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, const Eigen::Ref & /*bus_vn_kv*/, - RealVect & theta) const + Eigen::Ref theta) const { for(int el_id = 0; el_id < nb_element; ++el_id){ // if the element is disconnected, i leave it like that diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index 491f444b..b4d597ad 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -66,7 +66,7 @@ class LS2G_API GenericContainer : public BaseConstants // is overriden mainly for "branches" (lines, transformers etc.) }; - virtual void fillSbus(CplxVect & /*Sbus*/, const SolverBusIdVect & /*id_grid_to_solver*/, bool /*ac*/) const { + virtual void fillSbus(Eigen::Ref /*Sbus*/, const SolverBusIdVect & /*id_grid_to_solver*/, bool /*ac*/) const { // nothing to do by default // is overriden mainly for "one side elements" (loads, generators etc.) }; @@ -83,7 +83,7 @@ class LS2G_API GenericContainer : public BaseConstants // is overriden mainly for "generators" }; - virtual void set_p_slack(const RealVect& /*node_mismatch*/, const SolverBusIdVect & /*id_grid_to_solver*/) { + virtual void set_p_slack(const Eigen::Ref& /*node_mismatch*/, const SolverBusIdVect & /*id_grid_to_solver*/) { // nothing to do by default // is overriden mainly for "generators" }; @@ -214,7 +214,7 @@ class LS2G_API GenericContainer : public BaseConstants const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, const Eigen::Ref & bus_vn_kv, - RealVect & v) const; + Eigen::Ref v) const; /** @@ -227,7 +227,7 @@ class LS2G_API GenericContainer : public BaseConstants const GlobalBusIdVect & bus_me_id, const SolverBusIdVect & id_grid_to_solver, const Eigen::Ref & bus_vn_kv, - RealVect & v) const; + Eigen::Ref v) const; }; diff --git a/src/core/element_container/HvdcLineContainer.cpp b/src/core/element_container/HvdcLineContainer.cpp index ddaf9d79..a632ca6a 100644 --- a/src/core/element_container/HvdcLineContainer.cpp +++ b/src/core/element_container/HvdcLineContainer.cpp @@ -361,7 +361,7 @@ void HvdcLineContainer::set_status_droop(int hvdc_id, int status, DualAlgoContro } } -void HvdcLineContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const +void HvdcLineContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const { const int nb_hvdc = static_cast(nb()); // for droop lines, the active power is NOT a fixed injection: diff --git a/src/core/element_container/HvdcLineContainer.hpp b/src/core/element_container/HvdcLineContainer.hpp index 371202bb..6511fa2b 100644 --- a/src/core/element_container/HvdcLineContainer.hpp +++ b/src/core/element_container/HvdcLineContainer.hpp @@ -345,7 +345,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; void fillpv(std::vector& bus_pv, std::vector & has_bus_been_added, @@ -364,9 +364,9 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer total_gen_per_bus, + Eigen::Ref total_q_min_per_bus, + Eigen::Ref total_q_max_per_bus) const { side_1_.init_q_vector(nb_bus, total_gen_per_bus, total_q_min_per_bus, total_q_max_per_bus); side_2_.init_q_vector(nb_bus, total_gen_per_bus, total_q_min_per_bus, total_q_max_per_bus); } @@ -383,23 +383,23 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer & reactive_mismatch, const SolverBusIdVect & id_grid_to_solver, bool ac, - const Eigen::VectorXi & total_gen_per_bus, - const RealVect & total_q_min_per_bus, - const RealVect & total_q_max_per_bus){ + const Eigen::Ref & total_gen_per_bus, + const Eigen::Ref & total_q_min_per_bus, + const Eigen::Ref & total_q_max_per_bus){ side_1_.set_q(reactive_mismatch, id_grid_to_solver, ac, total_gen_per_bus, total_q_min_per_bus, total_q_max_per_bus); side_2_.set_q(reactive_mismatch, id_grid_to_solver, ac, total_gen_per_bus, total_q_min_per_bus, total_q_max_per_bus); } - void get_vm_for_dc(RealVect & Vm){ + void get_vm_for_dc(Eigen::Ref Vm){ side_1_.get_vm_for_dc(Vm); side_2_.get_vm_for_dc(Vm); } - void set_vm_or(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const{ + void set_vm_or(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const{ side_1_.set_vm(V, id_grid_to_solver); } - void set_vm_ex(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const{ + void set_vm_ex(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const{ side_2_.set_vm(V, id_grid_to_solver); } @@ -407,7 +407,7 @@ class LS2G_API HvdcLineContainer final : public TwoSidesContainer V, const SolverBusIdVect & id_grid_to_solver) const{ side_1_.set_vm(V, id_grid_to_solver); side_2_.set_vm(V, id_grid_to_solver); } diff --git a/src/core/element_container/LoadContainer.cpp b/src/core/element_container/LoadContainer.cpp index 02f6399b..3a188b60 100644 --- a/src/core/element_container/LoadContainer.cpp +++ b/src/core/element_container/LoadContainer.cpp @@ -27,7 +27,7 @@ void LoadContainer::set_state(LoadContainer::StateRes & my_state) reset_results(); } -void LoadContainer::fillSbus(CplxVect & Sbus, +void LoadContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { diff --git a/src/core/element_container/LoadContainer.hpp b/src/core/element_container/LoadContainer.hpp index 966a041f..1b50ad63 100644 --- a/src/core/element_container/LoadContainer.hpp +++ b/src/core/element_container/LoadContainer.hpp @@ -80,7 +80,7 @@ class LS2G_API LoadContainer final: public OneSideContainer_PQ, public IteratorA reset_results(); } - void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: void _compute_results(const Eigen::Ref & /*Va*/, diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index afd50eba..dcc53ca9 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -259,13 +259,13 @@ class OneSideContainer : public GenericContainer reset_osc_results(); } - void set_pos_topo_vect(Eigen::Ref pos_topo_vect) + void set_pos_topo_vect(const Eigen::Ref & pos_topo_vect) { check_size(pos_topo_vect, nb(), "pos_topo_vect"); pos_topo_vect_.array() = pos_topo_vect; } - void set_subid(Eigen::Ref subid) + void set_subid(const Eigen::Ref & subid) { check_size(subid, nb(), "subid"); subid_.array() = subid; @@ -277,8 +277,8 @@ class OneSideContainer : public GenericContainer * The bus labelling in "new_values" are local bus (between 1 and n_max_busbar_per_sub). */ virtual std::vector update_topo( - Eigen::Ref > & has_changed, - Eigen::Ref > & new_values, + const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values, DualAlgoControl & solver_control, SubstationContainer & substations ) final diff --git a/src/core/element_container/OneSideContainer_forBranch.hpp b/src/core/element_container/OneSideContainer_forBranch.hpp index 08983a1e..f08e0c7f 100644 --- a/src/core/element_container/OneSideContainer_forBranch.hpp +++ b/src/core/element_container/OneSideContainer_forBranch.hpp @@ -98,9 +98,9 @@ class OneSideContainer_ForBranch : public OneSideContainer } void init_osc_forB( - const RealVect & /*els_p*/, - const RealVect & /*els_q*/, - const Eigen::VectorXi & els_bus_id, + const Eigen::Ref & /*els_p*/, + const Eigen::Ref & /*els_q*/, + const Eigen::Ref & els_bus_id, const std::string & /*name_el*/ ) // osc: one side element { diff --git a/src/core/element_container/SGenContainer.cpp b/src/core/element_container/SGenContainer.cpp index f04c0efb..531f4a21 100644 --- a/src/core/element_container/SGenContainer.cpp +++ b/src/core/element_container/SGenContainer.cpp @@ -68,7 +68,7 @@ void SGenContainer::set_state(SGenContainer::StateRes & my_state ) reset_results(); } -void SGenContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { +void SGenContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_sgen = nb(); GlobalBusId bus_id_me; SolverBusId bus_id_solver; diff --git a/src/core/element_container/SGenContainer.hpp b/src/core/element_container/SGenContainer.hpp index f67a0807..8248155c 100644 --- a/src/core/element_container/SGenContainer.hpp +++ b/src/core/element_container/SGenContainer.hpp @@ -86,7 +86,7 @@ class LS2G_API SGenContainer final: public OneSideContainer_PQ, public IteratorA const Eigen::Ref & sgen_bus_id ); - void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: void _compute_results( diff --git a/src/core/element_container/ShuntContainer.cpp b/src/core/element_container/ShuntContainer.cpp index 48c38320..8da815d8 100644 --- a/src/core/element_container/ShuntContainer.cpp +++ b/src/core/element_container/ShuntContainer.cpp @@ -102,7 +102,7 @@ void ShuntContainer::fillBp_Bpp(std::vector > & /*Bp*/ } } -void ShuntContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const // in DC i need that +void ShuntContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const // in DC i need that { if(ac) return; // in AC I do not do that // std::cout << " ok i use this function" << std::endl; diff --git a/src/core/element_container/ShuntContainer.hpp b/src/core/element_container/ShuntContainer.hpp index a0d22bc6..93d1d94d 100644 --- a/src/core/element_container/ShuntContainer.hpp +++ b/src/core/element_container/ShuntContainer.hpp @@ -80,7 +80,7 @@ class LS2G_API ShuntContainer final: public OneSideContainer_PQ, public Iterator const SolverBusIdVect & id_grid_to_solver, real_type sn_mva, FDPFMethod xb_or_bx) const override; - void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; // in DC i need that + void fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; // in DC i need that protected: void _change_p(int shunt_id, real_type new_p, bool /*my_status*/, DualAlgoControl & solver_control) override diff --git a/src/core/element_container/StorageContainer.cpp b/src/core/element_container/StorageContainer.cpp index 27a3372d..79d75d88 100644 --- a/src/core/element_container/StorageContainer.cpp +++ b/src/core/element_container/StorageContainer.cpp @@ -27,7 +27,7 @@ void StorageContainer::set_state(StorageContainer::StateRes & my_state) reset_results(); } -void StorageContainer::fillSbus(CplxVect & Sbus, +void StorageContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { diff --git a/src/core/element_container/StorageContainer.hpp b/src/core/element_container/StorageContainer.hpp index 546e140c..605b758f 100644 --- a/src/core/element_container/StorageContainer.hpp +++ b/src/core/element_container/StorageContainer.hpp @@ -82,7 +82,7 @@ class LS2G_API StorageContainer final: public OneSideContainer_PQ, public Iterat reset_results(); } - void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; protected: void _compute_results(const Eigen::Ref & /*Va*/, diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index d448d1f9..8b584f57 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -85,7 +85,7 @@ void SvcContainer::set_state(SvcContainer::StateRes & my_state) reset_results(); } -void SvcContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const +void SvcContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_svc = nb(); for(int svc_id = 0; svc_id < nb_svc; ++svc_id){ @@ -113,7 +113,7 @@ void SvcContainer::fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_ } } -void SvcContainer::get_vm_for_dc(RealVect & Vm) +void SvcContainer::get_vm_for_dc(Eigen::Ref Vm) { const int nb_svc = nb(); for(int svc_id = 0; svc_id < nb_svc; ++svc_id){ @@ -125,7 +125,7 @@ void SvcContainer::get_vm_for_dc(RealVect & Vm) } } -void SvcContainer::set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const +void SvcContainer::set_vm(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const { const int nb_svc = nb(); for(int svc_id = 0; svc_id < nb_svc; ++svc_id){ diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 9f836983..74cb1003 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -136,9 +136,9 @@ class LS2G_API SvcContainer final : public OneSideContainer_PQ, public IteratorA void set_voltage_control_q(int svc_id, real_type q_mvar) {res_q_(svc_id) = q_mvar;} // solver interface - void fillSbus(CplxVect & Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; - void get_vm_for_dc(RealVect & Vm); - void set_vm(CplxVect & V, const SolverBusIdVect & id_grid_to_solver) const; + void fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool ac) const override; + void get_vm_for_dc(Eigen::Ref Vm); + void set_vm(Eigen::Ref V, const SolverBusIdVect & id_grid_to_solver) const; protected: void _compute_results( diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 7d102ebc..3f16ac1c 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -225,7 +225,7 @@ void TrafoContainer::_update_model_coeffs_one_el(int el_id) } void TrafoContainer::hack_Sbus_for_dc_phase_shifter( - CplxVect & Sbus, + Eigen::Ref Sbus, bool ac, const SolverBusIdVect & id_grid_to_solver) { diff --git a/src/core/element_container/TrafoContainer.hpp b/src/core/element_container/TrafoContainer.hpp index 9d2d64e2..6281ceb4 100644 --- a/src/core/element_container/TrafoContainer.hpp +++ b/src/core/element_container/TrafoContainer.hpp @@ -136,7 +136,7 @@ class LS2G_API TrafoContainer final : public TwoSidesContainer_rxh_A Sbus, bool ac, const SolverBusIdVect & id_grid_to_solver); // needed for dc mode diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index d7c7d939..f10a4780 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -220,27 +220,27 @@ class TwoSidesContainer : public GenericContainer } } - void set_pos_topo_vect_side_1(Eigen::Ref pos_topo_vect) + void set_pos_topo_vect_side_1(const Eigen::Ref & pos_topo_vect) { side_1_.set_pos_topo_vect(pos_topo_vect); } - void set_pos_topo_vect_side_2(Eigen::Ref pos_topo_vect) + void set_pos_topo_vect_side_2(const Eigen::Ref & pos_topo_vect) { side_2_.set_pos_topo_vect(pos_topo_vect); } - void set_subid_side_1(Eigen::Ref subid) + void set_subid_side_1(const Eigen::Ref & subid) { side_1_.set_subid(subid); } - void set_subid_side_2(Eigen::Ref subid) + void set_subid_side_2(const Eigen::Ref & subid) { side_2_.set_subid(subid); } virtual void update_topo( - Eigen::Ref > & has_changed, - Eigen::Ref > & new_values, + const Eigen::Ref > & has_changed, + const Eigen::Ref > & new_values, DualAlgoControl & solver_control, SubstationContainer & substations ) final diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 78a3c761..7282cd17 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -173,11 +173,11 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer // current limit, in kA, per side -- input, not a powerflow result. // Optional: empty (size 0) if never set (e.g. pandapower-origin grids). - void set_limit_a1_ka(const RealVect & limit_a1_ka){ + void set_limit_a1_ka(const Eigen::Ref & limit_a1_ka){ check_size(limit_a1_ka, nb(), "TwoSidesContainer_rxh_A::set_limit_a1_ka"); limit_a1_ka_ = limit_a1_ka; } - void set_limit_a2_ka(const RealVect & limit_a2_ka){ + void set_limit_a2_ka(const Eigen::Ref & limit_a2_ka){ check_size(limit_a2_ka, nb(), "TwoSidesContainer_rxh_A::set_limit_a2_ka"); limit_a2_ka_ = limit_a2_ka; } diff --git a/src/core/linear_solvers/CKTSOSolver.cpp b/src/core/linear_solvers/CKTSOSolver.cpp index e78aa758..f5fa1a70 100644 --- a/src/core/linear_solvers/CKTSOSolver.cpp +++ b/src/core/linear_solvers/CKTSOSolver.cpp @@ -96,7 +96,7 @@ ErrorType CKTSOLinearSolver::refactorize(const Eigen::SparseMatrix & return ErrorType::NoError; } -ErrorType CKTSOLinearSolver::solve(RealVect & b){ +ErrorType CKTSOLinearSolver::solve(Eigen::Ref b){ RealVect x(b.size()); int ret = solver_->Solve(&b(0), &x(0), false, 1); if(ret < 0){ diff --git a/src/core/linear_solvers/CKTSOSolver.hpp b/src/core/linear_solvers/CKTSOSolver.hpp index da90d9ba..fbfe066a 100644 --- a/src/core/linear_solvers/CKTSOSolver.hpp +++ b/src/core/linear_solvers/CKTSOSolver.hpp @@ -86,7 +86,7 @@ class LS2G_API CKTSOLinearSolver final ErrorType analyze(const Eigen::SparseMatrix & J); // creates solver + reordering + symbolic factorization ErrorType factorize(const Eigen::SparseMatrix & J); // numeric factorization (requires values) ErrorType refactorize(const Eigen::SparseMatrix & J); // re-numeric factorization, reuses symbolic - ErrorType solve(RealVect & b); + ErrorType solve(Eigen::Ref b); // can this linear solver solve problem where RHS is a matrix static const bool CAN_SOLVE_MAT; diff --git a/src/core/linear_solvers/KLUSolver.cpp b/src/core/linear_solvers/KLUSolver.cpp index 7ef7364b..83ca45e3 100644 --- a/src/core/linear_solvers/KLUSolver.cpp +++ b/src/core/linear_solvers/KLUSolver.cpp @@ -63,7 +63,7 @@ ErrorType KLULinearSolver::refactorize(const Eigen::SparseMatrix& J){ return ErrorType::NoError; } -ErrorType KLULinearSolver::solve(RealVect & b){ +ErrorType KLULinearSolver::solve(Eigen::Ref b){ const int n = static_cast(b.size()); int ok = klu_solve(symbolic_.get(), numeric_.get(), n, 1, &b(0), &common_); if(ok != 1){ diff --git a/src/core/linear_solvers/KLUSolver.hpp b/src/core/linear_solvers/KLUSolver.hpp index 78550d95..713886d3 100644 --- a/src/core/linear_solvers/KLUSolver.hpp +++ b/src/core/linear_solvers/KLUSolver.hpp @@ -54,7 +54,7 @@ class LS2G_API KLULinearSolver final ErrorType analyze(const Eigen::SparseMatrix& J); // reordering + symbolic factorization (structure only) ErrorType factorize(const Eigen::SparseMatrix& J); // numeric factorization (requires values) ErrorType refactorize(const Eigen::SparseMatrix& J); // re-numeric factorization, reuses symbolic - ErrorType solve(RealVect & b); + ErrorType solve(Eigen::Ref b); // can this linear solver solve problem where RHS is a matrix static const bool CAN_SOLVE_MAT; diff --git a/src/core/linear_solvers/NICSLUSolver.cpp b/src/core/linear_solvers/NICSLUSolver.cpp index 0a108d82..9c85efd4 100644 --- a/src/core/linear_solvers/NICSLUSolver.cpp +++ b/src/core/linear_solvers/NICSLUSolver.cpp @@ -84,7 +84,7 @@ ErrorType NICSLULinearSolver::refactorize(const Eigen::SparseMatrix & return ErrorType::NoError; } -ErrorType NICSLULinearSolver::solve(RealVect & b){ +ErrorType NICSLULinearSolver::solve(Eigen::Ref b){ RealVect x(b.size()); int ret = solver_.Solve(&b(0), &x(0)); if(ret < 0){ diff --git a/src/core/linear_solvers/NICSLUSolver.hpp b/src/core/linear_solvers/NICSLUSolver.hpp index b38e6569..48c02fad 100644 --- a/src/core/linear_solvers/NICSLUSolver.hpp +++ b/src/core/linear_solvers/NICSLUSolver.hpp @@ -73,7 +73,7 @@ class LS2G_API NICSLULinearSolver final ErrorType analyze(const Eigen::SparseMatrix & J); // reordering + symbolic factorization (may use values for MC64 scaling) ErrorType factorize(const Eigen::SparseMatrix & J); // numeric factorization (requires values) ErrorType refactorize(const Eigen::SparseMatrix & J); // re-numeric factorization, reuses symbolic - ErrorType solve(RealVect & b); + ErrorType solve(Eigen::Ref b); // can this linear solver solve problem where RHS is a matrix static const bool CAN_SOLVE_MAT; diff --git a/src/core/linear_solvers/SparseLUSolver.cpp b/src/core/linear_solvers/SparseLUSolver.cpp index 4dcfafe3..69fc2273 100644 --- a/src/core/linear_solvers/SparseLUSolver.cpp +++ b/src/core/linear_solvers/SparseLUSolver.cpp @@ -32,7 +32,7 @@ ErrorType SparseLULinearSolver::refactorize(const Eigen::SparseMatrix return ErrorType::NoError; } -ErrorType SparseLULinearSolver::solve(RealVect & b){ +ErrorType SparseLULinearSolver::solve(Eigen::Ref b){ ErrorType err = ErrorType::NoError; RealVect Va = solver_.solve(b); // std::cout << "\t\tSparseLUSolver.cpp: solver_.info: " << solver_.info() << std::endl; // TODO DEBUG WINDOWS diff --git a/src/core/linear_solvers/SparseLUSolver.hpp b/src/core/linear_solvers/SparseLUSolver.hpp index e82b22ce..4cf81a0c 100644 --- a/src/core/linear_solvers/SparseLUSolver.hpp +++ b/src/core/linear_solvers/SparseLUSolver.hpp @@ -41,7 +41,7 @@ class LS2G_API SparseLULinearSolver final ErrorType analyze(const Eigen::SparseMatrix & J); // reordering + symbolic factorization (structure only) ErrorType factorize(const Eigen::SparseMatrix & J); // numeric factorization (requires values) ErrorType refactorize(const Eigen::SparseMatrix & J); // re-numeric factorization, reuses symbolic - ErrorType solve(RealVect & b); + ErrorType solve(Eigen::Ref b); ErrorType reset(){ return ErrorType::NoError; } diff --git a/src/core/powerflow_algorithm/BaseAlgo.cpp b/src/core/powerflow_algorithm/BaseAlgo.cpp index 28b2639b..2acd95b9 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.cpp +++ b/src/core/powerflow_algorithm/BaseAlgo.cpp @@ -20,7 +20,7 @@ namespace { // `marks` holds, for each bus, 0 (unseen) or the 1-based index into // `array_names` of the array that already claimed it. void check_bus_id_array(const std::string & caller, - Eigen::Ref ids, + const Eigen::Ref & ids, Eigen::Index n, std::vector & marks, int array_code, @@ -50,13 +50,16 @@ void check_bus_id_array(const std::string & caller, } // anonymous namespace -void BaseAlgo::check_pf_inputs(const std::string & caller, - Eigen::Index ybus_rows, Eigen::Index ybus_cols, - Eigen::Index v_size, Eigen::Index sbus_size, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) +void BaseAlgo::check_pf_inputs( + const std::string & caller, + Eigen::Index ybus_rows, + Eigen::Index ybus_cols, + Eigen::Index v_size, + Eigen::Index sbus_size, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { if (ybus_rows != ybus_cols) { std::ostringstream exc_; @@ -112,12 +115,12 @@ void BaseAlgo::check_iter_tol(const std::string & caller, int max_iter, real_typ bool BaseAlgo::compute_pf_with_input_validation( const Eigen::SparseMatrix & Ybus, - CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, int max_iter, real_type tol) { @@ -129,12 +132,12 @@ bool BaseAlgo::compute_pf_with_input_validation( bool BaseAlgo::compute_pf_dc_with_input_validation( const Eigen::SparseMatrix & Bbus, - CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & V, + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { check_pf_inputs("compute_pf_dc", Bbus.rows(), Bbus.cols(), V.size(), Pbus.size(), slack_ids, slack_weights, pv, pq); @@ -158,11 +161,12 @@ void BaseAlgo::reset(){ } -RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, - const CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref pv, - Eigen::Ref pq) +RealVect BaseAlgo::_evaluate_Fx( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { auto timer = CustTimer(); auto npv = pv.size(); @@ -184,14 +188,15 @@ RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, return res; } -RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, - const CplxVect & V, - Eigen::Ref Sbus, - size_t slack_id, // id of the ref slack bus - real_type slack_absorbed, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) +RealVect BaseAlgo::_evaluate_Fx( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + size_t slack_id, // id of the ref slack bus + real_type slack_absorbed, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { /** Remember, when this function is used: @@ -248,8 +253,9 @@ RealVect BaseAlgo::_evaluate_Fx(const Eigen::SparseMatrix & Ybus, } -bool BaseAlgo::_check_for_convergence(const RealVect & F, - real_type tol) +bool BaseAlgo::_check_for_convergence( + const Eigen::Ref & F, + real_type tol) { auto timer = CustTimer(); const auto norm_inf = F.lpNorm(); @@ -258,9 +264,10 @@ bool BaseAlgo::_check_for_convergence(const RealVect & F, return res; } -bool BaseAlgo::_check_for_convergence(const RealVect & p, - const RealVect & q, - real_type tol) +bool BaseAlgo::_check_for_convergence( + const Eigen::Ref & p, + const Eigen::Ref & q, + real_type tol) { auto timer = CustTimer(); const auto norm_inf_p = p.lpNorm(); @@ -270,9 +277,10 @@ bool BaseAlgo::_check_for_convergence(const RealVect & p, return res; } -Eigen::VectorXi BaseAlgo::extract_slack_bus_id(Eigen::Ref pv, - Eigen::Ref pq, - unsigned int nb_bus) +Eigen::VectorXi BaseAlgo::extract_slack_bus_id( + const Eigen::Ref & pv, + const Eigen::Ref & pq, + unsigned int nb_bus) { // pv: list of index of pv nodes // pq: list of index of pq nodes diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 78848a04..d85428a7 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -107,13 +107,16 @@ class LS2G_API BaseAlgo : public BaseConstants // Throws std::out_of_range (python IndexError) for any id of // slack_ids / pv / pq outside [0, n). // `caller` names the solver in the error message (eg "NRAlgo::compute_pf"). - static void check_pf_inputs(const std::string & caller, - Eigen::Index ybus_rows, Eigen::Index ybus_cols, - Eigen::Index v_size, Eigen::Index sbus_size, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); + static void check_pf_inputs( + const std::string & caller, + Eigen::Index ybus_rows, + Eigen::Index ybus_cols, + Eigen::Index v_size, + Eigen::Index sbus_size, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); // Throws std::runtime_error unless max_iter >= 0 (0 is a legitimate, // well-defined call: every solver builds its initial state / first @@ -123,7 +126,10 @@ class LS2G_API BaseAlgo : public BaseConstants // negative values are nonsensical) and tol is a finite // strictly positive number (NaN and infinity are rejected). AC only: // DC is a single linear solve without iterations / tolerance. - static void check_iter_tol(const std::string & caller, int max_iter, real_type tol); + static void check_iter_tol( + const std::string & caller, + int max_iter, + real_type tol); // Checked wrapper around the (virtual) compute_pf: validates with // check_pf_inputs + check_iter_tol, then dispatches to compute_pf on @@ -133,31 +139,44 @@ class LS2G_API BaseAlgo : public BaseConstants // the method actually bound to python's Solver.compute_pf / .solve // (see binding_solvers.cpp) -- the raw compute_pf keeps its name and // stays reachable only from C++. + // V is read-only here (see compute_pf's declaration above): this changes the + // python-facing Solver.compute_pf/.solve parameter from a mutable numpy array + // to a read-only one -- nothing in-tree relied on in-place mutation. + // Ybus is a bare (non-Ref) Eigen::SparseMatrix, unlike every other Ybus/Bbus + // parameter in this file: this is the one signature pybind11 actually binds + // (see binding_solvers.cpp), and pybind11's Eigen support has no type_caster + // for Eigen::Ref> (only for a concrete, owning + // SparseMatrix -- see TODO in CHANGELOG.rst about writing one to avoid this + // copy). Internally this is still cheap: it just forwards into the + // Ref-taking compute_pf below, a single C++-side (not python-side) bind. bool compute_pf_with_input_validation( const Eigen::SparseMatrix & Ybus, - CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol); + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol); // Same idea for the DC entry point (validates, then calls the // virtual compute_pf_dc). Not bound to python today -- compute_pf_dc // itself isn't exposed under any name -- kept symmetric with the AC // wrapper above and ready if that binding gap is ever closed. + // V stays plain: see the comment on the virtual compute_pf_dc above (resized). + // Bbus is a bare (non-Ref) Eigen::SparseMatrix for the same pybind11 reason as + // Ybus in compute_pf_with_input_validation above. bool compute_pf_dc_with_input_validation( const Eigen::SparseMatrix & Bbus, - CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); - - virtual Eigen::Ref > get_J() const { + const Eigen::Ref & V, + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); + + virtual EigenRefConstRealSpMat get_J() const { throw std::runtime_error("AlgorithmSelector::get_J: There is not Jacobian matrix for this solver type."); } @@ -311,14 +330,23 @@ class LS2G_API BaseAlgo : public BaseConstants // Complex AC entry point: solves V . (Ybus . V)* = Sbus. // Every AC solver overrides this; the DC solver does not (it uses `compute_pf_dc`) and // therefore inherits this throwing default (symmetric with `compute_pf_dc` below). + // Ybus stays a plain reference (not Eigen::Ref): NRSystem::update_state caches + // its address (Ybus_ptr_) across phases (update_state -> build_J_sparsity), + // which needs the caller's actual, long-lived matrix, not a call-site Ref + // temporary. Since compute_pf is one shared virtual signature (BaseAlgo, + // NRAlgo, GaussSeidelAlgo, BaseFDPFAlgo all share it), every Ybus/Ybus-forwarding + // parameter in this family must stay plain for the same reason. + // V is the Vinit on input; none of the AC overriders write back into it (the + // converged result is retrieved via get_V()), so it's read-only in practice -- + // unlike compute_pf_dc's V (BaseDCAlgo resizes/reassigns it), which stays plain. virtual - bool compute_pf(const Eigen::SparseMatrix & /*Ybus*/, - CplxVect & /*V*/, // store the results of the powerflow and the Vinit ! - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/, + bool compute_pf(const EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & /*V*/, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/, int /*max_iter*/, real_type /*tol*/ ){ @@ -329,14 +357,19 @@ class LS2G_API BaseAlgo : public BaseConstants // Only the DC solver overrides this; every other solver type throws. // `V` carries the complex initial voltage (slack angle + voltage setpoints) on input // and the complex result on output. There is no max_iter / tol: DC is a single linear solve. + // V stays a plain reference (not Eigen::Ref): BaseDCAlgo::compute_pf_dc + // resizes/reassigns it (V.resize(...), V = CplxVect() on the early-error + // path) -- unlike compute_pf's V (AC family), which is read-only and was + // converted to Eigen::Ref. compute_pf/compute_pf_dc are independent virtual + // chains (different overrider sets), so there's no need for them to match. virtual - bool compute_pf_dc(const Eigen::SparseMatrix & /*Bbus*/, - CplxVect & /*V*/, - Eigen::Ref /*Pbus*/, - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/){ + bool compute_pf_dc(const EigenRefConstRealSpMat & /*Bbus*/, + const Eigen::Ref & /*V*/, + const Eigen::Ref & /*Pbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/){ throw std::runtime_error("compute_pf_dc is only available for DC solvers."); } @@ -344,11 +377,13 @@ class LS2G_API BaseAlgo : public BaseConstants _solver_control = solver_control; } virtual void reset(); + // TODO speed: prevent copy and use Eigen::Ref here virtual RealMat get_ptdf(){ throw std::runtime_error("Impossible to get the PTDF matrix with this solver type."); } - virtual RealMat get_lodf(const IntVect & /*from_bus*/, - const IntVect & /*to_bus*/){ // TODO interface is likely to change + // TODO speed: prevent copy and use Eigen::Ref here + virtual RealMat get_lodf(const Eigen::Ref & /*from_bus*/, + const Eigen::Ref & /*to_bus*/){ // TODO interface is likely to change throw std::runtime_error("Impossible to get the LODF matrix with this solver type."); } virtual Eigen::SparseMatrix get_bsdf(){ // TODO interface is likely to change @@ -384,30 +419,30 @@ class LS2G_API BaseAlgo : public BaseConstants // return res; return (err_ != ErrorType::LicenseError); } - RealVect _evaluate_Fx(const Eigen::SparseMatrix & Ybus, - const CplxVect & V, - Eigen::Ref Sbus, - size_t slack_id, // id of the slack bus - real_type slack_absorbed, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); - - RealVect _evaluate_Fx(const Eigen::SparseMatrix & Ybus, - const CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref pv, - Eigen::Ref pq); - - bool _check_for_convergence(const RealVect & F, + RealVect _evaluate_Fx(const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + size_t slack_id, // id of the slack bus + real_type slack_absorbed, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); + + RealVect _evaluate_Fx(const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & pv, + const Eigen::Ref & pq); + + bool _check_for_convergence(const Eigen::Ref & F, real_type tol); - bool _check_for_convergence(const RealVect & p, - const RealVect & q, + bool _check_for_convergence(const Eigen::Ref & p, + const Eigen::Ref & q, real_type tol); - Eigen::VectorXi extract_slack_bus_id(Eigen::Ref pv, - Eigen::Ref pq, + Eigen::VectorXi extract_slack_bus_id(const Eigen::Ref & pv, + const Eigen::Ref & pq, unsigned int nb_bus); /** @@ -424,8 +459,8 @@ class LS2G_API BaseAlgo : public BaseConstants not a crash, just wrong (or non-reproducible) results that are miserable to debug back to this assumption. **/ - Eigen::VectorXi retrieve_pv_with_slack(Eigen::Ref slack_ids, - Eigen::Ref pv) const { + Eigen::VectorXi retrieve_pv_with_slack(const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) const { if(slack_ids.size() > 1){ const auto nb_slack_added = slack_ids.size() - 1; Eigen::VectorXi my_pv = Eigen::VectorXi(pv.size() + nb_slack_added); @@ -444,15 +479,15 @@ class LS2G_API BaseAlgo : public BaseConstants /** When there are multiple slacks, add the other "slack buses" in the PV buses indexes **/ - Eigen::VectorXi add_slack_to_pv(Eigen::Ref slack_ids, - Eigen::Ref pv) const { + Eigen::VectorXi add_slack_to_pv(const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) const { Eigen::VectorXi my_pv = Eigen::VectorXi(slack_ids.size() + pv.size()); my_pv << slack_ids, pv; return my_pv; } // terribly inefficient way to know if an element is in a vector - bool isin(int k, Eigen::Ref vect) const{ + bool isin(int k, const Eigen::Ref & vect) const{ for(auto el : vect){ if(el == k) return true; } diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index a45cb336..e197ebbb 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -84,18 +84,24 @@ class BaseDCAlgo final: public BaseAlgo // Native real-valued DC power flow: solves `Bbus . theta = Pbus`. // (the DC solver does not implement the complex `compute_pf`: it inherits the throwing // default from BaseAlgo, since every DC code path goes through `compute_pf_dc`) - bool compute_pf_dc(const Eigen::SparseMatrix & Bbus, - CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq - ) override; + // V stays a plain reference (not Eigen::Ref): it is resized/reassigned below + // (V.resize(...), V = CplxVect() on the early-error path). + bool compute_pf_dc( + const EigenRefConstRealSpMat & Bbus, + const Eigen::Ref & V, + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq + ) override; RealMat get_ptdf() override; - RealMat get_lodf(const IntVect & from_bus, - const IntVect & to_bus) override; + RealMat get_lodf( + const Eigen::Ref & from_bus, + const Eigen::Ref & to_bus + ) override; + Eigen::SparseMatrix get_bsdf() override; // TODO BSDF void update_internal_Ybus(const Coeff & coeff, bool add) override{ diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 4ca341fb..670590d0 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -11,14 +11,15 @@ #include // for nans template -bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix & Bbus, - CplxVect & V, - Eigen::Ref Pbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq - ) +bool BaseDCAlgo::compute_pf_dc( + const EigenRefConstRealSpMat & Bbus, + const Eigen::Ref & V, + const Eigen::Ref & Pbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq +) { // V is used the following way: at pq buses it's completely ignored. For pv bus only the magnitude is used, // and for the slack bus both the magnitude and the angle are used. @@ -277,7 +278,6 @@ bool BaseDCAlgo::compute_pf_dc(const Eigen::SparseMatrix::compute_pf_dc(const Eigen::SparseMatrix::get_ptdf(){ } template -RealMat BaseDCAlgo::get_lodf(const IntVect & from_bus, - const IntVect & to_bus){ +RealMat BaseDCAlgo::get_lodf(const Eigen::Ref & from_bus, + const Eigen::Ref & to_bus){ auto timer = CustTimer(); const RealMat PTDF = get_ptdf(); // size n_line x n_bus RealMat LODF = RealMat::Zero(from_bus.size(), from_bus.rows()); // nb_line, nb_line diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index fb57268c..550e15f6 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -26,15 +26,15 @@ class BaseFDPFAlgo final: public BaseAlgo static constexpr bool IS_FDPF = true; bool is_fdpf() const noexcept override { return IS_FDPF; } - bool compute_pf(const Eigen::SparseMatrix & Ybus, - CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol + bool compute_pf(const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol ) override; // requires a gridmodel ! void reset() override @@ -56,20 +56,24 @@ class BaseFDPFAlgo final: public BaseAlgo if((reset_status != ErrorType::NoError) && (err_ != ErrorType::NotInitError)) err_ = reset_status; } - Eigen::SparseMatrix debug_get_Bp_python() { return Bp_;} - Eigen::SparseMatrix debug_get_Bpp_python() { return Bpp_;} + // bare (non-Ref) return type: these are bound directly to python + // (binding_solvers.cpp), and pybind11's Eigen support has no type_caster for + // Eigen::Ref> (only for a concrete, owning + // SparseMatrix -- see TODO in CHANGELOG.rst), same reason as get_J_python. + Eigen::SparseMatrix debug_get_Bp_python() const { return Bp_;} + Eigen::SparseMatrix debug_get_Bpp_python() const { return Bpp_;} protected: void reset_timer() override { BaseAlgo::reset_timer(); } - CplxVect evaluate_mismatch(const Eigen::SparseMatrix & Ybus, - const CplxVect & V, - Eigen::Ref Sbus, + CplxVect evaluate_mismatch(const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, size_t /*slack_id*/, // id of the ref slack bus real_type slack_absorbed, - Eigen::Ref slack_weights) + const Eigen::Ref & slack_weights) { // CplxVect tmp = Ybus * V; // this is a vector // tmp = tmp.array().conjugate(); // i take the conjugate @@ -77,7 +81,9 @@ class BaseFDPFAlgo final: public BaseAlgo return mis; } - void fillBp_Bpp(Eigen::SparseMatrix & Bp, Eigen::SparseMatrix & Bpp) const; // defined in Solvers.cpp ! + void fillBp_Bpp( + Eigen::SparseMatrix & Bp, + Eigen::SparseMatrix & Bpp) const; // defined in Solvers.cpp ! void initialize() { auto timer = CustTimer(); @@ -113,7 +119,7 @@ class BaseFDPFAlgo final: public BaseAlgo } void solve(LinearSolver& linear_solver, - RealVect & b){ + Eigen::Ref b){ auto timer = CustTimer(); const ErrorType solve_status = linear_solver.solve(b); if(solve_status != ErrorType::NoError){ @@ -123,15 +129,16 @@ class BaseFDPFAlgo final: public BaseAlgo timer_solve_ += timer.duration(); } - bool has_converged(const Eigen::Ref & tmp_va, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref Sbus, - size_t slack_bus_id, - real_type & slack_absorbed, - Eigen::Ref slack_weights, - const Eigen::Ref & pvpq, - const Eigen::Ref & pq, - real_type tol) + bool has_converged( + const Eigen::Ref & tmp_va, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & Sbus, + size_t slack_bus_id, + real_type & slack_absorbed, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pvpq, + const Eigen::Ref & pq, + real_type tol) { /** It is suppose to implement the python code bellow (so it updates p_, q_, v_, va_ and vm_): @@ -175,17 +182,19 @@ class BaseFDPFAlgo final: public BaseAlgo return _check_for_convergence(p_, q_, tol); } - void fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp, - const Eigen::SparseMatrix & grid_Bpp, - const std::vector & pvpq_inv, - const std::vector & pq_inv, - size_t n_pvpq, - size_t n_pq); - - void aux_fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp_Bpp, - const std::vector & ind_inv, - size_t mat_dim, - Eigen::SparseMatrix & res); + void fill_sparse_matrices( + const EigenRefConstRealSpMat & grid_Bp, + const EigenRefConstRealSpMat & grid_Bpp, + const std::vector & pvpq_inv, + const std::vector & pq_inv, + size_t n_pvpq, + size_t n_pq); + + void aux_fill_sparse_matrices( + const EigenRefConstRealSpMat & grid_Bp_Bpp, + const std::vector & ind_inv, + size_t mat_dim, + Eigen::SparseMatrix & res); protected: // use 2 linear solvers diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp index c3201b47..69e3837c 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp @@ -9,16 +9,17 @@ // inspired from pypower https://github.com/rwl/PYPOWER/blob/master/pypower/fdpf.py template -bool BaseFDPFAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, - CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol - ) +bool BaseFDPFAlgo::compute_pf( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol +) { /** This method uses the newton raphson algorithm to compute voltage angles and magnitudes at each bus @@ -154,12 +155,13 @@ bool BaseFDPFAlgo::compute_pf(const Eigen::SparseMatrix -void BaseFDPFAlgo::fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp, - const Eigen::SparseMatrix & grid_Bpp, - const std::vector & pvpq_inv, - const std::vector & pq_inv, - size_t n_pvpq, - size_t n_pq) +void BaseFDPFAlgo::fill_sparse_matrices( + const EigenRefConstRealSpMat & grid_Bp, + const EigenRefConstRealSpMat & grid_Bpp, + const std::vector & pvpq_inv, + const std::vector & pq_inv, + size_t n_pvpq, + size_t n_pq) { /** Init Bp_ and Bpp_ such that: @@ -171,10 +173,11 @@ void BaseFDPFAlgo::fill_sparse_matrices(const Eigen::Sparse } template -void BaseFDPFAlgo::aux_fill_sparse_matrices(const Eigen::SparseMatrix & grid_Bp_Bpp, - const std::vector & ind_inv, - size_t mat_dim, - Eigen::SparseMatrix & res) +void BaseFDPFAlgo::aux_fill_sparse_matrices( + const EigenRefConstRealSpMat & grid_Bp_Bpp, + const std::vector & ind_inv, + size_t mat_dim, + Eigen::SparseMatrix & res) { // clear previous matrix res = Eigen::SparseMatrix(mat_dim, mat_dim); @@ -182,7 +185,7 @@ void BaseFDPFAlgo::aux_fill_sparse_matrices(const Eigen::Sp std::vector > tripletList; tripletList.reserve(grid_Bp_Bpp.nonZeros()); for (int k = 0; k < grid_Bp_Bpp.outerSize(); ++k){ - for (Eigen::SparseMatrix::InnerIterator it(grid_Bp_Bpp, k); it; ++it){ + for (EigenRefConstRealSpMat::InnerIterator it(grid_Bp_Bpp, k); it; ++it){ if ((ind_inv[it.row()] >= 0) && (ind_inv[it.col()] >= 0)){ auto tmp = Eigen::Triplet(ind_inv[it.row()], ind_inv[it.col()], it.value()); tripletList.push_back(tmp); diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp index 436aae64..60d46be8 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.cpp @@ -10,16 +10,17 @@ namespace ls2g { -bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, - CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref /*slack_weights*/, // currently unused - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol - ) +bool GaussSeidelAlgo::compute_pf( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & /*slack_weights*/, // currently unused + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol +) { /** pv: id of the pv buses @@ -80,10 +81,11 @@ bool GaussSeidelAlgo::compute_pf(const Eigen::SparseMatrix & Ybus, return res; } -void GaussSeidelAlgo::one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq) +void GaussSeidelAlgo::one_iter( + Eigen::Ref tmp_Sbus, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { // do an update with the standard GS algorithm cplx_type tmp; diff --git a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp index f4bd075c..e263e95a 100644 --- a/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelAlgo.hpp @@ -26,25 +26,27 @@ class LS2G_API GaussSeidelAlgo : public BaseAlgo } // todo change the name! - bool compute_pf(const Eigen::SparseMatrix & Ybus, - CplxVect & V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, // currently unused - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol - ) override; + bool compute_pf( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, // currently unused + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol + ) override; protected: virtual - void one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq - ); + void one_iter( + Eigen::Ref tmp_Sbus, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq + ); private: // no copy allowed diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp index 11d71009..0d97b31f 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.cpp @@ -10,10 +10,11 @@ namespace ls2g { -void GaussSeidelSynchAlgo::one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq) +void GaussSeidelSynchAlgo::one_iter( + Eigen::Ref tmp_Sbus, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { // do an update with all nodes being updated at the same time (different than the original GaussSeidel) cplx_type tmp; diff --git a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp index 8a91d440..f326f87e 100644 --- a/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp +++ b/src/core/powerflow_algorithm/GaussSeidelSynchAlgo.hpp @@ -25,11 +25,12 @@ class LS2G_API GaussSeidelSynchAlgo final: public GaussSeidelAlgo ~GaussSeidelSynchAlgo() noexcept override = default; protected: - void one_iter(CplxVect & tmp_Sbus, - const Eigen::SparseMatrix & Ybus, - Eigen::Ref pv, - Eigen::Ref pq - ) override; + void one_iter( + Eigen::Ref tmp_Sbus, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & pv, + const Eigen::Ref & pq + ) override; private: // no copy allowed diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 61944f4a..ccf8dd4b 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -133,16 +133,17 @@ class NRAlgo final : public BaseAlgo // ----- powerflow ----------------------------------------------------------- - bool compute_pf(const Eigen::SparseMatrix& Ybus, - CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol - ) override; + bool compute_pf( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol + ) override; void reset() override; diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index e1e547f7..58b308db 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -8,15 +8,15 @@ template bool NRAlgo::compute_pf( - const Eigen::SparseMatrix& Ybus, - CplxVect& V, - Eigen::Ref Sbus, - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, - int max_iter, - real_type tol) + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol) { if (!is_linear_solver_valid()) return false; diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 3c2b77c4..61e7a3ba 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -55,6 +55,8 @@ namespace ls2g { +static const Eigen::SparseMatrix _EmptySpMat = Eigen::SparseMatrix(); + class LSGrid; // only a pointer travels through the component protocol // ---- Primary template declaration (no definition) ----------------------------- @@ -156,7 +158,7 @@ class LS2G_API Base {} static int find_J_pos ( - Eigen::Ref > J_csc, + const Eigen::Ref > & J_csc, int row, int col){ int start = J_csc.outerIndexPtr()[col]; @@ -174,20 +176,20 @@ class LS2G_API Base // instantiation (SingleSlackNRSystem has no MultiSlack extension to // do this, so Base must own it). void update_state( - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); // call after update_state // at the beginning of each solve // only if the topology has changed void init_topology( - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref pv, - Eigen::Ref pq + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & pv, + const Eigen::Ref & pq ) { pv_ = IntVect(pv); pq_ = IntVect(pq); @@ -230,13 +232,13 @@ class LS2G_API Base } void declare_feature_entries(FeatureSink& /*sink*/) {} - void fill_feature_values(FeatureWriter& /*writer*/, const RealVect& /*Va*/) const {} - void adjust_mismatch(const CplxVect& /*V_t*/, const RealVect& /*dx*/, CplxVect& /*mis*/) const {} - void fill_custom_rows(RealVect& /*res*/, - const RealVect& /*Va*/, - const RealVect& /*Vm*/, - const RealVect& /*dx*/) const {} - void apply_step(const RealVect& /*dx*/) {} + void fill_feature_values(FeatureWriter& /*writer*/, const Eigen::Ref& /*Va*/) const {} + void adjust_mismatch(const Eigen::Ref& /*V_t*/, const Eigen::Ref& /*dx*/, Eigen::Ref /*mis*/) const {} + void fill_custom_rows(Eigen::Ref /*res*/, + const Eigen::Ref& /*Va*/, + const Eigen::Ref& /*Vm*/, + const Eigen::Ref& /*dx*/) const {} + void apply_step(const Eigen::Ref& /*dx*/) {} void clear() { pv_ = IntVect(); @@ -299,21 +301,21 @@ class LS2G_API MultiSlack // distributed-slack extension // call at the beginning of each NR solve once. It is not called for NR // iterations. Caches slack_weights / initial slack_absorbed. void update_state( - const Base * nr_system_base_ptr, - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Base * nr_system_base_ptr, + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); // call after update_state // at the beginning of each solve // only if the topology has changed void init_topology( - Eigen::Ref slack_ids, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/ + const Eigen::Ref & slack_ids, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/ ) { my_size_ = static_cast(slack_ids.size()); // `slack_ids[0]` is the reference bus by convention, shared with every other @@ -348,14 +350,14 @@ class LS2G_API MultiSlack // distributed-slack extension feature_handles_.push_back(sink.add(slack_p_rows_[k], slack_col_)); } - void fill_feature_values(FeatureWriter& writer, const RealVect& /*Va*/) const + void fill_feature_values(FeatureWriter& writer, const Eigen::Ref& /*Va*/) const { for (int k = 0; k < my_size_; ++k) writer.add(feature_handles_[k], slack_weights_(slack_buses_[k])); } // adjust the per-bus complex power mismatch by (slack_absorbed + dx step) * slack_weights - void adjust_mismatch(const CplxVect& /*V_t*/, const RealVect& dx, CplxVect& mis) const + void adjust_mismatch(const Eigen::Ref& /*V_t*/, const Eigen::Ref& dx, Eigen::Ref mis) const { const real_type sa = slack_absorbed_ + dx(slack_col_); mis.array() += (sa * slack_weights_.array()).cast(); @@ -363,14 +365,14 @@ class LS2G_API MultiSlack // distributed-slack extension // all this extension's rows are bus-owned P equations, // filled generically by NRSystem - void fill_custom_rows(RealVect& /*res*/, - const RealVect& /*Va*/, - const RealVect& /*Vm*/, - const RealVect& /*dx*/) const {} + void fill_custom_rows(Eigen::Ref /*res*/, + const Eigen::Ref& /*Va*/, + const Eigen::Ref& /*Vm*/, + const Eigen::Ref& /*dx*/) const {} // voltage updates (the non-ref slack thetas) are generic; only the // slack_absorbed state is owned by this extension - void apply_step(const RealVect& dx) + void apply_step(const Eigen::Ref& dx) { slack_absorbed_ += dx(slack_col_); } @@ -447,18 +449,18 @@ class LS2G_API Hvdc // pulls the droop data (solver bus ids, pu) from the grid; // defined in NRSystemHvdc.cpp (needs the full LSGrid type) void update_state( - const Base * nr_system_base_ptr, - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Base * nr_system_base_ptr, + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); void init_topology( - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/ + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/ ) {} // claims nothing: only caches the rows / columns of the two end buses @@ -493,7 +495,7 @@ class LS2G_API Hvdc } } - void fill_feature_values(FeatureWriter& writer, const RealVect& Va) const + void fill_feature_values(FeatureWriter& writer, const Eigen::Ref& Va) const { for (int k = 0; k < my_size_; ++k) { if (data_.status(k) != 0) continue; // saturated: constant injection, zero slopes @@ -511,7 +513,10 @@ class LS2G_API Hvdc // the flows LEAVE the buses into the hvdc: they ADD to the computed // power, ie to the mismatch (mis = V conj(Ybus V) - Sbus + ...) - void adjust_mismatch(const CplxVect& V_t, const RealVect& /*dx*/, CplxVect& mis) const + void adjust_mismatch( + const Eigen::Ref& V_t, + const Eigen::Ref& /*dx*/, + Eigen::Ref mis) const { real_type p1_flow, p2_flow; for (int k = 0; k < my_size_; ++k) { @@ -524,12 +529,12 @@ class LS2G_API Hvdc } } - void fill_custom_rows(RealVect& /*res*/, - const RealVect& /*Va*/, - const RealVect& /*Vm*/, - const RealVect& /*dx*/) const {} + void fill_custom_rows(Eigen::Ref /*res*/, + const Eigen::Ref& /*Va*/, + const Eigen::Ref& /*Vm*/, + const Eigen::Ref& /*dx*/) const {} - void apply_step(const RealVect& /*dx*/) {} + void apply_step(const Eigen::Ref& /*dx*/) {} void clear() { my_size_ = 0; @@ -615,18 +620,18 @@ class LS2G_API VoltageControl // the per-controller reactive state; defined in NRSystemVoltageControl.cpp // (needs the full LSGrid type) void update_state( - const Base * nr_system_base_ptr, - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Base * nr_system_base_ptr, + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ); void init_topology( - Eigen::Ref /*slack_ids*/, - Eigen::Ref /*slack_weights*/, - Eigen::Ref /*pv*/, - Eigen::Ref /*pq*/ + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/ ) {} // claims, per group: N q-unknown columns, 1 voltage row, N-1 sharing rows; @@ -690,7 +695,7 @@ class LS2G_API VoltageControl } } - void fill_feature_values(FeatureWriter& writer, const RealVect& /*Va*/) const + void fill_feature_values(FeatureWriter& writer, const Eigen::Ref& /*Va*/) const { const int ng = data_.n_groups(); for (int g = 0; g < ng; ++g) { @@ -711,7 +716,7 @@ class LS2G_API VoltageControl } // the controller reactive injection subtracts from the mismatch like Sbus - void adjust_mismatch(const CplxVect& /*V_t*/, const RealVect& dx, CplxVect& mis) const + void adjust_mismatch(const Eigen::Ref& /*V_t*/, const Eigen::Ref& dx, Eigen::Ref mis) const { const int nc = data_.n_controllers(); for (int j = 0; j < nc; ++j) @@ -719,10 +724,10 @@ class LS2G_API VoltageControl } // the bordered voltage and sharing rows - void fill_custom_rows(RealVect& res, - const RealVect& /*Va*/, - const RealVect& Vm, - const RealVect& dx) const + void fill_custom_rows(Eigen::Ref res, + const Eigen::Ref& /*Va*/, + const Eigen::Ref& Vm, + const Eigen::Ref& dx) const { const int ng = data_.n_groups(); for (int g = 0; g < ng; ++g) { @@ -748,7 +753,7 @@ class LS2G_API VoltageControl } } - void apply_step(const RealVect& dx) + void apply_step(const Eigen::Ref& dx) { const int nc = data_.n_controllers(); for (int j = 0; j < nc; ++j) q_(j) += dx(q_cols_[j]); @@ -833,7 +838,7 @@ class NRSystem timer_fillJ_(0.), masked_dirty_(false), lsgrid_ptr_(nullptr), - Ybus_ptr_(nullptr), + Ybus_ref_(_EmptySpMat), Sbus_data_ptr_(nullptr), Sbus_size_(0) {} @@ -842,19 +847,19 @@ class NRSystem // ----- Phase 1: topology init (call when pv/pq/slack topology changes) ------- void init_topology( - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq); + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq); // ----- Phase 1.5: per-compute_pf state update (cheap) ----------------------- void update_state( - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - const CplxVect& V_init, - Eigen::Ref Sbus, - Eigen::Ref slack_weights); + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V_init, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights); // ----- Phase 2: build J sparsity + value maps ------------------------------- @@ -881,8 +886,8 @@ class NRSystem // ----- NR iteration primitives ----------------------------------------------- RealVect mismatch() const; - void apply_step(const RealVect& dx); - real_type mismatch_sq_norm_at(const RealVect& dx) const; + void apply_step(const Eigen::Ref& dx); + real_type mismatch_sq_norm_at(const Eigen::Ref& dx) const; // ----- Housekeeping ---------------------------------------------------------- @@ -973,12 +978,12 @@ class NRSystem // ----- Scaling reductions ---------------------------------------------------- // max |angle step| / max |voltage-magnitude step| across all state variables. - real_type max_abs_dtheta(const RealVect& dx) const { + real_type max_abs_dtheta(const Eigen::Ref& dx) const { real_type m = static_cast(0.); for (int col : ledger_.theta_cols()) m = std::max(m, std::abs(dx(col))); return m; } - real_type max_abs_dvm(const RealVect& dx) const { + real_type max_abs_dvm(const Eigen::Ref& dx) const { real_type m = static_cast(0.); for (int col : ledger_.vm_cols()) m = std::max(m, std::abs(dx(col))); return m; @@ -1063,7 +1068,7 @@ class NRSystem protected: // visible attribute for derived class (non owning ptr) const LSGrid * lsgrid_ptr_; - const Eigen::SparseMatrix* Ybus_ptr_; + EigenRefConstCplxSpMat Ybus_ref_; // Sbus is cached as a raw data pointer + size (reconstructed as an // Eigen::Map on demand via _Sbus_view()) rather than a `const CplxVect*`: // update_state() now receives Sbus as an Eigen::Ref, which is itself a @@ -1071,25 +1076,25 @@ class NRSystem // previously for the concrete-reference version) would dangle the moment // update_state() returns. `.data()` instead points at the real, // caller-owned buffer the Ref views, which is what must outlive the - // whole solve (same contract as Ybus_ptr_ above). + // whole solve (same contract as Ybus_ref_ above). const cplx_type* Sbus_data_ptr_; Eigen::Index Sbus_size_; Eigen::Map _Sbus_view() const { return Eigen::Map(Sbus_data_ptr_, Sbus_size_); } - static CplxVect _reconstruct_V(const RealVect& Va, const RealVect& Vm); - CplxVect _compute_trial_V(const RealVect& dx) const; + static CplxVect _reconstruct_V(const Eigen::Ref& Va, const Eigen::Ref& Vm); + CplxVect _compute_trial_V(const Eigen::Ref& dx) const; // assemble the (negated) residual at trial voltages V_t; dx is the step that // produced V_t (used by components that carry extra state, e.g. slack absorbed). - RealVect _residual(const CplxVect& V_t, const RealVect& dx) const; + RealVect _residual(const Eigen::Ref& V_t, const Eigen::Ref& dx) const; private: // ---- component hook fold helpers (C++14 index_sequence/dummy-array idiom) --- template void _init_topology_extensions( - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, std::index_sequence) { int dummy[] = { 0, (std::get(extensions_).init_topology( slack_ids, @@ -1102,10 +1107,10 @@ class NRSystem template void _update_state_extensions( - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - Eigen::Ref Sbus, - Eigen::Ref slack_weights, + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights, std::index_sequence){ int dummy[] = { 0, (std::get(extensions_).update_state( &base_, @@ -1138,7 +1143,7 @@ class NRSystem } template - void _adjust_mismatch_extensions(const CplxVect& V_t, const RealVect& dx, CplxVect& mis, + void _adjust_mismatch_extensions(const Eigen::Ref& V_t, const Eigen::Ref& dx, Eigen::Ref mis, std::index_sequence) const { int dummy[] = { 0, (std::get(extensions_).adjust_mismatch(V_t, dx, mis), 0)... }; (void)dummy; @@ -1146,16 +1151,17 @@ class NRSystem } template - void _fill_custom_rows_extensions(RealVect& res, - const RealVect& Va, const RealVect& Vm, - const RealVect& dx, + void _fill_custom_rows_extensions(Eigen::Ref res, + const Eigen::Ref& Va, + const Eigen::Ref& Vm, + const Eigen::Ref& dx, std::index_sequence) const { int dummy[] = { 0, (std::get(extensions_).fill_custom_rows(res, Va, Vm, dx), 0)... }; (void)dummy; } template - void _apply_step_extensions(const RealVect& dx, std::index_sequence) { + void _apply_step_extensions(const Eigen::Ref& dx, std::index_sequence) { int dummy[] = { 0, (std::get(extensions_).apply_step(dx), 0)... }; (void)dummy; } diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index 1f11e656..ff4778e6 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -11,14 +11,15 @@ namespace ls2g { // ---- Phase 1: topology init -------------------------------------------------- template inline void NRSystem::init_topology( - Eigen::Ref slack_ids, - Eigen::Ref slack_weights, - Eigen::Ref pv, - Eigen::Ref pq) + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq) { // init the sparsity pattern of the dS matrices (values do not matter yet) - dS_dVm_ = *Ybus_ptr_; - dS_dVa_ = *Ybus_ptr_; + // TODO speed: copy only the sparsity pattern and not the values + dS_dVm_ = Ybus_ref_; // Ybus_ref_ is a reference, dS_dVm_ is a plain struct, this forces a copy + dS_dVa_ = Ybus_ref_; map_dsdva_r_.clear(); map_dsdva_i_.clear(); map_dsdvm_r_.clear(); @@ -30,7 +31,7 @@ inline void NRSystem::init_topology( // then claim their equations / unknowns in the ledger // (allocation order defines the J layout) - const int n_bus = static_cast(Ybus_ptr_->rows()); + const int n_bus = static_cast(Ybus_ref_.rows()); ledger_.reset(n_bus); base_.register_in(ledger_); _register_in_extensions(ledger_, std::make_index_sequence{}); @@ -39,14 +40,14 @@ inline void NRSystem::init_topology( // ---- Phase 1.5: per-compute_pf state update ---------------------------------- template inline void NRSystem::update_state( - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& Ybus, - const CplxVect& V_init, - Eigen::Ref Sbus, - Eigen::Ref slack_weights) + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V_init, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights) { lsgrid_ptr_ = lsgrid_ptr; - Ybus_ptr_ = &Ybus; + Ybus_ref_ = Ybus; Sbus_data_ptr_ = Sbus.data(); Sbus_size_ = Sbus.size(); @@ -65,17 +66,17 @@ inline void NRSystem::build_J_sparsity() { J_ = Eigen::SparseMatrix(); - const Eigen::SparseMatrix& Ybus = *Ybus_ptr_; - const int nnz_Y = static_cast(Ybus.nonZeros()); + // const Eigen::SparseMatrix& Ybus = *Ybus_ptr_; + const int nnz_Y = static_cast(Ybus_ref_.nonZeros()); // generic dS pass: ONE loop over the Ybus nonzeros generates every // dS-derived entry, for all components at once. k is the running nnz index. std::vector cntrb; cntrb.reserve(4 * nnz_Y); // pessimistic upper bound int k = 0; - for (int outer = 0; outer < Ybus.outerSize(); ++outer) { - for (Eigen::SparseMatrix::InnerIterator - it(Ybus, outer); it; ++it, ++k) + for (int outer = 0; outer < Ybus_ref_.outerSize(); ++outer) { + for (EigenRefConstCplxSpMat::InnerIterator + it(Ybus_ref_, outer); it; ++it, ++k) { const int i = static_cast(it.row()), j = static_cast(it.col()); const int pr = ledger_.p_row(i), qr = ledger_.q_row(i); @@ -179,10 +180,10 @@ template inline void NRSystem::fill_internal_variables() { auto timer = CustTimer(); - const Eigen::SparseMatrix& Ybus = *Ybus_ptr_; + // const Eigen::SparseMatrix& Ybus = *Ybus_ptr_; const auto size_dS = V_.size(); const CplxVect Vnorm = V_.array() / V_.array().abs(); - const CplxVect Ibus = Ybus * V_; + const CplxVect Ibus = Ybus_ref_ * V_; const CplxVect conjIbus_Vnorm = Ibus.array().conjugate() * Vnorm.array(); cplx_type * ds_dvm_val_ptr = dS_dVm_.valuePtr(); @@ -190,7 +191,7 @@ inline void NRSystem::fill_internal_variables() size_t pos = 0; for (size_t col_id = 0; col_id < static_cast(size_dS); ++col_id) { - for (Eigen::SparseMatrix::InnerIterator it(Ybus, col_id); it; ++it) { + for (EigenRefConstCplxSpMat::InnerIterator it(Ybus_ref_, col_id); it; ++it) { const size_t row_id = static_cast(it.row()); const cplx_type el_ybus = it.value(); @@ -225,7 +226,7 @@ inline RealVect NRSystem::mismatch() const } template -inline void NRSystem::apply_step(const RealVect& dx) +inline void NRSystem::apply_step(const Eigen::Ref& dx) { // generic voltage updates, driven by the ledger's (bus, col) pair lists const std::vector& theta_buses = ledger_.theta_buses(); @@ -247,13 +248,13 @@ inline void NRSystem::apply_step(const RealVect& dx) } template -inline real_type NRSystem::mismatch_sq_norm_at(const RealVect& dx) const +inline real_type NRSystem::mismatch_sq_norm_at(const Eigen::Ref& dx) const { return _residual(_compute_trial_V(dx), dx).squaredNorm(); } template -inline CplxVect NRSystem::_reconstruct_V(const RealVect& Va, const RealVect& Vm) +inline CplxVect NRSystem::_reconstruct_V(const Eigen::Ref& Va, const Eigen::Ref& Vm) { const cplx_type m_i = BaseConstants::my_i; return Vm.array() * (Va.array().cos().template cast() @@ -261,7 +262,7 @@ inline CplxVect NRSystem::_reconstruct_V(const RealVect& Va, cons } template -inline CplxVect NRSystem::_compute_trial_V(const RealVect& dx) const +inline CplxVect NRSystem::_compute_trial_V(const Eigen::Ref& dx) const { // same voltage loops as apply_step, on local copies; the components' extra // state is handled through the dx argument of adjust_mismatch / fill_custom_rows @@ -277,10 +278,10 @@ inline CplxVect NRSystem::_compute_trial_V(const RealVect& dx) co } template -inline RealVect NRSystem::_residual(const CplxVect& V_t, const RealVect& dx) const +inline RealVect NRSystem::_residual(const Eigen::Ref& V_t, const Eigen::Ref& dx) const { // per-bus complex power mismatch: V .* conj(Ybus V) - Sbus - CplxVect mis = V_t.array() * (*Ybus_ptr_ * V_t).array().conjugate() + CplxVect mis = V_t.array() * (Ybus_ref_ * V_t).array().conjugate() - _Sbus_view().array(); // components adjust the complex injection (e.g. + slack_absorbed * slack_weights, // + the theta-dependent hvdc droop flows) diff --git a/src/core/powerflow_algorithm/NRSystemBase.cpp b/src/core/powerflow_algorithm/NRSystemBase.cpp index b332ef42..96e5c027 100644 --- a/src/core/powerflow_algorithm/NRSystemBase.cpp +++ b/src/core/powerflow_algorithm/NRSystemBase.cpp @@ -21,10 +21,10 @@ namespace ls2g { void Base::update_state( - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_weights*/ + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/ ) { // Slack buses not pinned by a LOCAL voltage-regulating generator need a diff --git a/src/core/powerflow_algorithm/NRSystemHvdc.cpp b/src/core/powerflow_algorithm/NRSystemHvdc.cpp index 0512611a..b3f23214 100644 --- a/src/core/powerflow_algorithm/NRSystemHvdc.cpp +++ b/src/core/powerflow_algorithm/NRSystemHvdc.cpp @@ -15,11 +15,11 @@ namespace ls2g { void Hvdc::update_state( - const Base * /*nr_system_base_ptr*/, - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_weights*/ + const Base * /*nr_system_base_ptr*/, + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/ ) { data_.clear(); diff --git a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp index f9b103a7..47c3f365 100644 --- a/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp +++ b/src/core/powerflow_algorithm/NRSystemMultiSlack.cpp @@ -11,11 +11,11 @@ namespace ls2g { void MultiSlack::update_state( - const Base * /*nr_system_base_ptr*/, - const LSGrid * /*lsgrid_ptr*/, - const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref Sbus, - Eigen::Ref slack_weights + const Base * /*nr_system_base_ptr*/, + const LSGrid * /*lsgrid_ptr*/, + const EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_weights ) { slack_weights_ = slack_weights; diff --git a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp index 73fb6314..01118ec0 100644 --- a/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp +++ b/src/core/powerflow_algorithm/NRSystemVoltageControl.cpp @@ -15,11 +15,11 @@ namespace ls2g { void VoltageControl::update_state( - const Base * /*nr_system_base_ptr*/, - const LSGrid * lsgrid_ptr, - const Eigen::SparseMatrix& /*Ybus*/, - Eigen::Ref /*Sbus*/, - Eigen::Ref /*slack_weights*/ + const Base * /*nr_system_base_ptr*/, + const LSGrid * lsgrid_ptr, + const EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/ ) { data_.clear(); diff --git a/src/core/powerflow_algorithm/ScalingPolicies.hpp b/src/core/powerflow_algorithm/ScalingPolicies.hpp index c84fe14a..b1303655 100644 --- a/src/core/powerflow_algorithm/ScalingPolicies.hpp +++ b/src/core/powerflow_algorithm/ScalingPolicies.hpp @@ -38,7 +38,7 @@ class LS2G_API ScalingPolicy public: virtual ~ScalingPolicy() noexcept = default; virtual ScalingPolicyType type() const = 0; - virtual real_type scale(const NRSystem& system, const RealVect & F) = 0; + virtual real_type scale(const NRSystem& system, const Eigen::Ref & F) = 0; // call to update the policy at each iteration // nothing to do in general, is used for @@ -52,7 +52,7 @@ class LS2G_API NoScalingPolicy final : public ScalingPolicy { public: ScalingPolicyType type() const override {return ScalingPolicyType::NoScaling;} - real_type scale(const NRSystem& /*system*/, const RealVect & /*F*/) override + real_type scale(const NRSystem& /*system*/, const Eigen::Ref & /*F*/) override { return 1.; } @@ -64,7 +64,7 @@ class LS2G_API MaxVoltageChangeScalingPolicy final : public ScalingPolicy & F) override { real_type alpha = static_cast(1.0); // max_abs_dtheta / max_abs_dvm account for both the base block and any @@ -98,7 +98,7 @@ class LS2G_API LineSearchScalingPolicy final : public ScalingPolicy public: ScalingPolicyType type() const override {return ScalingPolicyType::LineSearch;} - real_type scale(const NRSystem& system, const RealVect & F) override + real_type scale(const NRSystem& system, const Eigen::Ref & F) override { // Current merit (||mismatch(x)||^2 before the step). By the time scale() // runs, F has already been overwritten in place by the linear solve @@ -153,7 +153,7 @@ class IwamotoScalingPolicy final : public ScalingPolicy public: ScalingPolicyType type() const override {return ScalingPolicyType::Iwamoto;} - real_type scale(const NRSystem& system, const RealVect & F) override + real_type scale(const NRSystem& system, const Eigen::Ref & F) override { // g0 = ||mismatch(x)||^2 (current state, BEFORE the step) -- see the // identical note in LineSearchScalingPolicy::scale: F is already dx by diff --git a/src/core/powerflow_algorithm/SlackPolicies.hpp b/src/core/powerflow_algorithm/SlackPolicies.hpp index 7f165542..efd32912 100644 --- a/src/core/powerflow_algorithm/SlackPolicies.hpp +++ b/src/core/powerflow_algorithm/SlackPolicies.hpp @@ -25,35 +25,35 @@ struct MultiSlackPolicy { template static Eigen::VectorXi get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv); + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv); - static real_type initial_slack_absorbed(const CplxVect& Sbus); + static real_type initial_slack_absorbed(const Eigen::Ref& Sbus); template static RealVect evaluate_Fx(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, - const CplxVect& Sbus, + const Eigen::Ref& V, + const Eigen::Ref& Sbus, size_t slack_bus_id, real_type slack_absorbed, - const RealVect& slack_weights, - const Eigen::VectorXi& my_pv, - Eigen::Ref pq); + const Eigen::Ref& slack_weights, + const Eigen::Ref& my_pv, + const Eigen::Ref & pq); template static void update_slack_absorbed(NRAlgoT& algo, - const RealVect& dx, + const Eigen::Ref& dx, real_type& slack_absorbed); template static void fill_jacobian_matrix(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, + const Eigen::Ref& V, size_t slack_bus_id, - const RealVect& slack_weights, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& slack_weights, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv); @@ -61,25 +61,25 @@ struct MultiSlackPolicy { template static void fill_jac_unknown_sparsity(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, + const Eigen::Ref& V, size_t slack_bus_id, - const RealVect& slack_weights, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& slack_weights, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv); template static void fill_jac_known_sparsity(NRAlgoT& algo, size_t slack_bus_id, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq); + const Eigen::Ref& pq, + const Eigen::Ref& pvpq); template static void fill_value_map_impl(NRAlgoT& algo, size_t slack_bus_id, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, bool reset_J); }; @@ -92,35 +92,35 @@ struct SingleSlackPolicy { template static Eigen::VectorXi get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv); + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv); - static real_type initial_slack_absorbed(const CplxVect& Sbus); + static real_type initial_slack_absorbed(const Eigen::Ref& Sbus); template static RealVect evaluate_Fx(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, - const CplxVect& Sbus, + const Eigen::Ref& V, + const Eigen::Ref& Sbus, size_t slack_bus_id, real_type slack_absorbed, - const RealVect& slack_weights, - const Eigen::VectorXi& my_pv, - Eigen::Ref pq); + const Eigen::Ref& slack_weights, + const Eigen::Ref& my_pv, + const Eigen::Ref & pq); template static void update_slack_absorbed(NRAlgoT& algo, - const RealVect& dx, + const Eigen::Ref& dx, real_type& slack_absorbed); template static void fill_jacobian_matrix(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, + const Eigen::Ref& V, size_t slack_bus_id, - const RealVect& slack_weights, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& slack_weights, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv); @@ -128,21 +128,21 @@ struct SingleSlackPolicy { template static void fill_jac_unknown_sparsity(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& V, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv); template static void fill_jac_known_sparsity(NRAlgoT& algo, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq); + const Eigen::Ref& pq, + const Eigen::Ref& pvpq); template static void fill_value_map_impl(NRAlgoT& algo, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, bool reset_J); }; diff --git a/src/core/powerflow_algorithm/SlackPolicies.tpp b/src/core/powerflow_algorithm/SlackPolicies.tpp index 6c28983b..f728d11e 100644 --- a/src/core/powerflow_algorithm/SlackPolicies.tpp +++ b/src/core/powerflow_algorithm/SlackPolicies.tpp @@ -12,13 +12,13 @@ template Eigen::VectorXi MultiSlackPolicy::get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv) + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) { return algo.retrieve_pv_with_slack(slack_ids, pv); } -inline real_type MultiSlackPolicy::initial_slack_absorbed(const CplxVect& Sbus) +inline real_type MultiSlackPolicy::initial_slack_absorbed(const Eigen::Ref& Sbus) { return std::real(Sbus.sum()); } @@ -26,20 +26,20 @@ inline real_type MultiSlackPolicy::initial_slack_absorbed(const CplxVect& Sbus) template RealVect MultiSlackPolicy::evaluate_Fx(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, - const CplxVect& Sbus, + const Eigen::Ref& V, + const Eigen::Ref& Sbus, size_t slack_bus_id, real_type slack_absorbed, - const RealVect& slack_weights, - const Eigen::VectorXi& my_pv, - Eigen::Ref pq) + const Eigen::Ref& slack_weights, + const Eigen::Ref& my_pv, + const Eigen::Ref & pq) { return algo._evaluate_Fx(Ybus, V, Sbus, slack_bus_id, slack_absorbed, slack_weights, my_pv, pq); } template void MultiSlackPolicy::update_slack_absorbed(NRAlgoT& algo, - const RealVect& dx, + const Eigen::Ref& dx, real_type& slack_absorbed) { slack_absorbed -= dx(algo._layout.J_slack_row()); @@ -48,11 +48,11 @@ void MultiSlackPolicy::update_slack_absorbed(NRAlgoT& algo, template void MultiSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, + const Eigen::Ref& V, size_t slack_bus_id, - const RealVect& slack_weights, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& slack_weights, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv) { @@ -79,11 +79,11 @@ void MultiSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, template void MultiSlackPolicy::fill_jac_unknown_sparsity(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, + const Eigen::Ref& V, size_t slack_bus_id, - const RealVect& slack_weights, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& slack_weights, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv) { @@ -202,8 +202,8 @@ void MultiSlackPolicy::fill_jac_unknown_sparsity(NRAlgoT& algo, template void MultiSlackPolicy::fill_value_map_impl(NRAlgoT& algo, size_t slack_bus_id, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, bool reset_J) { const int n_pvpq = static_cast(pvpq.size()); @@ -261,8 +261,8 @@ void MultiSlackPolicy::fill_value_map_impl(NRAlgoT& algo, template void MultiSlackPolicy::fill_jac_known_sparsity(NRAlgoT& algo, size_t slack_bus_id, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq) + const Eigen::Ref& pq, + const Eigen::Ref& pvpq) { const Eigen::Index n_pvpq_1 = static_cast(algo._layout.J_dQ_row()); // lag + theta_size const auto n_cols = algo.J_.cols(); @@ -286,13 +286,13 @@ void MultiSlackPolicy::fill_jac_known_sparsity(NRAlgoT& algo, template Eigen::VectorXi SingleSlackPolicy::get_my_pv(NRAlgoT& algo, - Eigen::Ref slack_ids, - Eigen::Ref pv) + const Eigen::Ref & slack_ids, + const Eigen::Ref & pv) { return IntVect(pv); } -inline real_type SingleSlackPolicy::initial_slack_absorbed(const CplxVect& Sbus) +inline real_type SingleSlackPolicy::initial_slack_absorbed(const Eigen::Ref& Sbus) { return static_cast(0.); } @@ -300,20 +300,20 @@ inline real_type SingleSlackPolicy::initial_slack_absorbed(const CplxVect& Sbus) template RealVect SingleSlackPolicy::evaluate_Fx(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, - const CplxVect& Sbus, + const Eigen::Ref& V, + const Eigen::Ref& Sbus, size_t slack_bus_id, real_type slack_absorbed, - const RealVect& slack_weights, - const Eigen::VectorXi& my_pv, - Eigen::Ref pq) + const Eigen::Ref& slack_weights, + const Eigen::Ref& my_pv, + const Eigen::Ref & pq) { return algo._evaluate_Fx(Ybus, V, Sbus, my_pv, pq); } template void SingleSlackPolicy::update_slack_absorbed(NRAlgoT& algo, - const RealVect& dx, + const Eigen::Ref& dx, real_type& slack_absorbed) { // single-slack: no distributed-slack variable in the state vector @@ -322,11 +322,11 @@ void SingleSlackPolicy::update_slack_absorbed(NRAlgoT& algo, template void SingleSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, + const Eigen::Ref& V, size_t slack_bus_id, - const RealVect& slack_weights, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& slack_weights, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv) { @@ -353,9 +353,9 @@ void SingleSlackPolicy::fill_jacobian_matrix(NRAlgoT& algo, template void SingleSlackPolicy::fill_jac_unknown_sparsity(NRAlgoT& algo, const Eigen::SparseMatrix& Ybus, - const CplxVect& V, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& V, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, const std::vector& pq_inv, const std::vector& pvpq_inv) { @@ -436,8 +436,8 @@ void SingleSlackPolicy::fill_jac_unknown_sparsity(NRAlgoT& algo, template void SingleSlackPolicy::fill_value_map_impl(NRAlgoT& algo, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq, + const Eigen::Ref& pq, + const Eigen::Ref& pvpq, bool reset_J) { const int n_pvpq = static_cast(pvpq.size()); @@ -480,8 +480,8 @@ void SingleSlackPolicy::fill_value_map_impl(NRAlgoT& algo, template void SingleSlackPolicy::fill_jac_known_sparsity(NRAlgoT& algo, - const Eigen::VectorXi& pq, - const Eigen::VectorXi& pvpq) + const Eigen::Ref& pq, + const Eigen::Ref& pvpq) { const int n_pvpq_threshold = algo._layout.J_dQ_row(); // lag + theta_size = n_pvpq for lag=0 const int n_cols = static_cast(algo.J_.cols()); From ba136eb0cef7fb59e2cbb2b6ea44965429526377 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 16 Jul 2026 17:49:35 +0200 Subject: [PATCH 133/166] fix a bug in benchmark_grid_size.py Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_grid_size.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/benchmark_grid_size.py b/benchmarks/benchmark_grid_size.py index ff95bb0c..bee1975e 100644 --- a/benchmarks/benchmark_grid_size.py +++ b/benchmarks/benchmark_grid_size.py @@ -355,7 +355,7 @@ def run_grid2op_env(env_lightsim, case, reset_solver, computer = time_serie.computer computer_ts = time_serie.computer v_init = env_lightsim.backend.V - status = computer.compute_Vs(gen_p, + status = computer.compute_Vs(gen_p_g2op, sgen_p, load_p, load_q, From 840a9f9d57e3e64d264fc15ee1236255e9e5793c Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 16 Jul 2026 17:50:54 +0200 Subject: [PATCH 134/166] fix a bug when compiling with march=native flags Signed-off-by: DONNOT Benjamin --- .github/workflows/main.yml | 50 ++++++++++ CHANGELOG.rst | 33 +++++++ docs/solver_plugin.rst | 98 +++++++++++++++++++ examples/dist_slack_algorithm/CMakeLists.txt | 6 ++ examples/external_algorithm/CMakeLists.txt | 6 ++ examples/external_algorithm/test_plugin.py | 10 ++ src/bindings/python/CMakeLists.txt | 32 +++++- .../batch_algorithm/BaseBatchSolverSynch.cpp | 8 +- .../batch_algorithm/BaseBatchSolverSynch.hpp | 6 +- .../cmake/lightsim2grid_coreConfig.cmake.in | 12 +++ 10 files changed, 251 insertions(+), 10 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 86d04a7f..4073be0e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -802,6 +802,56 @@ jobs: print('Strategy 1 OK: KLU available') " + test_march_native: + # -march=native is a supported opt-in build flag (see benchmarks/env_compile_all.sh) + # that release wheels do not use, so plain CI builds never exercise it. It changes + # which SIMD ISA Eigen sees enabled (__AVX__, __AVX2__, ...) in each translation + # unit; lightsim2grid_core and lightsim2grid_cpp (the pybind11 bindings) are two + # separate shared libraries, so both must see the identical flag -- otherwise an + # Eigen object allocated in one and freed in the other computes a different + # alignment offset, corrupting the heap ("double free or corruption") the first + # time a function's return value crosses the module boundary (e.g. any pandapower + # -> LightSimBackend conversion, or a forecast-backend copy). Regression test for + # that class of bug: env creation reliably crashed within seconds before the fix. + name: Test -march=native build (Ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements_compile_ci.txt + python -m pip install grid2op pandapower lxml + + - name: Build lightsim2grid with -march=native + run: __COMPILE_MARCHNATIVE=1 __O3_OPTIM=1 python -m pip install -v . --no-build-isolation + + - name: Create + reset a LightSimBackend env (exercises the core<->bindings Eigen boundary) + run: | + mkdir -p _march_native_check + cd _march_native_check + python -c " + import warnings + from grid2op import make + from grid2op.Parameters import Parameters + from lightsim2grid import LightSimBackend + + param = Parameters() + param.init_from_dict({'NO_OVERFLOW_DISCONNECTION': True, 'ENV_DC': True, 'FORECAST_DC': True}) + with warnings.catch_warnings(): + warnings.filterwarnings('ignore') + env = make('l2rpn_case14_sandbox', backend=LightSimBackend(), param=param, test=True) + env.reset() + print('OK: env created and reset under -march=native without crashing') + " + package: name: Package wheels runs-on: ubuntu-latest diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ef6ef2ee..ea53f976 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -691,6 +691,39 @@ TODO: speed: `BaseBatchSolverSynch::compute_amps_flows` / `compute_active_power_ need to separately pass `sort_index` back in) and raises `NotImplementedError` for a grid built with the legacy `buses_for_sub=True` mode. See the "Inspecting results in a pypowsybl-like way" section of the documentation. +- [FIXED] a build with ``__COMPILE_MARCHNATIVE=1`` (see ``benchmarks/env_compile_all.sh``) + reliably corrupted the heap and crashed (``free(): invalid size`` / ``double free or + corruption``) as soon as any C++ function's return value crossed from + ``lightsim2grid_core`` into ``lightsim2grid_cpp`` (the pybind11 bindings) -- for + example on the very first ``grid2op.make(...)`` with a pandapower-backed grid, well + before any solver is even selected. Root cause: ``lightsim2grid_core`` and + ``lightsim2grid_cpp`` are two separate shared libraries (a recent split), and + ``-march=native``/``-O3`` were only ever applied (as ``PRIVATE`` compile options) to + ``lightsim2grid_core``. ``-march=native`` changes which SIMD instruction sets Eigen + sees enabled (``__AVX__``/``__AVX2__``/...), which changes ``EIGEN_MAX_ALIGN_BYTES`` + and thus how Eigen aligns/allocates/frees its dynamic-size matrices; since both + libraries instantiate the same ``Eigen::Matrix<...>`` types (every pybind11 Eigen + return-value cast does), an object allocated under one alignment assumption and freed + under another silently computed the wrong original-allocation offset. Fixed by + mirroring ``-march=native``/``-O3`` onto ``lightsim2grid_cpp`` in + ``src/bindings/python/CMakeLists.txt``, the same way ``__SANITIZE``/``__DEBUG_ASSERTS`` + were already mirrored there. Added a regression test, + ``.github/workflows/main.yml``'s ``test_march_native`` job, since release wheels do + not use ``-march=native`` and so never exercised this. +- [FIXED] ``lightsim2grid.compilation_options.compiled_march_native`` / + ``compiled_o3_optim`` always reported ``False``, regardless of + ``__COMPILE_MARCHNATIVE``/``__O3_OPTIM``, because ``binding_module.cpp`` (compiled + into ``lightsim2grid_cpp``) checks the ``__COMPILE_MARCHNATIVE``/``__O3_OPTIM`` + *preprocessor macros*, which were never defined for that target: ``__O3_OPTIM`` was + only ``target_compile_definitions``'d as ``PRIVATE`` on ``lightsim2grid_core`` (so it + did not propagate across the library boundary, unlike ``KLU_SOLVER_AVAILABLE`` and + friends, which are ``PUBLIC``), and ``__COMPILE_MARCHNATIVE`` was never defined as a + macro anywhere at all -- only used to conditionally add the ``-march=native`` compiler + *flag* (a separate thing from the macro). This was a pre-existing, purely cosmetic gap + (the actual ``-march=native``/``-O3`` compiler flags were, and are, applied correctly); + it just happened to surface right after the fix above, when checking that the build + driving a benchmark actually had ``-march=native`` active. Fixed alongside the ABI fix + above, in the same ``src/bindings/python/CMakeLists.txt`` block. [0.13.1] 2026-04-21 -------------------- diff --git a/docs/solver_plugin.rst b/docs/solver_plugin.rst index 898b3077..7d716ce3 100644 --- a/docs/solver_plugin.rst +++ b/docs/solver_plugin.rst @@ -319,6 +319,44 @@ development without a full ``pip install``. add_library(my_solver MODULE my_solver_plugin.cpp) target_link_libraries(my_solver PRIVATE lightsim2grid::core) + # Match lightsim2grid_core's -march=native / -O3. This is required, not + # just a performance nicety: lightsim2grid_core and my_solver are two + # *separate* shared libraries, and -march=native changes which SIMD ISA + # Eigen sees enabled, which changes EIGEN_MAX_ALIGN_BYTES -- an Eigen + # object (RealVect, CplxVect, ...) allocated under one alignment + # assumption and freed under another (possible the moment such an object + # crosses the BaseAlgo interface) silently corrupts the heap. See the + # "Matching build flags" section below. + if(lightsim2grid_core_FOUND) + # Installed package: it exports the flags it was actually built + # with -- no guessing needed. + set(_ls2g_march_native "${lightsim2grid_core_MARCH_NATIVE}") + set(_ls2g_o3_optim "${lightsim2grid_core_O3_OPTIM}") + else() + # Source tree: no installed package to query -- fall back to the + # same env vars lightsim2grid itself reads. + if("$ENV{__COMPILE_MARCHNATIVE}" STREQUAL "1" OR "$ENV{__COMPILE_MARCHNATIVE}" STREQUAL "True") + set(_ls2g_march_native ON) + else() + set(_ls2g_march_native OFF) + endif() + if("$ENV{__O3_OPTIM}" STREQUAL "1" OR "$ENV{__O3_OPTIM}" STREQUAL "True") + set(_ls2g_o3_optim ON) + else() + set(_ls2g_o3_optim OFF) + endif() + endif() + if(NOT MSVC) + if(_ls2g_march_native) + message(STATUS "my_solver: lightsim2grid_core was built with -march=native -- matching it") + target_compile_options(my_solver PRIVATE -march=native) + endif() + if(_ls2g_o3_optim) + message(STATUS "my_solver: lightsim2grid_core was built with -O3 -- matching it") + target_compile_options(my_solver PRIVATE -O3) + endif() + endif() + if(WIN32) # find_package provides the import lib via the IMPORTED target. elseif(UNIX AND NOT APPLE) @@ -473,6 +511,66 @@ Python API reference import library must match the ``lightsim2grid_cpp.pyd`` that will be loaded at runtime — i.e. both must come from the same lightsim2grid build. +.. _solver_plugin_march_native: + +Matching build flags (``-march=native`` / ``-O3``) +---------------------------------------------------- + +``lightsim2grid_core`` supports two opt-in build flags, set as environment +variables *before* building lightsim2grid (see ``benchmarks/env_compile_all.sh``): + +* ``__COMPILE_MARCHNATIVE=1`` — adds ``-march=native`` +* ``__O3_OPTIM=1`` — adds ``-O3`` (on by default, actually) + +A plugin **must** be compiled with the identical ``-march=native`` setting as +the ``lightsim2grid_core`` it links against, and this is a correctness +requirement, not a performance one. ``lightsim2grid_core`` and a plugin +module are two *separate* shared libraries. ``-march=native`` changes which +SIMD instruction sets Eigen sees enabled (``__AVX__``, ``__AVX2__``, ...), +which changes ``EIGEN_MAX_ALIGN_BYTES`` and thus how Eigen aligns / allocates +/ frees its dynamic-size matrices (``RealVect``, ``CplxVect``, ...). If the +two libraries disagree, an Eigen object allocated under one alignment +assumption and freed under another silently corrupts the heap — surfacing +later, and far away, as ``free(): invalid size`` or ``double free or +corruption``. This is a real, previously-hit bug (see the CHANGELOG entry +for the ``lightsim2grid_core`` / ``lightsim2grid_cpp`` split) — not a +hypothetical one. (``-O3`` alone does not affect ABI/alignment, so a mismatch +there is only a lost optimization, not a correctness risk — but the two +builds should still track each other to avoid surprising perf differences.) + +Both example plugins in this repository (``examples/dist_slack_algorithm/``, +``examples/external_algorithm/``) handle this automatically, and log an +``-- my_solver: lightsim2grid_core was built with -march=native -- matching +it`` status message when they do: + +* When linking against an **installed** ``lightsim2grid_core`` (``pip + install lightsim2grid``, ``find_package(lightsim2grid_core CONFIG)``), the + installed CMake package exports ``lightsim2grid_core_MARCH_NATIVE`` / + ``lightsim2grid_core_O3_OPTIM`` — the flags it was *actually* built with — + so the plugin build queries them directly, no guessing or separate Python + call needed. +* When falling back to a **source-tree** build (no installed package), there + is nothing to query, so the plugin build instead reads the same + ``__COMPILE_MARCHNATIVE`` / ``__O3_OPTIM`` env vars lightsim2grid itself + reads — set them identically for both builds. + +The shared logic lives in +``examples/cmake/MatchLightsim2gridBuildFlags.cmake`` as the +``ls2g_match_core_build_flags()`` function; ``include()`` it and call +it on your plugin's target right after ``target_link_libraries( +PRIVATE lightsim2grid::core)`` if you are building inside this repository. +The "Writing a solver plugin" CMakeLists.txt template above inlines the same +logic for third-party projects that do not have access to that file. + +You can also check what a given lightsim2grid install was built with +directly from Python:: + + >>> import lightsim2grid + >>> lightsim2grid.compilation_options.compiled_march_native + True + >>> lightsim2grid.compilation_options.compiled_o3_optim + True + Worked example (``examples/external_algorithm/``) ------------------------------------------------- diff --git a/examples/dist_slack_algorithm/CMakeLists.txt b/examples/dist_slack_algorithm/CMakeLists.txt index b76864c2..e43d5d36 100644 --- a/examples/dist_slack_algorithm/CMakeLists.txt +++ b/examples/dist_slack_algorithm/CMakeLists.txt @@ -44,6 +44,12 @@ endif() add_library(dist_slack_algorithm MODULE NRAlgoDistSlack_plugin.cpp) target_link_libraries(dist_slack_algorithm PRIVATE lightsim2grid::core) +# Match lightsim2grid_core's -march=native / -O3 -- see docs/solver_plugin.rst +# and examples/cmake/MatchLightsim2gridBuildFlags.cmake for why this matters +# (skipping it is a real, silent heap-corruption risk, not just a perf loss). +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/MatchLightsim2gridBuildFlags.cmake") +ls2g_match_core_build_flags(dist_slack_algorithm) + # ----------------------------------------------------------------------- # KLUSolver.hpp (pulled in transitively via Solvers.hpp) needs SuiteSparse's # own headers (cs.h, klu.h, amd.h, colamd.h, btf.h, SuiteSparse_config.h) to diff --git a/examples/external_algorithm/CMakeLists.txt b/examples/external_algorithm/CMakeLists.txt index 1479b7be..bcee945f 100644 --- a/examples/external_algorithm/CMakeLists.txt +++ b/examples/external_algorithm/CMakeLists.txt @@ -44,6 +44,12 @@ endif() add_library(dummy_solver MODULE DummyExternalAlgo.cpp) target_link_libraries(dummy_solver PRIVATE lightsim2grid::core) +# Match lightsim2grid_core's -march=native / -O3 -- see docs/solver_plugin.rst +# and examples/cmake/MatchLightsim2gridBuildFlags.cmake for why this matters +# (skipping it is a real, silent heap-corruption risk, not just a perf loss). +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/MatchLightsim2gridBuildFlags.cmake") +ls2g_match_core_build_flags(dummy_solver) + if(WIN32) # find_package provides the import lib via the IMPORTED target — nothing extra needed. elseif(UNIX AND NOT APPLE) diff --git a/examples/external_algorithm/test_plugin.py b/examples/external_algorithm/test_plugin.py index 78f9bd01..6b02e399 100644 --- a/examples/external_algorithm/test_plugin.py +++ b/examples/external_algorithm/test_plugin.py @@ -7,6 +7,16 @@ cmake .. make (or: cmake --build . --config Release on Windows) +The plugin's CMakeLists.txt automatically matches whatever -march=native / +-O3 the installed lightsim2grid_core was built with (see +examples/cmake/MatchLightsim2gridBuildFlags.cmake and +docs/solver_plugin.rst) -- this is required, not just a performance nicety: +lightsim2grid_core and this plugin are separate shared libraries, and a +mismatched -march=native changes Eigen's alignment assumptions, so an Eigen +object allocated in one and freed in the other silently corrupts the heap. +Nothing to do here unless you're hand-rolling your own build system instead +of the provided CMakeLists.txt. + Then run: python test_plugin.py """ diff --git a/src/bindings/python/CMakeLists.txt b/src/bindings/python/CMakeLists.txt index ed99a809..414be68b 100644 --- a/src/bindings/python/CMakeLists.txt +++ b/src/bindings/python/CMakeLists.txt @@ -55,11 +55,37 @@ target_include_directories(lightsim2grid_cpp PRIVATE target_link_libraries(lightsim2grid_cpp PRIVATE lightsim2grid_core) -# CI hardening builds (see .github/workflows/sanitizers.yml). The env vars are -# read again here (rather than relying on the USE_SANITIZERS/USE_DEBUG_ASSERTS -# options defined in src/core/CMakeLists.txt) so the flags also apply when +# The env vars are read again here (rather than relying on the +# USE_MARCH_NATIVE/USE_O3_OPTIM/USE_SANITIZERS/USE_DEBUG_ASSERTS options +# defined in src/core/CMakeLists.txt) so the flags also apply when # lightsim2grid_core is imported pre-built and src/core is never processed. if(NOT MSVC) + # -march=native changes which SIMD instruction sets Eigen sees enabled + # (__AVX__, __AVX2__, ...), which changes EIGEN_MAX_ALIGN_BYTES and thus + # how Eigen aligns/allocates/frees its dynamic-size matrices. lightsim2grid_cpp + # instantiates the same Eigen::Matrix<...> types as lightsim2grid_core (e.g. + # via pybind11's Eigen return-value caster) whenever a function's return + # value crosses the module boundary, so if the two are compiled with + # different -march flags, an Eigen object allocated in one and freed in the + # other computes a different alignment offset -- heap corruption + # ("free(): invalid size" / "double free or corruption") at the first such + # crossing. Must stay identical to src/core/CMakeLists.txt's USE_MARCH_NATIVE. + # target_compile_definitions (not just _options) below: binding_module.cpp's + # compiled_march_native/compiled_o3_optim (reported via lightsim2grid. + # compilation_options) check the __COMPILE_MARCHNATIVE/__O3_OPTIM *macros*, + # separately from whether the compiler flags are actually active. + set(ENV_COMPILE_MARCHNATIVE "$ENV{__COMPILE_MARCHNATIVE}") + if(ENV_COMPILE_MARCHNATIVE STREQUAL "1" OR ENV_COMPILE_MARCHNATIVE STREQUAL "True") + target_compile_options(lightsim2grid_cpp PRIVATE -march=native) + target_compile_definitions(lightsim2grid_cpp PRIVATE __COMPILE_MARCHNATIVE) + endif() + + set(ENV_O3_OPTIM "$ENV{__O3_OPTIM}") + if(ENV_O3_OPTIM STREQUAL "1" OR ENV_O3_OPTIM STREQUAL "True") + target_compile_options(lightsim2grid_cpp PRIVATE -O3) + target_compile_definitions(lightsim2grid_cpp PRIVATE __O3_OPTIM) + endif() + set(ENV_SANITIZE "$ENV{__SANITIZE}") if(ENV_SANITIZE STREQUAL "1" OR ENV_SANITIZE STREQUAL "True") target_compile_options(lightsim2grid_cpp PRIVATE diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp index 0587411d..ddb83cbb 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.cpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.cpp @@ -15,7 +15,7 @@ namespace ls2g { Member version: single-threaded path, uses the member solver / control / accumulators. **/ bool BaseBatchSolverSynch::compute_one_powerflow( - const Eigen::SparseMatrix & Ybus, + const EigenRefConstCplxSpMat & Ybus, CplxVect & V, const Eigen::Ref & Sbus, const Eigen::Ref & slack_ids, @@ -41,8 +41,8 @@ bool BaseBatchSolverSynch::compute_one_powerflow( AlgoControl & control, int & nb_solved, double & timer_solver, - const Eigen::SparseMatrix & Ybus, - CplxVect & V, + const EigenRefConstCplxSpMat & Ybus, + CplxVect & V, const Eigen::Ref & Sbus, const Eigen::Ref & slack_ids, const Eigen::Ref & slack_weights, @@ -90,7 +90,7 @@ bool BaseBatchSolverSynch::compute_one_powerflow( bool BaseBatchSolverSynch::warmup_solver( AlgorithmSelector & algo, AlgoControl & control, - CplxVect Vinit_solver, + const Eigen::Ref & Vinit_solver, int max_iter, real_type tol) { diff --git a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp index aebca7f0..c751a22e 100644 --- a/src/core/batch_algorithm/BaseBatchSolverSynch.hpp +++ b/src/core/batch_algorithm/BaseBatchSolverSynch.hpp @@ -299,7 +299,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // algo.compute_pf(Ybus, V, ...) (AC) and algo.compute_pf_dc(Bbus, V, ...) (DC) // depending on ac_solver_used(), and the DC path resizes/reassigns V -- so it // can't become Eigen::Ref even though the AC path alone would allow it. - bool compute_one_powerflow(const Eigen::SparseMatrix & Ybus, + bool compute_one_powerflow(const EigenRefConstCplxSpMat & Ybus, CplxVect & V, const Eigen::Ref & Sbus, const Eigen::Ref & slack_ids, @@ -319,7 +319,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants AlgoControl & control, int & nb_solved, double & timer_solver, - const Eigen::SparseMatrix & Ybus, + const EigenRefConstCplxSpMat & Ybus, CplxVect & V, const Eigen::Ref & Sbus, const Eigen::Ref & slack_ids, @@ -338,7 +338,7 @@ class LS2G_API BaseBatchSolverSynch : protected BaseConstants // subsequent per-contingency solves reuse the factorization. bool warmup_solver(AlgorithmSelector & algo, AlgoControl & control, - CplxVect Vinit_solver, + const Eigen::Ref & Vinit_solver, int max_iter, real_type tol); diff --git a/src/core/cmake/lightsim2grid_coreConfig.cmake.in b/src/core/cmake/lightsim2grid_coreConfig.cmake.in index 7bd29011..328426c0 100644 --- a/src/core/cmake/lightsim2grid_coreConfig.cmake.in +++ b/src/core/cmake/lightsim2grid_coreConfig.cmake.in @@ -26,4 +26,16 @@ endif() set(lightsim2grid_core_LIBRARIES lightsim2grid::core) set(lightsim2grid_core_INCLUDE_DIRS "${lightsim2grid_core_INCLUDE_DIR}") +# Whether *this* lightsim2grid_core was built with -march=native / -O3 +# (src/core/CMakeLists.txt's USE_MARCH_NATIVE / USE_O3_OPTIM, driven by the +# __COMPILE_MARCHNATIVE / __O3_OPTIM env vars). A consumer linking against +# lightsim2grid::core (a separate shared library) MUST use the identical +# -march flag: -march=native changes which SIMD ISA Eigen sees enabled, +# which changes EIGEN_MAX_ALIGN_BYTES and thus how Eigen aligns/allocates/ +# frees its dynamic-size matrices. An Eigen object allocated under one +# alignment assumption and freed under another (e.g. crossing the BaseAlgo +# virtual interface) silently corrupts the heap. See docs/solver_plugin.rst. +set(lightsim2grid_core_MARCH_NATIVE @USE_MARCH_NATIVE@) +set(lightsim2grid_core_O3_OPTIM @USE_O3_OPTIM@) + check_required_components(lightsim2grid_core) From e7c85b5f33bc2d2db5e8c5ff145bdf07398f0dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:38:24 +0000 Subject: [PATCH 135/166] Add missing examples/cmake/MatchLightsim2gridBuildFlags.cmake The march=native fix referenced this file's ls2g_match_core_build_flags() function from both example plugin CMakeLists.txt but never committed it, so configuring either example failed with an include() error. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- .../cmake/MatchLightsim2gridBuildFlags.cmake | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/cmake/MatchLightsim2gridBuildFlags.cmake diff --git a/examples/cmake/MatchLightsim2gridBuildFlags.cmake b/examples/cmake/MatchLightsim2gridBuildFlags.cmake new file mode 100644 index 00000000..4da2eed8 --- /dev/null +++ b/examples/cmake/MatchLightsim2gridBuildFlags.cmake @@ -0,0 +1,45 @@ +# Match a solver plugin target's -march=native / -O3 flags to whatever +# lightsim2grid_core was actually built with. +# +# This is a correctness requirement, not a performance nicety: +# lightsim2grid_core and a plugin module are two *separate* shared +# libraries. -march=native changes which SIMD ISA Eigen sees enabled +# (__AVX__, __AVX2__, ...), which changes EIGEN_MAX_ALIGN_BYTES and thus +# how Eigen aligns/allocates/frees its dynamic-size matrices (RealVect, +# CplxVect, ...). If the two libraries disagree, an Eigen object allocated +# under one alignment assumption and freed under another (possible the +# moment such an object crosses the BaseAlgo interface) silently corrupts +# the heap. See docs/solver_plugin.rst, section +# "Matching build flags (-march=native / -O3)". +function(ls2g_match_core_build_flags target) + if(lightsim2grid_core_FOUND) + # Installed package: it exports the flags it was actually built + # with -- no guessing needed. + set(_ls2g_march_native "${lightsim2grid_core_MARCH_NATIVE}") + set(_ls2g_o3_optim "${lightsim2grid_core_O3_OPTIM}") + else() + # Source tree: no installed package to query -- fall back to the + # same env vars lightsim2grid itself reads. + if("$ENV{__COMPILE_MARCHNATIVE}" STREQUAL "1" OR "$ENV{__COMPILE_MARCHNATIVE}" STREQUAL "True") + set(_ls2g_march_native ON) + else() + set(_ls2g_march_native OFF) + endif() + if("$ENV{__O3_OPTIM}" STREQUAL "1" OR "$ENV{__O3_OPTIM}" STREQUAL "True") + set(_ls2g_o3_optim ON) + else() + set(_ls2g_o3_optim OFF) + endif() + endif() + + if(NOT MSVC) + if(_ls2g_march_native) + message(STATUS "${target}: lightsim2grid_core was built with -march=native -- matching it") + target_compile_options(${target} PRIVATE -march=native) + endif() + if(_ls2g_o3_optim) + message(STATUS "${target}: lightsim2grid_core was built with -O3 -- matching it") + target_compile_options(${target} PRIVATE -O3) + endif() + endif() +endfunction() From 07b682a9ff1e8a2188e22013102b9eb915d092ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:12:31 +0000 Subject: [PATCH 136/166] Detect core/plugin and core/bindings Eigen ABI drift at load time lightsim2grid_core, lightsim2grid_cpp, and any third-party solver plugin are separately-compiled shared libraries. If they disagree on -march=native (or any -m flag), Eigen's EIGEN_MAX_ALIGN_BYTES differs between them, and an Eigen object allocated under one alignment assumption and freed under another silently corrupts the heap -- the exact bug fixed earlier on this branch for the core/bindings pair. A static_assert can't catch this: no single translation unit has visibility into another, separately-compiled one's flags. Ls2gAbiTag.hpp adds a small "ABI tag" (EIGEN_MAX_ALIGN_BYTES, the resolved EIGEN_VECTORIZE_* flags, and the Eigen version) computed independently in whichever TU calls it. Comparing two independently-evaluated tags is the only way to observe this kind of drift from outside a single compilation. Two checkpoints now use it: - AlgorithmRegistry::register_solver, for third-party plugins loaded via load_algorithm_plugin()/AlgorithmRegistrar -- the plugin's own tag is captured automatically via a defaulted parameter evaluated at the plugin's call site, no change needed in any plugin's source. - lightsim2grid_cpp's PYBIND11_MODULE init, comparing itself against lightsim2grid_core -- this is the checkpoint that would have caught the original bug, surfacing as a clean Python ImportError instead of a crash later on. Verified by building lightsim2grid_core once with __COMPILE_MARCHNATIVE=1 and linking a second, plain-compiled TU against it: the tag mismatch is detected exactly as expected (align=16 bytes vs align=64 bytes), and a matching build produces no false positive. Full pip install + import also verified with matching flags on both sides. Added src/tests/test_ls2g_abi_tag.cpp (Catch2); full suite (1200 assertions, 49 cases) still passes. Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 13 +++ docs/solver_plugin.rst | 12 +++ src/bindings/python/binding_module.cpp | 27 ++++++ src/core/AlgorithmRegistry.cpp | 12 ++- src/core/AlgorithmRegistry.hpp | 10 +- src/core/CMakeLists.txt | 1 + src/core/Ls2gAbiTag.cpp | 15 +++ src/core/Ls2gAbiTag.hpp | 127 +++++++++++++++++++++++++ src/tests/CMakeLists.txt | 1 + src/tests/test_ls2g_abi_tag.cpp | 72 ++++++++++++++ 10 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 src/core/Ls2gAbiTag.cpp create mode 100644 src/core/Ls2gAbiTag.hpp create mode 100644 src/tests/test_ls2g_abi_tag.cpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ea53f976..48a37dab 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -724,6 +724,19 @@ TODO: speed: `BaseBatchSolverSynch::compute_amps_flows` / `compute_active_power_ it just happened to surface right after the fix above, when checking that the build driving a benchmark actually had ``-march=native`` active. Fixed alongside the ABI fix above, in the same ``src/bindings/python/CMakeLists.txt`` block. +- [ADDED] a runtime guard, on top of the CMake-level ``-march=native``/``-O3`` matching + above, against the exact class of bug those two ``[FIXED]`` entries describe. + ``src/core/Ls2gAbiTag.hpp`` defines an "ABI tag" (``EIGEN_MAX_ALIGN_BYTES``, the + resolved ``EIGEN_VECTORIZE_*`` flags, and the Eigen version) computed independently in + whichever translation unit calls it; comparing two independently-evaluated tags is the + only way to observe this kind of drift between separately-compiled binaries (a + ``static_assert`` cannot, since no single translation unit has visibility into another, + separately-compiled one's flags). Two checkpoints now compare tags and refuse to + proceed with a clear error instead of silently corrupting the heap: (1) + ``AlgorithmRegistry::register_solver`` for third-party solver plugins loaded via + ``load_algorithm_plugin``/``AlgorithmRegistrar``, and (2) ``lightsim2grid_cpp``'s + module init, comparing itself against ``lightsim2grid_core``, catching this bug's own + original failure mode directly at import time. [0.13.1] 2026-04-21 -------------------- diff --git a/docs/solver_plugin.rst b/docs/solver_plugin.rst index 7d716ce3..76530cb2 100644 --- a/docs/solver_plugin.rst +++ b/docs/solver_plugin.rst @@ -571,6 +571,18 @@ directly from Python:: >>> lightsim2grid.compilation_options.compiled_o3_optim True +As defense-in-depth on top of the CMake-level matching above, this is also +checked **at runtime**: every plugin solver registration compares an "ABI +tag" (``EIGEN_MAX_ALIGN_BYTES``, the resolved ``EIGEN_VECTORIZE_*`` flags, +and the Eigen version) computed in the plugin's own translation unit against +the one ``lightsim2grid_core`` was actually compiled with, and rejects the +registration with a clear error if they differ — instead of registering it +and letting the heap corrupt later. The same comparison runs once between +``lightsim2grid_core`` and ``lightsim2grid_cpp`` themselves at import time. +This does not replace matching the build flags (a rejected plugin is still a +plugin you can't use), but it turns a silent, far-away heap corruption into +an immediate, actionable error at load time. See ``src/core/Ls2gAbiTag.hpp``. + Worked example (``examples/external_algorithm/``) ------------------------------------------------- diff --git a/src/bindings/python/binding_module.cpp b/src/bindings/python/binding_module.cpp index d8a07401..5e2fe7bd 100644 --- a/src/bindings/python/binding_module.cpp +++ b/src/bindings/python/binding_module.cpp @@ -7,6 +7,9 @@ // This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. #include "binding_declarations.hpp" +#include "Ls2gAbiTag.hpp" + +#include #ifndef KLU_SOLVER_AVAILABLE #define this_KLU_SOLVER_AVAILABLE 0 @@ -47,6 +50,30 @@ PYBIND11_MODULE(lightsim2grid_cpp, m) { + // Guard against the exact bug that motivated this check: lightsim2grid_core + // and lightsim2grid_cpp are two separate shared libraries, and if they were + // compiled with different -march (or otherwise disagree on Eigen's SIMD/ + // alignment settings), an Eigen object allocated in one and freed in the + // other silently corrupts the heap (see docs/solver_plugin.rst). Comparing + // the two ABI tags here -- bindings' own view vs. core's compiled-in view, + // queried via a real cross-.so function call -- catches the mismatch at + // import time instead. PYBIND11_MODULE wraps this function body in a + // try/catch, so throwing here surfaces as a clean Python ImportError. + { + const ls2g::Ls2gAbiTag bindings_tag = ls2g::ls2g_current_abi_tag(); + const ls2g::Ls2gAbiTag core_tag = ls2g::core_abi_tag(); + if (bindings_tag != core_tag) { + throw std::runtime_error( + "lightsim2grid: lightsim2grid_cpp (the Python bindings) and lightsim2grid_core were " + "compiled with different Eigen SIMD/alignment settings -- loading them together would " + "silently corrupt the heap the first time an Eigen object crosses the module boundary. " + "lightsim2grid_core was built with [" + core_tag.describe() + "], lightsim2grid_cpp was " + "built with [" + bindings_tag.describe() + "]. This should not happen with the provided " + "build system; if you customized the build, make sure both are compiled with identical " + "-march flags -- see docs/solver_plugin.rst, section \"Matching build flags\"."); + } + } + // constant and compilation information m.attr("klu_solver_available") = py::bool_(this_KLU_SOLVER_AVAILABLE); m.attr("nicslu_solver_available") = py::bool_(this_NICSLU_SOLVER_AVAILABLE); diff --git a/src/core/AlgorithmRegistry.cpp b/src/core/AlgorithmRegistry.cpp index 39782721..211bbe27 100644 --- a/src/core/AlgorithmRegistry.cpp +++ b/src/core/AlgorithmRegistry.cpp @@ -15,7 +15,17 @@ AlgorithmRegistry& AlgorithmRegistry::instance() { return reg; } -void AlgorithmRegistry::register_solver(const std::string& name, Factory f) { +void AlgorithmRegistry::register_solver(const std::string& name, Factory f, Ls2gAbiTag caller_tag) { + const Ls2gAbiTag& this_core_tag = core_abi_tag(); + if (caller_tag != this_core_tag) { + throw std::runtime_error( + "AlgorithmRegistry: refusing to register solver '" + name + "': it was compiled with " + "different Eigen SIMD/alignment settings than lightsim2grid_core. Loading it would silently " + "corrupt the heap the first time an Eigen object crosses the BaseAlgo interface. " + "lightsim2grid_core was built with [" + this_core_tag.describe() + "], this plugin was built " + "with [" + caller_tag.describe() + "]. Recompile the plugin with matching compiler/-march " + "flags -- see docs/solver_plugin.rst, section \"Matching build flags\"."); + } if (_factories.count(name)) { throw std::invalid_argument("AlgorithmRegistry: a solver named '" + name + "' is already registered."); } diff --git a/src/core/AlgorithmRegistry.hpp b/src/core/AlgorithmRegistry.hpp index 371ac76a..b814f29d 100644 --- a/src/core/AlgorithmRegistry.hpp +++ b/src/core/AlgorithmRegistry.hpp @@ -17,6 +17,7 @@ #include #include "powerflow_algorithm/BaseAlgo.hpp" +#include "Ls2gAbiTag.hpp" namespace ls2g { @@ -26,7 +27,14 @@ class LS2G_API AlgorithmRegistry { static AlgorithmRegistry& instance(); - void register_solver(const std::string& name, Factory f); + // `caller_tag` defaults to `ls2g_current_abi_tag()` evaluated at the call + // site: since AlgorithmRegistrar's constructor below is defined inline, + // that default is compiled fresh into whichever plugin instantiates it, + // so it automatically captures the plugin's own Eigen SIMD/alignment + // settings with no change needed on the plugin author's side. Throws if + // it disagrees with lightsim2grid_core's own tag -- see Ls2gAbiTag.hpp. + void register_solver(const std::string& name, Factory f, + Ls2gAbiTag caller_tag = ls2g_current_abi_tag()); std::unique_ptr make(const std::string& name) const; bool is_registered(const std::string& name) const; std::vector available_algorithm_names() const; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index e566f6a9..b13129f4 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -288,6 +288,7 @@ add_library(lightsim2grid_core SHARED "${CMAKE_CURRENT_SOURCE_DIR}/LSGrid.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/AlgorithmSelector.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/AlgorithmRegistry.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Ls2gAbiTag.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/AlgorithmTypeNames.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/BuiltinSolversRegistration.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/SolverInstantiations.cpp" diff --git a/src/core/Ls2gAbiTag.cpp b/src/core/Ls2gAbiTag.cpp new file mode 100644 index 00000000..1f9cea06 --- /dev/null +++ b/src/core/Ls2gAbiTag.cpp @@ -0,0 +1,15 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#include "Ls2gAbiTag.hpp" + +namespace ls2g { + +Ls2gAbiTag core_abi_tag() { return ls2g_current_abi_tag(); } + +} // namespace ls2g diff --git a/src/core/Ls2gAbiTag.hpp b/src/core/Ls2gAbiTag.hpp new file mode 100644 index 00000000..b1360967 --- /dev/null +++ b/src/core/Ls2gAbiTag.hpp @@ -0,0 +1,127 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef LS2G_ABI_TAG_H +#define LS2G_ABI_TAG_H + +#include +#include + +#include "ls2g_api.hpp" +#include "Eigen/Core" + +namespace ls2g { + +// Fingerprint of the Eigen SIMD/alignment configuration a given translation +// unit was compiled with. -march=native (or any -m flag) changes which +// of EIGEN_VECTORIZE_* get defined, which changes EIGEN_MAX_ALIGN_BYTES, +// which changes how Eigen aligns/allocates/frees its dynamic-size matrices. +// If two separately-compiled binaries that share Eigen objects (core vs. the +// Python bindings, or core vs. a solver plugin) disagree on this, an object +// allocated under one alignment assumption and freed under another silently +// corrupts the heap -- this is a real, previously-hit bug, not a +// hypothetical one (see CHANGELOG.rst / docs/solver_plugin.rst). +// +// `ls2g_current_abi_tag()` below is `inline` on purpose: it must be +// recompiled in each translation unit that calls it (core, bindings, a +// plugin) so that it reflects *that* TU's own compiler flags, not a value +// baked once somewhere else. Comparing two independently-evaluated tags is +// the only way to observe this kind of drift from outside a single +// compilation -- a static_assert cannot do it, because no single TU has +// visibility into what flags another, separately-compiled TU used. +struct Ls2gAbiTag { + // Bump only if this struct's meaning changes (e.g. a field is added or + // reinterpreted), not for ordinary Eigen/compiler upgrades. + unsigned tag_version = 1; + + unsigned eigen_max_align_bytes = EIGEN_MAX_ALIGN_BYTES; + unsigned eigen_world_version = EIGEN_WORLD_VERSION; + unsigned eigen_major_version = EIGEN_MAJOR_VERSION; + unsigned eigen_minor_version = EIGEN_MINOR_VERSION; + +#ifdef EIGEN_VECTORIZE_SSE + bool vectorize_sse = true; +#else + bool vectorize_sse = false; +#endif +#ifdef EIGEN_VECTORIZE_AVX + bool vectorize_avx = true; +#else + bool vectorize_avx = false; +#endif +#ifdef EIGEN_VECTORIZE_AVX2 + bool vectorize_avx2 = true; +#else + bool vectorize_avx2 = false; +#endif +#ifdef EIGEN_VECTORIZE_AVX512 + bool vectorize_avx512 = true; +#else + bool vectorize_avx512 = false; +#endif +#ifdef EIGEN_VECTORIZE_FMA + bool vectorize_fma = true; +#else + bool vectorize_fma = false; +#endif +#ifdef EIGEN_VECTORIZE_NEON + bool vectorize_neon = true; +#else + bool vectorize_neon = false; +#endif + + bool operator==(const Ls2gAbiTag& o) const { + return tag_version == o.tag_version + && eigen_max_align_bytes == o.eigen_max_align_bytes + && eigen_world_version == o.eigen_world_version + && eigen_major_version == o.eigen_major_version + && eigen_minor_version == o.eigen_minor_version + && vectorize_sse == o.vectorize_sse + && vectorize_avx == o.vectorize_avx + && vectorize_avx2 == o.vectorize_avx2 + && vectorize_avx512 == o.vectorize_avx512 + && vectorize_fma == o.vectorize_fma + && vectorize_neon == o.vectorize_neon; + } + bool operator!=(const Ls2gAbiTag& o) const { return !(*this == o); } + + // Human-readable summary for error messages, e.g. "align=32 bytes, + // Eigen 5.0.1, SSE+AVX+AVX2+FMA". + std::string describe() const { + std::ostringstream oss; + oss << "align=" << eigen_max_align_bytes << " bytes, Eigen " + << eigen_world_version << "." << eigen_major_version << "." << eigen_minor_version << ", "; + bool first = true; + auto add = [&](bool present, const char* name) { + if (!present) return; + if (!first) oss << "+"; + oss << name; + first = false; + }; + add(vectorize_sse, "SSE"); + add(vectorize_avx, "AVX"); + add(vectorize_avx2, "AVX2"); + add(vectorize_avx512, "AVX512"); + add(vectorize_fma, "FMA"); + add(vectorize_neon, "NEON"); + if (first) oss << "no vectorization"; + return oss.str(); + } +}; + +// Evaluated in the caller's own translation unit -- see the class comment. +inline Ls2gAbiTag ls2g_current_abi_tag() { return Ls2gAbiTag{}; } + +// Non-inline: always executes as compiled into lightsim2grid_core, so it +// reports core's real build flags regardless of who calls it (bindings, a +// plugin, ...). Defined in Ls2gAbiTag.cpp. +LS2G_API Ls2gAbiTag core_abi_tag(); + +} // namespace ls2g + +#endif // LS2G_ABI_TAG_H diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9156934f..604a0e20 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -55,6 +55,7 @@ add_executable(lightsim2grid_unit_tests test_binary_archive_corruption.cpp test_lsgrid.cpp test_case_exotic_elements.cpp + test_ls2g_abi_tag.cpp ) target_link_libraries(lightsim2grid_unit_tests PRIVATE lightsim2grid_core Catch2::Catch2WithMain) diff --git a/src/tests/test_ls2g_abi_tag.cpp b/src/tests/test_ls2g_abi_tag.cpp new file mode 100644 index 00000000..d69d8f7a --- /dev/null +++ b/src/tests/test_ls2g_abi_tag.cpp @@ -0,0 +1,72 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Ls2gAbiTag is the mechanism that catches, at plugin-registration / bindings +// import time, the exact bug this repository hit once already: core and a +// separately-compiled consumer (a solver plugin, or lightsim2grid_cpp itself) +// disagreeing on -march=native (and therefore on Eigen's alignment), which +// otherwise silently corrupts the heap the first time an Eigen object crosses +// the boundary. These tests can't reproduce a real cross-flag build inside a +// single binary, so they exercise the comparison/rejection logic directly by +// constructing a deliberately-mismatched tag. + +#include + +#include "AlgorithmRegistry.hpp" +#include "Ls2gAbiTag.hpp" + +TEST_CASE("Ls2gAbiTag equality reflects every field", "[ls2g_abi_tag]") +{ + ls2g::Ls2gAbiTag a = ls2g::ls2g_current_abi_tag(); + ls2g::Ls2gAbiTag b = a; + REQUIRE(a == b); + + b = a; + b.eigen_max_align_bytes += 16; + REQUIRE(a != b); + + b = a; + b.vectorize_avx2 = !b.vectorize_avx2; + REQUIRE(a != b); + + b = a; + b.tag_version += 1; + REQUIRE(a != b); +} + +TEST_CASE("register_solver rejects a solver registered with a mismatched ABI tag", "[ls2g_abi_tag]") +{ + ls2g::Ls2gAbiTag mismatched = ls2g::ls2g_current_abi_tag(); + mismatched.eigen_max_align_bytes += 16; // guaranteed to differ from core's own tag + + // The factory is never invoked -- the check must reject the registration + // before any solver object (and any Eigen member it holds) is ever built. + REQUIRE_THROWS_AS( + ls2g::AlgorithmRegistry::instance().register_solver( + "__test_mismatched_abi_tag_plugin__", + []() -> std::unique_ptr { return nullptr; }, + mismatched), + std::runtime_error); + + REQUIRE_FALSE(ls2g::AlgorithmRegistry::instance().is_registered("__test_mismatched_abi_tag_plugin__")); +} + +TEST_CASE("register_solver accepts a solver registered with a matching ABI tag", "[ls2g_abi_tag]") +{ + bool called = false; + ls2g::AlgorithmRegistry::instance().register_solver( + "__test_matching_abi_tag_plugin__", + [&called]() -> std::unique_ptr { called = true; return std::make_unique(); }); + // caller_tag defaults to ls2g_current_abi_tag(), i.e. this TU's own -- + // which is core's own TU here, so it always matches core_abi_tag(). + + REQUIRE(ls2g::AlgorithmRegistry::instance().is_registered("__test_matching_abi_tag_plugin__")); + auto algo = ls2g::AlgorithmRegistry::instance().make("__test_matching_abi_tag_plugin__"); + REQUIRE(called); + REQUIRE(algo != nullptr); +} From e5d4b01eedec546343cdee2cef38b32dc428b75a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 06:18:47 +0000 Subject: [PATCH 137/166] Ls2gAbiTag: compare full Eigen version, document the runtime ABI check Addresses PR #151 review comments: Eigen 5.0.0 moved to real semver (major.minor.patch), and the tag was missing EIGEN_PATCH_VERSION. Added it (guarded, since pre-5.0 Eigen never defined that macro) and compare it in operator==, alongside the existing world/major/minor fields. Kept the comparison exact rather than major-only: Eigen is header-only, so its semver promise is about API compatibility for code using Eigen, not about the internal aligned-malloc offset arithmetic this tag exists to catch drift in -- that isn't a thing Eigen tests or promises across minor/ patch releases, since there is no shared-object ABI to keep stable in the first place. In normal use core/bindings/plugins all build against the one Eigen commit vendored in this repo's eigen/ submodule, so this only fires for a plugin deliberately pointed at a different Eigen copy. Also fixes describe()'s version display, which was printing world.major.minor (e.g. "3.5.0") instead of Eigen's own major.minor.patch convention (e.g. "5.0.1", matching EIGEN_VERSION_STRING). Expanded docs/solver_plugin.rst's "Matching build flags" section with what the runtime check compares, and recommends compiling a plugin against the Eigen headers lightsim2grid itself provides (bundled next to an installed lightsim2grid_core, or the eigen/ submodule for a source-tree build) rather than a separately-installed system Eigen, so the version check is a non-issue in the common case. Verified: rebuilt and re-ran the full Catch2 suite (1201 assertions, 49 cases, including the new eigen_patch_version case in test_ls2g_abi_tag.cpp). Signed-off-by: Claude Signed-off-by: DONNOT Benjamin --- docs/solver_plugin.rst | 52 ++++++++++++++++++++++++++------- src/core/Ls2gAbiTag.hpp | 28 ++++++++++++++++-- src/tests/test_ls2g_abi_tag.cpp | 7 +++++ 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/docs/solver_plugin.rst b/docs/solver_plugin.rst index 76530cb2..edba0dea 100644 --- a/docs/solver_plugin.rst +++ b/docs/solver_plugin.rst @@ -572,16 +572,48 @@ directly from Python:: True As defense-in-depth on top of the CMake-level matching above, this is also -checked **at runtime**: every plugin solver registration compares an "ABI -tag" (``EIGEN_MAX_ALIGN_BYTES``, the resolved ``EIGEN_VECTORIZE_*`` flags, -and the Eigen version) computed in the plugin's own translation unit against -the one ``lightsim2grid_core`` was actually compiled with, and rejects the -registration with a clear error if they differ — instead of registering it -and letting the heap corrupt later. The same comparison runs once between -``lightsim2grid_core`` and ``lightsim2grid_cpp`` themselves at import time. -This does not replace matching the build flags (a rejected plugin is still a -plugin you can't use), but it turns a silent, far-away heap corruption into -an immediate, actionable error at load time. See ``src/core/Ls2gAbiTag.hpp``. +checked **at runtime**. Every plugin solver registration (``load_algorithm_plugin()`` +/ ``AlgorithmRegistrar``) computes an "ABI tag" — ``EIGEN_MAX_ALIGN_BYTES``, the +resolved ``EIGEN_VECTORIZE_SSE``/``AVX``/``AVX2``/``AVX512``/``FMA``/``NEON`` +flags, and the full Eigen version (major.minor.patch) — in the plugin's own +translation unit, and compares it against the tag ``lightsim2grid_core`` was +actually compiled with. If they differ, registration is refused with an error +naming exactly what mismatched, instead of silently registering a plugin that +would corrupt the heap the first time one of its Eigen objects crosses the +``BaseAlgo`` interface. The same comparison runs once between +``lightsim2grid_core`` and ``lightsim2grid_cpp`` themselves at import time — +this is the check that would have caught the ``lightsim2grid_core`` / +``lightsim2grid_cpp`` split bug directly, as a clean Python ``ImportError`` +instead of a crash. This does not replace matching the build flags above (a +rejected plugin is still a plugin you can't use), but it turns a silent, +far-away heap corruption into an immediate, actionable error at load time. +See ``src/core/Ls2gAbiTag.hpp``. + +The Eigen version is part of the tag, compared exactly (not just the major +version): Eigen is header-only, so its semver promise +(https://libeigen.gitlab.io/news/eigen_5.0.0_released/) is about API +compatibility for code *using* Eigen, not about the internal aligned-malloc +offset arithmetic this check actually cares about staying identical across +two independently-compiled binaries — that isn't something Eigen tests or +promises release-to-release, since there is no shared-object ABI to keep +stable in the first place. Practically, this means **a plugin should be +compiled against the same Eigen headers lightsim2grid itself uses**, not a +separately-installed system Eigen: + +* Linking against an **installed** ``lightsim2grid_core`` (the normal case, + ``find_package(lightsim2grid_core CONFIG)``): the package bundles the exact + Eigen it was built with alongside its own headers, and + ``lightsim2grid_core_INCLUDE_DIRS`` already points at it — as long as your + plugin doesn't add a different Eigen include path ahead of that one (e.g. a + system ``/usr/include/eigen3``), it picks up the matching Eigen + automatically and this check is a non-issue. +* Building against the **source tree**: use the ``eigen/`` submodule checked + out in this repository (the same one the examples' ``Eigen3_INCLUDE`` + fallback points to), not a separately-installed copy. + +If you do need a different Eigen version for your plugin for some other +reason, that's a deliberate, advanced choice this check exists specifically +to flag — the error message tells you exactly which field(s) mismatched. Worked example (``examples/external_algorithm/``) diff --git a/src/core/Ls2gAbiTag.hpp b/src/core/Ls2gAbiTag.hpp index b1360967..bbfc5cf6 100644 --- a/src/core/Ls2gAbiTag.hpp +++ b/src/core/Ls2gAbiTag.hpp @@ -43,6 +43,15 @@ struct Ls2gAbiTag { unsigned eigen_world_version = EIGEN_WORLD_VERSION; unsigned eigen_major_version = EIGEN_MAJOR_VERSION; unsigned eigen_minor_version = EIGEN_MINOR_VERSION; + // EIGEN_PATCH_VERSION only exists since Eigen moved to real semver in 5.0.0 + // (https://libeigen.gitlab.io/news/eigen_5.0.0_released/); older Eigen + // (3.x, pre-5.0) never defined it, so a plugin built against an older, + // separately-installed Eigen must still compile against this header. +#ifdef EIGEN_PATCH_VERSION + unsigned eigen_patch_version = EIGEN_PATCH_VERSION; +#else + unsigned eigen_patch_version = 0; +#endif #ifdef EIGEN_VECTORIZE_SSE bool vectorize_sse = true; @@ -75,12 +84,24 @@ struct Ls2gAbiTag { bool vectorize_neon = false; #endif + // Eigen version is compared in full (world+major+minor+patch), not just + // major: Eigen is header-only, so its semver promise is about *API* + // compatibility for user code, not about the internal aligned-malloc + // offset arithmetic this tag actually cares about staying byte-identical + // across two independently-compiled binaries -- that's simply not + // something Eigen tests or promises across minor/patch releases, since + // there is no shared-object ABI to keep stable in the first place. In + // normal use core/bindings/plugins all build against the one Eigen + // commit vendored in this repo's `eigen/` submodule, so this only ever + // fires for a plugin deliberately pointed at a different Eigen copy -- + // see docs/solver_plugin.rst, section "Matching build flags". bool operator==(const Ls2gAbiTag& o) const { return tag_version == o.tag_version && eigen_max_align_bytes == o.eigen_max_align_bytes && eigen_world_version == o.eigen_world_version && eigen_major_version == o.eigen_major_version && eigen_minor_version == o.eigen_minor_version + && eigen_patch_version == o.eigen_patch_version && vectorize_sse == o.vectorize_sse && vectorize_avx == o.vectorize_avx && vectorize_avx2 == o.vectorize_avx2 @@ -91,11 +112,14 @@ struct Ls2gAbiTag { bool operator!=(const Ls2gAbiTag& o) const { return !(*this == o); } // Human-readable summary for error messages, e.g. "align=32 bytes, - // Eigen 5.0.1, SSE+AVX+AVX2+FMA". + // Eigen 5.0.1, SSE+AVX+AVX2+FMA". Displayed as major.minor.patch, + // matching Eigen's own EIGEN_VERSION_STRING convention (world_version is + // pinned at 3 forever per Eigen's own versioning scheme and not part of + // its public-facing version string, but is still compared in operator==). std::string describe() const { std::ostringstream oss; oss << "align=" << eigen_max_align_bytes << " bytes, Eigen " - << eigen_world_version << "." << eigen_major_version << "." << eigen_minor_version << ", "; + << eigen_major_version << "." << eigen_minor_version << "." << eigen_patch_version << ", "; bool first = true; auto add = [&](bool present, const char* name) { if (!present) return; diff --git a/src/tests/test_ls2g_abi_tag.cpp b/src/tests/test_ls2g_abi_tag.cpp index d69d8f7a..90ec34b8 100644 --- a/src/tests/test_ls2g_abi_tag.cpp +++ b/src/tests/test_ls2g_abi_tag.cpp @@ -37,6 +37,13 @@ TEST_CASE("Ls2gAbiTag equality reflects every field", "[ls2g_abi_tag]") b = a; b.tag_version += 1; REQUIRE(a != b); + + // Eigen version is compared in full: two independently-compiled TUs + // built against different Eigen copies (even just a patch bump apart) + // are treated as a mismatch -- see the comment on operator== for why. + b = a; + b.eigen_patch_version += 1; + REQUIRE(a != b); } TEST_CASE("register_solver rejects a solver registered with a mismatched ABI tag", "[ls2g_abi_tag]") From a696a3e1b916d6dbed076314677be183b6de660a Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 17 Jul 2026 08:40:15 +0200 Subject: [PATCH 138/166] before merge Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 18 +++++++ .../cmake/MatchLightsim2gridBuildFlags.cmake | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 examples/cmake/MatchLightsim2gridBuildFlags.cmake diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ea53f976..2cd152e4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -90,6 +90,24 @@ TODO: speed: `BaseBatchSolverSynch::compute_amps_flows` / `compute_active_power_ common type with `CplxVect::Zero(nb_steps)` for the open-side case. Removing the copy would need a control-flow restructure (separate open-side / closed-side code paths), not just a reference-type change. +TODO: building ``examples/dist_slack_algorithm`` against a NICSLU/CKTSO-enabled + ``lightsim2grid_core`` fails: ``NICSLUSolver.hpp`` (pulled in transitively via + ``Solvers.hpp``) needs NICSLU's own SDK header ``nicslu_cpp.inl`` (and, presumably, + CKTSO's own headers for ``CKTSOSolver.hpp``), which are a private implementation + detail not exposed by an installed ``lightsim2grid_core`` CMake package. The plugin's + ``CMakeLists.txt`` only special-cases SuiteSparse/KLU's headers this way (see the + comment there), not NICSLU/CKTSO's. Found while verifying the ``-march=native`` + matching fix below against a full ``env_compile_all.sh`` build; not fixed. +TODO: add a CI job that builds one of the example C++ algorithm plugins + (``examples/dist_slack_algorithm`` or ``examples/external_algorithm``) against a + ``lightsim2grid_core`` built with ``__COMPILE_MARCHNATIVE=1``, and actually loads + + runs it (not just compiles it), to catch a regression of the ``-march=native`` + matching mechanism (``lightsim2grid_core_MARCH_NATIVE`` export + + ``examples/cmake/MatchLightsim2gridBuildFlags.cmake``, see ``docs/solver_plugin.rst``). + ``.github/workflows/main.yml``'s ``test_march_native`` job and + ``test_plugin_against_installed`` job each cover half of this (the former builds + lightsim2grid itself with the flag but does not touch a plugin; the latter builds a + plugin but never with the flag) -- neither exercises the combination. [0.14.0] 2026-xx-yy --------------------- diff --git a/examples/cmake/MatchLightsim2gridBuildFlags.cmake b/examples/cmake/MatchLightsim2gridBuildFlags.cmake new file mode 100644 index 00000000..07e955f7 --- /dev/null +++ b/examples/cmake/MatchLightsim2gridBuildFlags.cmake @@ -0,0 +1,53 @@ +# Match lightsim2grid_core's -march=native / -O3 on a plugin target. +# +# lightsim2grid_core and a plugin module (e.g. this one) are two *separate* +# shared libraries. -march=native changes which SIMD instruction sets Eigen +# sees enabled (__AVX__, __AVX2__, ...), which changes EIGEN_MAX_ALIGN_BYTES +# and thus how Eigen aligns/allocates/frees its dynamic-size matrices. Any +# owning Eigen object (RealVect, CplxVect, ...) allocated under one alignment +# assumption and freed under another -- which can happen the moment such an +# object crosses the BaseAlgo virtual interface -- silently corrupts the +# heap ("double free or corruption", usually far away from the real cause). +# See docs/solver_plugin.rst for the full writeup. +# +# Usage, after find_package(lightsim2grid_core ...) and add_library( ...): +# include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/MatchLightsim2gridBuildFlags.cmake") +# ls2g_match_core_build_flags() +function(ls2g_match_core_build_flags target) + if(lightsim2grid_core_FOUND) + # Strategy 1 (installed package): the CMake config exports the + # flags lightsim2grid_core was actually built with -- no guessing. + set(_ls2g_march_native "${lightsim2grid_core_MARCH_NATIVE}") + set(_ls2g_o3_optim "${lightsim2grid_core_O3_OPTIM}") + else() + # Strategy 2 (source tree): no installed package to query -- fall + # back to the same env vars lightsim2grid itself reads, so building + # both the plugin and lightsim2grid from source with + # `__COMPILE_MARCHNATIVE=1` set stays consistent. + if("$ENV{__COMPILE_MARCHNATIVE}" STREQUAL "1" OR "$ENV{__COMPILE_MARCHNATIVE}" STREQUAL "True") + set(_ls2g_march_native ON) + else() + set(_ls2g_march_native OFF) + endif() + if("$ENV{__O3_OPTIM}" STREQUAL "1" OR "$ENV{__O3_OPTIM}" STREQUAL "True") + set(_ls2g_o3_optim ON) + else() + set(_ls2g_o3_optim OFF) + endif() + endif() + + # -march=native has no MSVC equivalent; lightsim2grid_core itself only + # ever applies it under `if(NOT MSVC)` (see src/core/CMakeLists.txt), so + # a Windows-built lightsim2grid_core is never USE_MARCH_NATIVE in the + # first place. + if(NOT MSVC) + if(_ls2g_march_native) + message(STATUS "${target}: lightsim2grid_core was built with -march=native -- matching it (required, see docs/solver_plugin.rst)") + target_compile_options(${target} PRIVATE -march=native) + endif() + if(_ls2g_o3_optim) + message(STATUS "${target}: lightsim2grid_core was built with -O3 -- matching it") + target_compile_options(${target} PRIVATE -O3) + endif() + endif() +endfunction() From cd709b66c165b5b3a2ac16388876c77dadca885e Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 17 Jul 2026 11:32:52 +0200 Subject: [PATCH 139/166] add possibility to extract detailed information about which generators controls which Q column Signed-off-by: DONNOT Benjamin --- src/bindings/python/binding_lsgrid.cpp | 7 +++++++ src/core/AlgorithmSelector.hpp | 3 +++ src/core/LSGrid.hpp | 8 ++++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 4 ++++ src/core/powerflow_algorithm/NRAlgo.hpp | 1 + src/core/powerflow_algorithm/NRSystem.hpp | 15 +++++++++++++++ 6 files changed, 38 insertions(+) diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index cb073078..0b83c586 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -346,6 +346,13 @@ views (eg `LightsimResultNetwork`), never by any C++ powerflow logic. .def("get_controller_elem_id_solver", &LSGrid::get_controller_elem_id_solver, py::return_value_policy::reference, "Element id (generator id if GEN, svc id if SVC) of each " "VoltageControl controller, same order as get_controller_q_solver().") + .def("get_controller_q_col_solver", &LSGrid::get_controller_q_col_solver, py::return_value_policy::reference, + "J column of each VoltageControl controller's own Q unknown, same " + "order as get_controller_q_solver(). NOT the same as the bus-keyed " + "get_q_to_J_col_solver(): that map only keeps the LAST controller " + "registered at a given bus, so it silently collides whenever two " + "controllers regulate reactive power from the same bus. External " + "solvers rebuilding this bordered block must use this instead.") .def("get_p_buses_solver", &LSGrid::get_p_buses_solver, py::return_value_policy::reference, "Compact (bus, row) pair list for P equations -- the row/col " diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 69872f30..4621acbf 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -301,6 +301,9 @@ class LS2G_API AlgorithmSelector final IntVect get_controller_elem_id() const { return get_prt_solver("get_controller_elem_id", false)->get_controller_elem_id(); } + IntVect get_controller_q_col() const { + return get_prt_solver("get_controller_q_col", false)->get_controller_q_col(); + } int get_slack_col() const { return get_prt_solver("get_slack_col", false)->get_slack_col(); } diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 29cdb16b..a1cd465c 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1561,6 +1561,14 @@ class LS2G_API LSGrid final [[nodiscard]] RealVect get_controller_q_solver() const { return _algo.get_controller_q(); } [[nodiscard]] IntVect get_controller_kind_solver() const { return _algo.get_controller_kind(); } [[nodiscard]] IntVect get_controller_elem_id_solver() const { return _algo.get_controller_elem_id(); } + // J column of each controller's own Q unknown, controller registration + // order -- NOT the bus-keyed get_q_to_J_col_solver() (that map only keeps + // the LAST controller registered at a given bus, see NRLedger:: + // add_q_unknown's own doc). External solvers rebuilding this bordered + // block (e.g. gpusim2grid) MUST use this whenever two controllers can + // share a bus, exactly like p_buses()/p_rows() etc. must be used instead + // of the bus-keyed maps for the base P/Q block. + [[nodiscard]] IntVect get_controller_q_col_solver() const { return _algo.get_controller_q_col(); } [[nodiscard]] real_type get_computation_time() const{ return _algo.get_computation_time();} [[nodiscard]] real_type get_dc_computation_time() const{ return _dc_algo.get_computation_time();} diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 8cb9c0b8..1d508026 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -240,6 +240,10 @@ class LS2G_API BaseAlgo : public BaseConstants virtual RealVect get_controller_q() const { return RealVect(); } virtual IntVect get_controller_kind() const { return IntVect(); } virtual IntVect get_controller_elem_id() const { return IntVect(); } + // J column of each controller's own Q unknown, in controller registration + // order -- NOT the bus-keyed q_to_J_col (see NRSystem::controller_q_col's + // own doc): needed whenever two controllers share a bus. + virtual IntVect get_controller_q_col() const { return IntVect(); } // MultiSlack: J column of the slack_absorbed unknown (-1 when the // distributed-slack-in-Jacobian extension is not active). diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index ccf8dd4b..023f11fb 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -107,6 +107,7 @@ class NRAlgo final : public BaseAlgo RealVect get_controller_q() const override { return _system.controller_q(); } IntVect get_controller_kind() const override { return _system.controller_kind(); } IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } + IntVect get_controller_q_col() const override { return _system.controller_q_col(); } int get_slack_col() const override { return _system.slack_col(); } real_type get_slack_absorbed() const override { return _system.slack_absorbed(); } diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index 4e979b3b..ddc7302c 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -783,6 +783,17 @@ class LS2G_API VoltageControl Eigen::Ref controller_q() const { return q_; } Eigen::Ref controller_kind() const { return data_.kind; } Eigen::Ref controller_elem_id() const { return data_.elem_id; } + // J column of each controller's own Q unknown (controller registration + // order, matching controller_q()/controller_kind()/controller_elem_id()). + // NOT the same as the ledger's bus-keyed q_to_J_col (NRLedger:: + // add_q_unknown's own doc: that map is "sugar" for introspection and + // only keeps the LAST controller registered at a given bus) -- callers + // needing the true per-controller column (e.g. an external solver + // rebuilding this bordered block, like gpusim2grid) whenever two + // controllers share a bus MUST use this, not q_to_J_col. + IntVect controller_q_col() const { + return Eigen::Map(q_cols_.data(), static_cast(q_cols_.size())); + } private: int my_size_; // number of controllers @@ -968,6 +979,10 @@ class NRSystem const VoltageControl* vc = _find_extension(); return vc ? IntVect(vc->controller_elem_id()) : IntVect(); } + IntVect controller_q_col() const { + const VoltageControl* vc = _find_extension(); + return vc ? IntVect(vc->controller_q_col()) : IntVect(); + } // ----- MultiSlack: slack_absorbed J column (-1 when the extension is absent) -- int slack_col() const { From 52608f2eecdf268e820353c62f7f5a254d219e2a Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 17 Jul 2026 15:28:11 +0200 Subject: [PATCH 140/166] fixing a KLU bug, present since beginning and rationalizing some interface between algorithm and linear solver -for counting solve, refactor and timing etc. Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 49 ++++++++ docs/solvers.rst | 49 ++++++++ lightsim2grid/algorithm/__init__.py | 8 ++ lightsim2grid/tests/test_KLUSolver.py | 46 ++++++++ src/bindings/python/binding_solvers.cpp | 98 ++++++++++++++++ src/core/AlgorithmSelector.hpp | 4 + src/core/BuiltinSolversRegistration.cpp | 6 + src/core/SolverInstantiations.cpp | 43 +++---- src/core/Solvers.hpp | 106 ++++++++++------- src/core/help_fun_msg.cpp | 40 ++++++- src/core/help_fun_msg.hpp | 3 + src/core/linear_solvers/KLUSolver.cpp | 2 + src/core/linear_solvers/KLUSolver.hpp | 2 +- .../linear_solvers/LinearSolverPolicy.hpp | 109 ++++++++++++++++++ src/core/linear_solvers/LinearSolverStats.hpp | 72 ++++++++++++ .../RefactorRetryLinearSolver.hpp | 60 ++++++++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 9 ++ src/core/powerflow_algorithm/BaseDCAlgo.hpp | 27 +++-- src/core/powerflow_algorithm/BaseDCAlgo.tpp | 12 -- src/core/powerflow_algorithm/BaseFDPFAlgo.hpp | 41 +++++-- src/core/powerflow_algorithm/BaseFDPFAlgo.tpp | 17 +-- src/core/powerflow_algorithm/NRAlgo.hpp | 28 ++--- src/core/powerflow_algorithm/NRAlgo.tpp | 39 +++---- 23 files changed, 725 insertions(+), 145 deletions(-) create mode 100644 src/core/linear_solvers/LinearSolverPolicy.hpp create mode 100644 src/core/linear_solvers/LinearSolverStats.hpp create mode 100644 src/core/linear_solvers/RefactorRetryLinearSolver.hpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 13393c0d..f8dca542 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -108,6 +108,8 @@ TODO: add a CI job that builds one of the example C++ algorithm plugins ``test_plugin_against_installed`` job each cover half of this (the former builds lightsim2grid itself with the flag but does not touch a plugin; the latter builds a plugin but never with the flag) -- neither exercises the combination. +TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding small decreasing + lambba coefficients to the diagonal of J to improve its conditionning. [0.14.0] 2026-xx-yy --------------------- @@ -755,6 +757,53 @@ TODO: add a CI job that builds one of the example C++ algorithm plugins ``load_algorithm_plugin``/``AlgorithmRegistrar``, and (2) ``lightsim2grid_cpp``'s module init, comparing itself against ``lightsim2grid_core``, catching this bug's own original failure mode directly at import time. +- [FIXED] ``KLULinearSolver`` never called SuiteSparse's ``klu_defaults()``, so its + ``klu_common`` control struct was left all-zero (from ``common_ = klu_common();``) + instead of the library's actual defaults. In particular ``tol=0`` (should be ``0.001``) + disabled partial-pivoting's diagonal-preference safety, ``scale=0`` (should be ``2``) + disabled row scaling, ``btf=0`` (should be ``TRUE``) disabled block-triangular + preordering, and critically ``halt_if_singular=FALSE`` (should be ``TRUE``) made + ``klu_factor``/``klu_refactor`` silently return ``KLU_OK`` even on a numerically + singular factorization (found with ``rcond=0``). This caused ``NR_KLU`` to diverge + (``InifiniteValue``, one Newton step after a degenerate factorization) on a real grid + (``PtFige-20240807-2300``) where ``NR_SparseLU`` converges fine from the same seed -- + previously misdiagnosed as an inherent SparseLU-vs-KLU numerical-sensitivity artifact. + ``klu_defaults(&common_)`` is now called in the constructor, ``reset()`` and + ``analyze()``. Tested in ``test_KLUSolver.py``. +- [BREAKING] (cpp only) every built-in solver's ``LinearSolver`` template parameter + (``NR_*``/``DC_*``/``FDPF_*``, see ``Solvers.hpp``) is now wrapped in + ``LinearSolverPolicy<...>`` (``src/core/linear_solvers/LinearSolverPolicy.hpp``): a + transparent, non-virtual pass-through that counts and times every + analyze/factorize/refactorize/solve call. ``NRAlgo``/``BaseDCAlgo``/``BaseFDPFAlgo`` no + longer keep their own ``timer_factor_``/``timer_refactor_``/``timer_initialize_`` + members -- ``get_timers_jacobian()`` now reads those ``TimerJac`` fields live from the + wrapper instead, with identical externally-observable semantics (still reset every + ``compute_pf``/``compute_pf_dc`` call). A C++ plugin subclassing ``NRAlgo``/ + ``BaseDCAlgo`` and referencing those protected members directly needs updating; a + plugin only using the public ``LinearSolver`` API (``analyze``/``factorize``/ + ``refactorize``/``solve``/``reset``, e.g. ``examples/dist_slack_algorithm/``) is + unaffected. +- [ADDED] ``LinearSolverStats`` (``src/core/linear_solvers/LinearSolverStats.hpp``, + exported to python): per-call counters (``nb_analyze``/``nb_factorize``/ + ``nb_refactorize``/``nb_refactorize_failed``/``nb_fallback_factorize``/ + ``nb_fallback_factorize_failed``/``nb_solve``/``nb_reset``) and matching durations + (``timer_initialize_``/``timer_factor_``/``timer_refactor_``/``timer_solve_``) for the + linear solver backing a solver instance. Counters accumulate over the algorithm's whole + lifetime (so an occasionally-firing fallback is distinguishable from a systematic one); + the timer fields reset every call, like ``get_timers_jacobian()``. Available as + ``solver.get_linear_solver_stats()`` on any solver (``model.get_solver()``, or the + concrete ``NR_KLU``/``DC_KLU``/... type), and as ``get_linear_solver_stats_bp()`` / + ``get_linear_solver_stats_bpp()`` on the two-linear-solver ``FDPF_*`` family. +- [ADDED] ``RefactorRetryLinearSolver`` + (``src/core/linear_solvers/RefactorRetryLinearSolver.hpp``, ``final``, derives from + ``LinearSolverPolicy``): if a ``refactorize()`` call fails, falls back to + a full ``factorize()`` (reusing the existing symbolic factorization) before reporting an + error, tracked separately via ``LinearSolverStats.nb_fallback_factorize`` / + ``nb_refactorize_failed``. A SuiteSparse-recommended defensive measure for KLU, + generalized here to any solver with a real factorize/refactorize distinction. New + built-in algorithms ``NRRefactorRetry_KLU``, ``NRRefactorRetry_CKTSO`` and + ``NRRefactorRetry_NICSLU`` use it (``SparseLU`` is skipped: its ``factorize()`` and + ``refactorize()`` are already the same call, so the fallback would be a no-op). [0.13.1] 2026-04-21 -------------------- diff --git a/docs/solvers.rst b/docs/solvers.rst index aca58fe7..d9e950da 100644 --- a/docs/solvers.rst +++ b/docs/solvers.rst @@ -65,6 +65,55 @@ LightSim2Grid supports four families of powerflow algorithms: Algorithms based on ``NICSLU`` and ``CKTSO`` require a compilation from source. CKTSO algorithms are (for now) only tested on Linux. +Linear-solver diagnostics: ``LinearSolverStats`` +-------------------------------------------------- + +Every algorithm above is backed by a linear solver (``SparseLU``, ``KLU``, ``NICSLU`` or +``CKTSO``) whose ``analyze``/``factorize``/``refactorize``/``solve`` calls are counted and +timed. Call ``get_linear_solver_stats()`` on a solver (e.g. ``env.backend._grid.get_solver().get_linear_solver_stats()``) +to get a :class:`lightsim2grid.algorithm.LinearSolverStats` with: + +- ``nb_analyze`` / ``nb_factorize`` / ``nb_refactorize`` / ``nb_solve`` / ``nb_reset``: how + many times each was called. These accumulate over the whole lifetime of the solver + object (not reset every powerflow), so a fallback or failure that fires occasionally is + distinguishable from one that fires systematically. +- ``nb_refactorize_failed`` / ``nb_fallback_factorize`` / ``nb_fallback_factorize_failed``: + see :class:`~lightsim2grid.algorithm.NRRefactorRetry_KLU` below. +- ``timer_initialize`` / ``timer_factor`` / ``timer_refactor`` / ``timer_solve``: matching + durations, reset every ``compute_pf``/``compute_pf_dc`` call like + :class:`~lightsim2grid.algorithm.TimerJac` (returned by ``get_timers_jacobian()``), which + these numbers also feed into. + +The two-linear-solver Fast-Decoupled family (``FDPF_XB_*``/``FDPF_BX_*``) exposes this per +solver instead, as ``get_linear_solver_stats_bp()`` / ``get_linear_solver_stats_bpp()`` +(for B' and B'' respectively) on the concrete solver object. + +Retrying a failed refactor: ``NRRefactorRetry_*`` +---------------------------------------------------- + +:class:`lightsim2grid.algorithm.NRRefactorRetry_KLU`, +:class:`~lightsim2grid.algorithm.NRRefactorRetry_CKTSO` and +:class:`~lightsim2grid.algorithm.NRRefactorRetry_NICSLU` are Newton-Raphson (multi-slack) +variants of :class:`~lightsim2grid.algorithm.NR_KLU` / ``NR_CKTSO`` / ``NR_NICSLU``: if a +Jacobian ``refactorize()`` call fails, they fall back to a full ``factorize()`` (reusing the +existing symbolic factorization) before reporting an error, instead of failing immediately. +This is a defensive measure recommended by SuiteSparse's own documentation for KLU, +generalized here to any linear solver with a real factorize/refactorize distinction. + +.. note:: + There is no ``NRRefactorRetry_SparseLU``: Eigen's ``SparseLU`` has no cheaper + "reuse pivot order" refactor -- its ``factorize()`` and ``refactorize()`` are already + the same call, so the fallback would be a no-op. + +.. note:: + These are registered by name only (not part of the :class:`~lightsim2grid.algorithm.AlgorithmType` + enum), the same way externally-loaded algorithm plugins are -- select them with + ``grid.change_algorithm("NRRefactorRetry_KLU")`` rather than via ``AlgorithmType``. + +Use :class:`~lightsim2grid.algorithm.LinearSolverStats` (``get_linear_solver_stats()``, see +above) to inspect how often the fallback actually fires: ``nb_refactorize_failed`` and +``nb_fallback_factorize`` stay at ``0`` on a grid where refactor never fails. + Default algorithm selection ------------------------------ diff --git a/lightsim2grid/algorithm/__init__.py b/lightsim2grid/algorithm/__init__.py index 1a09a5f3..86383d50 100644 --- a/lightsim2grid/algorithm/__init__.py +++ b/lightsim2grid/algorithm/__init__.py @@ -9,6 +9,7 @@ __all__ = ["AlgorithmType", "ErrorType", "AlgorithmSelector", + "LinearSolverStats", "GaussSeidelAlgo", "GaussSeidelSynchAlgo", "NR_SparseLU", @@ -20,6 +21,7 @@ from ..lightsim2grid_cpp import AlgorithmType # pyright: ignore[reportMissingImports] from ..lightsim2grid_cpp import ErrorType # pyright: ignore[reportMissingImports] from ..lightsim2grid_cpp import AlgorithmSelector # pyright: ignore[reportMissingImports] +from ..lightsim2grid_cpp import LinearSolverStats # pyright: ignore[reportMissingImports] from ..lightsim2grid_cpp import GaussSeidelAlgo # AlgorithmType.GaussSeidel # pyright: ignore[reportMissingImports] from ..lightsim2grid_cpp import GaussSeidelSynchAlgo # AlgorithmType.GaussSeidelSynch # pyright: ignore[reportMissingImports] @@ -35,11 +37,13 @@ from ..lightsim2grid_cpp import DC_KLU # AlgorithmType.DC_KLU # pyright: ignore[reportMissingImports] # noqa: F401 from ..lightsim2grid_cpp import FDPF_XB_KLU # AlgorithmType.FDPF_XB_KLU # pyright: ignore[reportMissingImports] # noqa: F401 from ..lightsim2grid_cpp import FDPF_BX_KLU # AlgorithmType.FDPF_BX_KLU # pyright: ignore[reportMissingImports] # noqa: F401 + from ..lightsim2grid_cpp import NRRefactorRetry_KLU # not in AlgorithmType (string-registered only) # pyright: ignore[reportMissingImports] # noqa: F401 __all__.append("NR_KLU") __all__.append("NRSing_KLU") __all__.append("DC_KLU") __all__.append("FDPF_XB_KLU") __all__.append("FDPF_BX_KLU") + __all__.append("NRRefactorRetry_KLU") except Exception as exc_: # noqa: F841 # KLU is not available pass @@ -50,11 +54,13 @@ from ..lightsim2grid_cpp import DC_NICSLU # AlgorithmType.DC_NICSLU # pyright: ignore[reportMissingImports] # noqa: F401 from ..lightsim2grid_cpp import FDPF_XB_NICSLU # AlgorithmType.FDPF_XB_NICSLU # pyright: ignore[reportMissingImports] # noqa: F401 from ..lightsim2grid_cpp import FDPF_BX_NICSLU # AlgorithmType.FDPF_BX_NICSLU # pyright: ignore[reportMissingImports] # noqa: F401 + from ..lightsim2grid_cpp import NRRefactorRetry_NICSLU # not in AlgorithmType (string-registered only) # pyright: ignore[reportMissingImports] # noqa: F401 __all__.append("NR_NICSLU") __all__.append("NRSing_NICSLU") __all__.append("DC_NICSLU") __all__.append("FDPF_XB_NICSLU") __all__.append("FDPF_BX_NICSLU") + __all__.append("NRRefactorRetry_NICSLU") except Exception as exc_: # noqa: F841 # NICSLU is not available pass @@ -65,11 +71,13 @@ from ..lightsim2grid_cpp import DC_CKTSO # AlgorithmType.DC_CKTSO # pyright: ignore[reportMissingImports] # noqa: F401 from ..lightsim2grid_cpp import FDPF_XB_CKTSO # AlgorithmType.FDPF_XB_CKTSO # pyright: ignore[reportMissingImports] # noqa: F401 from ..lightsim2grid_cpp import FDPF_BX_CKTSO # AlgorithmType.FDPF_BX_CKTSO # pyright: ignore[reportMissingImports] # noqa: F401 + from ..lightsim2grid_cpp import NRRefactorRetry_CKTSO # not in AlgorithmType (string-registered only) # pyright: ignore[reportMissingImports] # noqa: F401 __all__.append("NR_CKTSO") __all__.append("NRSing_CKTSO") __all__.append("DC_CKTSO") __all__.append("FDPF_XB_CKTSO") __all__.append("FDPF_BX_CKTSO") + __all__.append("NRRefactorRetry_CKTSO") except Exception as exc_: # noqa: F841 # CKTSO is not available pass diff --git a/lightsim2grid/tests/test_KLUSolver.py b/lightsim2grid/tests/test_KLUSolver.py index e5e9a87a..9087af51 100644 --- a/lightsim2grid/tests/test_KLUSolver.py +++ b/lightsim2grid/tests/test_KLUSolver.py @@ -133,6 +133,52 @@ def test_dir(self): nb_tested += 1 assert nb_tested == 5, "incorrect number of test cases found, found {} while there should be 5".format(nb_tested) + def _find_and_load_one_case(self): + for path in os.listdir("."): + _, ext = os.path.splitext(path) + if ext == ".zip" and self.load_path(path): + return + raise RuntimeError("no test case zip found in the current directory") + + def test_linear_solver_stats(self): + """get_linear_solver_stats() (LinearSolverPolicy wrapper) reports non-trivial, + consistent counters after a successful powerflow.""" + if not KLU_AVAILBLE: + self.skipTest("KLU is not installed") + self._find_and_load_one_case() + self.solver_aux() + stats = self.solver.get_linear_solver_stats() + assert stats.nb_analyze >= 1, "analyze() should have been called at least once" + assert stats.nb_factorize >= 1, "factorize() should have been called at least once" + assert stats.nb_solve >= 1, "solve() should have been called at least once" + assert stats.nb_refactorize_failed == 0, "no refactor should fail on a well-behaved test case" + assert stats.nb_fallback_factorize == 0, "no fallback factorize expected on plain NR_KLU" + assert stats.timer_factor >= 0.0 + assert stats.timer_solve >= 0.0 + + def test_refactor_retry_solver(self): + """NRRefactorRetry_KLU (RefactorRetryLinearSolver) converges the same as NR_KLU + on a well-behaved case, with zero fallback fires (sanity, not a regression).""" + if not KLU_AVAILBLE: + self.skipTest("KLU is not installed") + try: + from lightsim2grid.lightsim2grid_cpp import NRRefactorRetry_KLU + except ImportError: + self.skipTest("NRRefactorRetry_KLU is not available") + self._find_and_load_one_case() + solver = NRRefactorRetry_KLU() + ref = set(np.arange(self.Sbus.shape[0])) - set(self.pv) - set(self.pq) + ref = np.array(list(ref)) + slack_weights = np.zeros(self.Sbus.shape[0]) + slack_weights[ref] = 1.0 / ref.shape[0] + has_conv = solver.compute_pf(self.Ybus, self.V_init, self.Sbus, ref, slack_weights, + self.pv, self.pq, self.max_it, self.tol) + assert has_conv, "NRRefactorRetry_KLU failed to converge on a case where NR_KLU succeeds" + stats = solver.get_linear_solver_stats() + assert stats.nb_factorize >= 1 + assert stats.nb_refactorize_failed == 0, "unexpected refactor failure on a well-behaved test case" + assert stats.nb_fallback_factorize == 0, "unexpected fallback factorize on a well-behaved test case" + if __name__ == "__main__": unittest.main() diff --git a/src/bindings/python/binding_solvers.cpp b/src/bindings/python/binding_solvers.cpp index 9715148e..98fbe188 100644 --- a/src/bindings/python/binding_solvers.cpp +++ b/src/bindings/python/binding_solvers.cpp @@ -85,6 +85,29 @@ void bind_nr_algo_policies(py::class_& cls) { ; } +// Bind get_linear_solver_stats() for solvers holding a single LinearSolver (NR_*/DC_*). +// FDPF_* has two solvers (B'/B'') and is bound separately, see bind_fdpf_linear_solver_stats. +template +void bind_linear_solver_stats(py::class_& cls) { + cls.def("get_linear_solver_stats", &Solver::get_linear_solver_stats, + "Per-call counters and timings for the underlying linear solver: " + "factor/refactor/analyze/solve counts, refactor-failure and " + "fallback-factor counts, and matching durations (LinearSolverStats). " + "Counters accumulate over the algorithm's whole lifetime; the timer_* " + "fields reset every compute_pf call, like get_timers_jacobian()."); +} + +// Bind the two-solver (B'/B'') equivalent for FDPF_* types. +template +void bind_fdpf_linear_solver_stats(py::class_& cls) { + cls + .def("get_linear_solver_stats_bp", &Solver::get_linear_solver_stats_bp, + "Per-call counters and timings for the B' linear solver (LinearSolverStats)") + .def("get_linear_solver_stats_bpp", &Solver::get_linear_solver_stats_bpp, + "Per-call counters and timings for the B'' linear solver (LinearSolverStats)") + ; +} + void bind_solvers(py::module_& m) { // ---- TimerJac ---- py::class_(m, "TimerJac", @@ -133,6 +156,32 @@ void bind_solvers(py::module_& m) { ", ...)"; }); + // ---- LinearSolverStats ---- + py::class_(m, "LinearSolverStats", + "Per-call counters and timings for a linear solver, as tracked by " + "LinearSolverPolicy (every built-in solver) and RefactorRetryLinearSolver " + "(NRRefactorRetry_* solvers). Counters accumulate over the algorithm's whole " + "lifetime; the timer_* fields reset every compute_pf call.") + .def_readonly("nb_reset", &LinearSolverStats::nb_reset) + .def_readonly("nb_analyze", &LinearSolverStats::nb_analyze) + .def_readonly("nb_factorize", &LinearSolverStats::nb_factorize) + .def_readonly("nb_refactorize", &LinearSolverStats::nb_refactorize) + .def_readonly("nb_refactorize_failed", &LinearSolverStats::nb_refactorize_failed) + .def_readonly("nb_fallback_factorize", &LinearSolverStats::nb_fallback_factorize) + .def_readonly("nb_fallback_factorize_failed", &LinearSolverStats::nb_fallback_factorize_failed) + .def_readonly("nb_solve", &LinearSolverStats::nb_solve) + .def_readonly("timer_initialize", &LinearSolverStats::timer_initialize_) + .def_readonly("timer_factor", &LinearSolverStats::timer_factor_) + .def_readonly("timer_refactor", &LinearSolverStats::timer_refactor_) + .def_readonly("timer_solve", &LinearSolverStats::timer_solve_) + .def("__repr__", [](const LinearSolverStats& s) { + return "LinearSolverStats(nb_factorize=" + std::to_string(s.nb_factorize) + + ", nb_refactorize=" + std::to_string(s.nb_refactorize) + + ", nb_refactorize_failed=" + std::to_string(s.nb_refactorize_failed) + + ", nb_fallback_factorize=" + std::to_string(s.nb_fallback_factorize) + + ", ...)"; + }); + // ---- SparseLU ---- { auto cls = py::class_(m, "NR_SparseLU", DocSolver::NR_SparseLU.c_str()) @@ -140,6 +189,7 @@ void bind_solvers(py::module_& m) { .def("get_J", &NR_SparseLU::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "NRSing_SparseLU", DocSolver::NRSing_SparseLU.c_str()) @@ -147,11 +197,13 @@ void bind_solvers(py::module_& m) { .def("get_J", &NRSing_SparseLU::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "DC_SparseLU", DocSolver::DC_SparseLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_XB_SparseLU", DocSolver::FDPF_XB_SparseLU.c_str()) @@ -159,6 +211,7 @@ void bind_solvers(py::module_& m) { .def("debug_get_Bp_python", &FDPF_XB_SparseLU::debug_get_Bp_python, DocLSGrid::_internal_do_not_use.c_str()) .def("debug_get_Bpp_python", &FDPF_XB_SparseLU::debug_get_Bpp_python, DocLSGrid::_internal_do_not_use.c_str()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_BX_SparseLU", DocSolver::FDPF_BX_SparseLU.c_str()) @@ -166,6 +219,7 @@ void bind_solvers(py::module_& m) { .def("debug_get_Bp_python", &FDPF_BX_SparseLU::debug_get_Bp_python, DocLSGrid::_internal_do_not_use.c_str()) .def("debug_get_Bpp_python", &FDPF_BX_SparseLU::debug_get_Bpp_python, DocLSGrid::_internal_do_not_use.c_str()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); } #if defined(KLU_SOLVER_AVAILABLE) || defined(_READ_THE_DOCS) @@ -175,6 +229,7 @@ void bind_solvers(py::module_& m) { .def("get_J", &NR_KLU::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "NRSing_KLU", DocSolver::NRSing_KLU.c_str()) @@ -182,21 +237,33 @@ void bind_solvers(py::module_& m) { .def("get_J", &NRSing_KLU::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "DC_KLU", DocSolver::DC_KLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_XB_KLU", DocSolver::FDPF_XB_KLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_BX_KLU", DocSolver::FDPF_BX_KLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); + } + { + auto cls = py::class_(m, "NRRefactorRetry_KLU", DocSolver::NRRefactorRetry_KLU.c_str()) + .def(py::init<>()) + .def("get_J", &NRRefactorRetry_KLU::get_J_python, DocSolver::get_J_python.c_str()); + bind_algo_methods(cls); + bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } #endif // KLU_SOLVER_AVAILABLE (or _READ_THE_DOCS) @@ -207,6 +274,7 @@ void bind_solvers(py::module_& m) { .def("get_J", &NR_NICSLU::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "NRSing_NICSLU", DocSolver::NRSing_NICSLU.c_str()) @@ -214,21 +282,33 @@ void bind_solvers(py::module_& m) { .def("get_J", &NRSing_NICSLU::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "DC_NICSLU", DocSolver::DC_NICSLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_XB_NICSLU", DocSolver::FDPF_XB_NICSLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_BX_NICSLU", DocSolver::FDPF_BX_NICSLU.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); + } + { + auto cls = py::class_(m, "NRRefactorRetry_NICSLU", DocSolver::NRRefactorRetry_NICSLU.c_str()) + .def(py::init<>()) + .def("get_J", &NRRefactorRetry_NICSLU::get_J_python, DocSolver::get_J_python.c_str()); + bind_algo_methods(cls); + bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } #endif // NICSLU_SOLVER_AVAILABLE (or _READ_THE_DOCS) @@ -239,6 +319,7 @@ void bind_solvers(py::module_& m) { .def("get_J", &NR_CKTSO::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "NRSing_CKTSO", DocSolver::NRSing_CKTSO.c_str()) @@ -246,21 +327,33 @@ void bind_solvers(py::module_& m) { .def("get_J", &NRSing_CKTSO::get_J_python, DocSolver::get_J_python.c_str()); bind_algo_methods(cls); bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "DC_CKTSO", DocSolver::DC_CKTSO.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_XB_CKTSO", DocSolver::FDPF_XB_CKTSO.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); } { auto cls = py::class_(m, "FDPF_BX_CKTSO", DocSolver::FDPF_BX_CKTSO.c_str()) .def(py::init<>()); bind_algo_methods(cls); + bind_fdpf_linear_solver_stats(cls); + } + { + auto cls = py::class_(m, "NRRefactorRetry_CKTSO", DocSolver::NRRefactorRetry_CKTSO.c_str()) + .def(py::init<>()) + .def("get_J", &NRRefactorRetry_CKTSO::get_J_python, DocSolver::get_J_python.c_str()); + bind_algo_methods(cls); + bind_nr_algo_policies(cls); + bind_linear_solver_stats(cls); } #endif // CKTSO_SOLVER_AVAILABLE (or _READ_THE_DOCS) @@ -293,6 +386,11 @@ void bind_solvers(py::module_& m) { .def("get_timers", &AlgorithmSelector::get_timers, "TODO") .def("get_timers_jacobian", &AlgorithmSelector::get_timers_jacobian, "TODO") .def("get_timers_ptdf_lodf", &AlgorithmSelector::get_timers_ptdf_lodf, "TODO") + .def("get_linear_solver_stats", &AlgorithmSelector::get_linear_solver_stats, + "Per-call counters and timings for the underlying linear solver (LinearSolverStats). " + "All-zero if the active solver doesn't track them (e.g. GaussSeidel, or the " + "FDPF family which exposes get_linear_solver_stats_bp/_bpp on its own concrete " + "Python type instead, since it holds two linear solvers).") .def("get_fdpf_xb_lu", &AlgorithmSelector::get_fdpf_xb_lu, py::return_value_policy::reference, DocLSGrid::_internal_do_not_use.c_str()) .def("get_fdpf_bx_lu", &AlgorithmSelector::get_fdpf_bx_lu, py::return_value_policy::reference, DocLSGrid::_internal_do_not_use.c_str()); } diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 4621acbf..0e91a4ab 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -327,6 +327,10 @@ class LS2G_API AlgorithmSelector final return get_prt_solver("get_timers_ptdf_lodf", true)->get_timers_ptdf_lodf(); } + LinearSolverStats get_linear_solver_stats() const { + return get_prt_solver("get_linear_solver_stats", true)->get_linear_solver_stats(); + } + ErrorType get_error() const { return get_prt_solver("get_error", true)->get_error(); } diff --git a/src/core/BuiltinSolversRegistration.cpp b/src/core/BuiltinSolversRegistration.cpp index 8ae80f11..15d9e6f6 100644 --- a/src/core/BuiltinSolversRegistration.cpp +++ b/src/core/BuiltinSolversRegistration.cpp @@ -39,6 +39,8 @@ void register_builtin_solvers(AlgorithmRegistry& reg) { []{ return std::make_unique(); }); reg.register_solver("FDPF_BX_KLU", []{ return std::make_unique(); }); + reg.register_solver("NRRefactorRetry_KLU", + []{ return std::make_unique(); }); #endif // KLU_SOLVER_AVAILABLE #ifdef NICSLU_SOLVER_AVAILABLE @@ -52,6 +54,8 @@ void register_builtin_solvers(AlgorithmRegistry& reg) { []{ return std::make_unique(); }); reg.register_solver("FDPF_BX_NICSLU", []{ return std::make_unique(); }); + reg.register_solver("NRRefactorRetry_NICSLU", + []{ return std::make_unique(); }); #endif // NICSLU_SOLVER_AVAILABLE #ifdef CKTSO_SOLVER_AVAILABLE @@ -65,6 +69,8 @@ void register_builtin_solvers(AlgorithmRegistry& reg) { []{ return std::make_unique(); }); reg.register_solver("FDPF_BX_CKTSO", []{ return std::make_unique(); }); + reg.register_solver("NRRefactorRetry_CKTSO", + []{ return std::make_unique(); }); #endif // CKTSO_SOLVER_AVAILABLE } diff --git a/src/core/SolverInstantiations.cpp b/src/core/SolverInstantiations.cpp index b25f48c8..9f4c0331 100644 --- a/src/core/SolverInstantiations.cpp +++ b/src/core/SolverInstantiations.cpp @@ -35,37 +35,40 @@ void BaseFDPFAlgo::fillBp_Bpp( } // ---- SparseLU (always available) ---- -template class LS2G_API NRAlgo; -template class LS2G_API NRAlgo; -template class LS2G_API BaseDCAlgo; -template class LS2G_API BaseFDPFAlgo; -template class LS2G_API BaseFDPFAlgo; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; +template class LS2G_API NRAlgo, SingleSlackNRSystem>; +template class LS2G_API BaseDCAlgo>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; // ---- KLU (optional) ---- #ifdef KLU_SOLVER_AVAILABLE -template class LS2G_API NRAlgo; -template class LS2G_API NRAlgo; -template class LS2G_API BaseDCAlgo; -template class LS2G_API BaseFDPFAlgo; -template class LS2G_API BaseFDPFAlgo; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; +template class LS2G_API NRAlgo, SingleSlackNRSystem>; +template class LS2G_API BaseDCAlgo>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; #endif // ---- NICSLU (optional) ---- #ifdef NICSLU_SOLVER_AVAILABLE -template class LS2G_API NRAlgo; -template class LS2G_API NRAlgo; -template class LS2G_API BaseDCAlgo; -template class LS2G_API BaseFDPFAlgo; -template class LS2G_API BaseFDPFAlgo; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; +template class LS2G_API NRAlgo, SingleSlackNRSystem>; +template class LS2G_API BaseDCAlgo>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; #endif // ---- CKTSO (optional) ---- #ifdef CKTSO_SOLVER_AVAILABLE -template class LS2G_API NRAlgo; -template class LS2G_API NRAlgo; -template class LS2G_API BaseDCAlgo; -template class LS2G_API BaseFDPFAlgo; -template class LS2G_API BaseFDPFAlgo; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; +template class LS2G_API NRAlgo, SingleSlackNRSystem>; +template class LS2G_API BaseDCAlgo>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; +template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; +template class LS2G_API NRAlgo, MultiSlackNRSystem>; #endif } // namespace ls2g diff --git a/src/core/Solvers.hpp b/src/core/Solvers.hpp index d370e39a..2da02589 100644 --- a/src/core/Solvers.hpp +++ b/src/core/Solvers.hpp @@ -19,107 +19,133 @@ #include "linear_solvers/KLUSolver.hpp" #include "linear_solvers/NICSLUSolver.hpp" #include "linear_solvers/CKTSOSolver.hpp" +#include "linear_solvers/LinearSolverPolicy.hpp" +#include "linear_solvers/RefactorRetryLinearSolver.hpp" namespace ls2g { +// Every built-in solver below is wrapped in LinearSolverPolicy<...>: a transparent +// pass-through that counts and times each analyze/factorize/refactorize/solve call +// (see LinearSolverPolicy.hpp). NRAlgo/BaseDCAlgo/BaseFDPFAlgo source their own +// get_timers_jacobian()/get_linear_solver_stats() from it instead of keeping separate +// bookkeeping. + /** Newton-Raphson (multi-slack) with Eigen SparseLU linear solver **/ -using NR_SparseLU = NRAlgo; +using NR_SparseLU = NRAlgo, MultiSlackNRSystem>; /** Newton-Raphson (single-slack) with Eigen SparseLU linear solver **/ -using NRSing_SparseLU = NRAlgo; +using NRSing_SparseLU = NRAlgo, SingleSlackNRSystem>; /** DC approximation with Eigen SparseLU linear solver **/ -using DC_SparseLU = BaseDCAlgo; +using DC_SparseLU = BaseDCAlgo>; /** Fast-Decoupled Power Flow (XB variant) with Eigen SparseLU linear solver **/ -using FDPF_XB_SparseLU = BaseFDPFAlgo; +using FDPF_XB_SparseLU = BaseFDPFAlgo, FDPFMethod::XB>; /** Fast-Decoupled Power Flow (BX variant) with Eigen SparseLU linear solver **/ -using FDPF_BX_SparseLU = BaseFDPFAlgo; +using FDPF_BX_SparseLU = BaseFDPFAlgo, FDPFMethod::BX>; +// NB: SparseLU's factorize()==refactorize() already (Eigen has no cheaper "reuse pivot +// order" refactor), so there is deliberately no NRRefactorRetry_SparseLU -- wrapping it +// would be a no-op retry. #ifdef KLU_SOLVER_AVAILABLE /** Newton-Raphson (multi-slack) with KLU linear solver **/ - using NR_KLU = NRAlgo; + using NR_KLU = NRAlgo, MultiSlackNRSystem>; /** Newton-Raphson (single-slack) with KLU linear solver **/ - using NRSing_KLU = NRAlgo; + using NRSing_KLU = NRAlgo, SingleSlackNRSystem>; /** DC approximation with KLU linear solver **/ - using DC_KLU = BaseDCAlgo; + using DC_KLU = BaseDCAlgo>; /** Fast-Decoupled Power Flow (XB variant) with KLU linear solver **/ - using FDPF_XB_KLU = BaseFDPFAlgo; + using FDPF_XB_KLU = BaseFDPFAlgo, FDPFMethod::XB>; /** Fast-Decoupled Power Flow (BX variant) with KLU linear solver **/ - using FDPF_BX_KLU = BaseFDPFAlgo; + using FDPF_BX_KLU = BaseFDPFAlgo, FDPFMethod::BX>; + /** Newton-Raphson (multi-slack) with KLU linear solver, retrying a failed refactor + * with a full factorize() before giving up **/ + using NRRefactorRetry_KLU = NRAlgo, MultiSlackNRSystem>; #elif defined(_READ_THE_DOCS) using NR_KLU = NR_SparseLU; using NRSing_KLU = NRSing_SparseLU; using DC_KLU = DC_SparseLU; using FDPF_XB_KLU = FDPF_XB_SparseLU; using FDPF_BX_KLU = FDPF_BX_SparseLU; + using NRRefactorRetry_KLU = NR_SparseLU; #endif // KLU_SOLVER_AVAILABLE #ifdef NICSLU_SOLVER_AVAILABLE /** Newton-Raphson (multi-slack) with NICSLU linear solver (requires license) **/ - using NR_NICSLU = NRAlgo; + using NR_NICSLU = NRAlgo, MultiSlackNRSystem>; /** Newton-Raphson (single-slack) with NICSLU linear solver (requires license) **/ - using NRSing_NICSLU = NRAlgo; + using NRSing_NICSLU = NRAlgo, SingleSlackNRSystem>; /** DC approximation with NICSLU linear solver (requires license) **/ - using DC_NICSLU = BaseDCAlgo; + using DC_NICSLU = BaseDCAlgo>; /** Fast-Decoupled Power Flow (XB variant) with NICSLU linear solver (requires license) **/ - using FDPF_XB_NICSLU = BaseFDPFAlgo; + using FDPF_XB_NICSLU = BaseFDPFAlgo, FDPFMethod::XB>; /** Fast-Decoupled Power Flow (BX variant) with NICSLU linear solver (requires license) **/ - using FDPF_BX_NICSLU = BaseFDPFAlgo; + using FDPF_BX_NICSLU = BaseFDPFAlgo, FDPFMethod::BX>; + /** Newton-Raphson (multi-slack) with NICSLU linear solver, retrying a failed refactor + * with a full factorize() before giving up (requires license) **/ + using NRRefactorRetry_NICSLU = NRAlgo, MultiSlackNRSystem>; #elif defined(_READ_THE_DOCS) using NR_NICSLU = NR_SparseLU; using NRSing_NICSLU = NRSing_SparseLU; using DC_NICSLU = DC_SparseLU; using FDPF_XB_NICSLU = FDPF_XB_SparseLU; using FDPF_BX_NICSLU = FDPF_BX_SparseLU; + using NRRefactorRetry_NICSLU = NR_SparseLU; #endif // NICSLU_SOLVER_AVAILABLE #ifdef CKTSO_SOLVER_AVAILABLE /** Newton-Raphson (multi-slack) with CKTSO linear solver (requires license) **/ - using NR_CKTSO = NRAlgo; + using NR_CKTSO = NRAlgo, MultiSlackNRSystem>; /** Newton-Raphson (single-slack) with CKTSO linear solver (requires license) **/ - using NRSing_CKTSO = NRAlgo; + using NRSing_CKTSO = NRAlgo, SingleSlackNRSystem>; /** DC approximation with CKTSO linear solver (requires license) **/ - using DC_CKTSO = BaseDCAlgo; + using DC_CKTSO = BaseDCAlgo>; /** Fast-Decoupled Power Flow (XB variant) with CKTSO linear solver (requires license) **/ - using FDPF_XB_CKTSO = BaseFDPFAlgo; + using FDPF_XB_CKTSO = BaseFDPFAlgo, FDPFMethod::XB>; /** Fast-Decoupled Power Flow (BX variant) with CKTSO linear solver (requires license) **/ - using FDPF_BX_CKTSO = BaseFDPFAlgo; + using FDPF_BX_CKTSO = BaseFDPFAlgo, FDPFMethod::BX>; + /** Newton-Raphson (multi-slack) with CKTSO linear solver, retrying a failed refactor + * with a full factorize() before giving up (requires license) **/ + using NRRefactorRetry_CKTSO = NRAlgo, MultiSlackNRSystem>; #elif defined(_READ_THE_DOCS) using NR_CKTSO = NR_SparseLU; using NRSing_CKTSO = NRSing_SparseLU; using DC_CKTSO = DC_SparseLU; using FDPF_XB_CKTSO = FDPF_XB_SparseLU; using FDPF_BX_CKTSO = FDPF_BX_SparseLU; + using NRRefactorRetry_CKTSO = NR_SparseLU; #endif // CKTSO_SOLVER_AVAILABLE #ifndef LS2G_BUILDING_CORE - extern template class LS2G_API NRAlgo; - extern template class LS2G_API NRAlgo; - extern template class LS2G_API BaseDCAlgo; - extern template class LS2G_API BaseFDPFAlgo; - extern template class LS2G_API BaseFDPFAlgo; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; + extern template class LS2G_API NRAlgo, SingleSlackNRSystem>; + extern template class LS2G_API BaseDCAlgo>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; #ifdef KLU_SOLVER_AVAILABLE - extern template class LS2G_API NRAlgo; - extern template class LS2G_API NRAlgo; - extern template class LS2G_API BaseDCAlgo; - extern template class LS2G_API BaseFDPFAlgo; - extern template class LS2G_API BaseFDPFAlgo; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; + extern template class LS2G_API NRAlgo, SingleSlackNRSystem>; + extern template class LS2G_API BaseDCAlgo>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; #endif #ifdef NICSLU_SOLVER_AVAILABLE - extern template class LS2G_API NRAlgo; - extern template class LS2G_API NRAlgo; - extern template class LS2G_API BaseDCAlgo; - extern template class LS2G_API BaseFDPFAlgo; - extern template class LS2G_API BaseFDPFAlgo; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; + extern template class LS2G_API NRAlgo, SingleSlackNRSystem>; + extern template class LS2G_API BaseDCAlgo>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; #endif #ifdef CKTSO_SOLVER_AVAILABLE - extern template class LS2G_API NRAlgo; - extern template class LS2G_API NRAlgo; - extern template class LS2G_API BaseDCAlgo; - extern template class LS2G_API BaseFDPFAlgo; - extern template class LS2G_API BaseFDPFAlgo; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; + extern template class LS2G_API NRAlgo, SingleSlackNRSystem>; + extern template class LS2G_API BaseDCAlgo>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::XB>; + extern template class LS2G_API BaseFDPFAlgo, FDPFMethod::BX>; + extern template class LS2G_API NRAlgo, MultiSlackNRSystem>; #endif #endif // LS2G_BUILDING_CORE diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index ca8bd465..a8dd654a 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -294,6 +294,17 @@ const std::string DocSolver::NRSing_KLU = R"mydelimiter( )mydelimiter"; +const std::string DocSolver::NRRefactorRetry_KLU = R"mydelimiter( + Same as :class:`lightsim2grid.solver.NR_KLU` (Newton Raphson, distributed slack, KLU + linear solver), except that if a Jacobian refactorize() fails it falls back to a full + factorize() (reusing the existing symbolic factorization) before giving up, rather + than reporting an error immediately. + + Use `get_linear_solver_stats()` on the solver to inspect how often factor/refactor + calls happen and how often the fallback fires (see :class:`lightsim2grid.solver.LinearSolverStats`). + +)mydelimiter"; + const std::string DocSolver::DC_KLU = R"mydelimiter( Alternative implementation of the DC solver, it uses the faster KLU solver available in the SuiteSparse library to solve for the DC voltage given the DC admitance matrix and the power injected at each nodes (can be unavailable if you build lightsim2grid from source). @@ -373,7 +384,21 @@ const std::string DocSolver::NR_NICSLU = R"mydelimiter( .. note:: NICSLU is available at https://github.com/chenxm1986/nicslu - + +)mydelimiter"; + +const std::string DocSolver::NRRefactorRetry_NICSLU = R"mydelimiter( + Same as :class:`lightsim2grid.solver.NR_NICSLU` (Newton Raphson, distributed slack, + NICSLU linear solver), except that if a Jacobian refactorize() fails it falls back to + a full factorize() before giving up, rather than reporting an error immediately. For + NICSLU, factorize() and refactorize() call the same underlying routine, so this + fallback is effectively a no-op retry -- included mainly for API symmetry with + :class:`lightsim2grid.solver.NRRefactorRetry_KLU` and + :class:`lightsim2grid.solver.NRRefactorRetry_CKTSO`. + + Use `get_linear_solver_stats()` on the solver to inspect how often factor/refactor + calls happen and how often the fallback fires (see :class:`lightsim2grid.solver.LinearSolverStats`). + )mydelimiter"; const std::string DocSolver::NRSing_NICSLU = R"mydelimiter( @@ -500,7 +525,18 @@ const std::string DocSolver::NR_CKTSO = R"mydelimiter( .. note:: CKTSO is available at https://github.com/chenxm1986/cktso - + +)mydelimiter"; + +const std::string DocSolver::NRRefactorRetry_CKTSO = R"mydelimiter( + Same as :class:`lightsim2grid.solver.NR_CKTSO` (Newton Raphson, distributed slack, + CKTSO linear solver), except that if a Jacobian refactorize() fails it falls back to + a full factorize() (reusing the existing symbolic factorization) before giving up, + rather than reporting an error immediately. + + Use `get_linear_solver_stats()` on the solver to inspect how often factor/refactor + calls happen and how often the fallback fires (see :class:`lightsim2grid.solver.LinearSolverStats`). + )mydelimiter"; const std::string DocSolver::NRSing_CKTSO = R"mydelimiter( diff --git a/src/core/help_fun_msg.hpp b/src/core/help_fun_msg.hpp index 5b5720dc..5ec75581 100644 --- a/src/core/help_fun_msg.hpp +++ b/src/core/help_fun_msg.hpp @@ -45,18 +45,21 @@ struct LS2G_API DocSolver static const std::string DC_KLU; static const std::string FDPF_XB_KLU; static const std::string FDPF_BX_KLU; + static const std::string NRRefactorRetry_KLU; static const std::string NR_NICSLU; static const std::string NRSing_NICSLU; static const std::string DC_NICSLU; static const std::string FDPF_XB_NICSLU; static const std::string FDPF_BX_NICSLU; + static const std::string NRRefactorRetry_NICSLU; static const std::string NR_CKTSO; static const std::string NRSing_CKTSO; static const std::string DC_CKTSO; static const std::string FDPF_XB_CKTSO; static const std::string FDPF_BX_CKTSO; + static const std::string NRRefactorRetry_CKTSO; static const std::string GaussSeidelAlgo; static const std::string GaussSeidelSynchAlgo; diff --git a/src/core/linear_solvers/KLUSolver.cpp b/src/core/linear_solvers/KLUSolver.cpp index b4d96e27..4ad9f12e 100644 --- a/src/core/linear_solvers/KLUSolver.cpp +++ b/src/core/linear_solvers/KLUSolver.cpp @@ -19,6 +19,7 @@ ErrorType KLULinearSolver::reset(){ numeric_.reset(); symbolic_.reset(); common_ = klu_common(); + klu_defaults(&common_); return ErrorType::NoError; } @@ -30,6 +31,7 @@ ErrorType KLULinearSolver::analyze(const EigenRefConstRealSpMat & J){ numeric_.reset(); symbolic_.reset(); common_ = klu_common(); + klu_defaults(&common_); symbolic_.reset(klu_analyze(n, const_cast::StorageIndex *>(J.outerIndexPtr()), const_cast::StorageIndex *>(J.innerIndexPtr()), diff --git a/src/core/linear_solvers/KLUSolver.hpp b/src/core/linear_solvers/KLUSolver.hpp index 630ecda2..cfbd5c21 100644 --- a/src/core/linear_solvers/KLUSolver.hpp +++ b/src/core/linear_solvers/KLUSolver.hpp @@ -44,7 +44,7 @@ class LS2G_API KLULinearSolver final common_(), symbolic_(nullptr, SymbolicDeleter{&common_}), numeric_(nullptr, NumericDeleter{&common_}) - {} + { klu_defaults(&common_); } // symbolic_ / numeric_ are unique_ptr with custom deleters and free themselves. ~KLULinearSolver() noexcept = default; diff --git a/src/core/linear_solvers/LinearSolverPolicy.hpp b/src/core/linear_solvers/LinearSolverPolicy.hpp new file mode 100644 index 00000000..0f467898 --- /dev/null +++ b/src/core/linear_solvers/LinearSolverPolicy.hpp @@ -0,0 +1,109 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef LINEAR_SOLVER_POLICY_H +#define LINEAR_SOLVER_POLICY_H + +#include "Utils.hpp" +#include "CustTimer.hpp" +#include "LinearSolverStats.hpp" + +namespace ls2g { + +/** +Default linear-solver policy: a transparent pass-through wrapper around `LinearSolver` +that counts and times every call (reset/analyze/factorize/refactorize/solve) into a +LinearSolverStats. This is the wrapper every built-in NR/DC/FDPF solver uses by +default (see Solvers.hpp): NRAlgo/BaseDCAlgo/BaseFDPFAlgo no longer keep their own +timer_factor_/timer_refactor_/timer_initialize_ bookkeeping, they read it from here +instead (single source of truth). + +Deliberately NOT virtual: `_linear_solver`'s static type is always known at compile time +by the algorithm that holds it (it's a template parameter, never accessed through a base +pointer/reference), so plain method-hiding is correct here and avoids an unnecessary +vtable / indirect-call overhead that a virtual interface would add to every single +solver call for no actual polymorphism ever exercised. +**/ +template +class LinearSolverPolicy +{ + public: + LinearSolverPolicy() noexcept = default; + ~LinearSolverPolicy() noexcept = default; + + // can this linear solver solve problem where RHS is a matrix + static const bool CAN_SOLVE_MAT; + + ErrorType reset() { + ++stats_.nb_reset; + return inner_.reset(); + } + + ErrorType analyze(const EigenRefConstRealSpMat & J) { + ++stats_.nb_analyze; + auto timer = CustTimer(); + ErrorType res = inner_.analyze(J); + stats_.timer_initialize_ += timer.duration(); + return res; + } + + ErrorType factorize(const EigenRefConstRealSpMat & J) { + ++stats_.nb_factorize; + auto timer = CustTimer(); + ErrorType res = inner_.factorize(J); + stats_.timer_factor_ += timer.duration(); + return res; + } + + ErrorType refactorize(const EigenRefConstRealSpMat & J) { + ++stats_.nb_refactorize; + auto timer = CustTimer(); + ErrorType res = inner_.refactorize(J); + stats_.timer_refactor_ += timer.duration(); + return res; + } + + ErrorType solve(Eigen::Ref b) { + ++stats_.nb_solve; + auto timer = CustTimer(); + ErrorType res = inner_.solve(b); + stats_.timer_solve_ += timer.duration(); + return res; + } + + const LinearSolverStats & get_linear_solver_stats() const noexcept { return stats_; } + + // Called from the owning algorithm's reset_timer() (itself invoked at the start + // of every compute_pf/compute_pf_dc): zeroes only the timer_* fields, so + // get_timers_jacobian() keeps reporting "last call only" like it always has. + // Counters (nb_*) are untouched -- see detail::reset_stats_timers_impl. + void reset_stats_timers() noexcept { + stats_.timer_initialize_ = 0.; + stats_.timer_factor_ = 0.; + stats_.timer_refactor_ = 0.; + stats_.timer_solve_ = 0.; + } + + protected: + LinearSolver inner_; + LinearSolverStats stats_; + + private: + // no copy allowed (matches the concrete solver classes' own convention) + LinearSolverPolicy(const LinearSolverPolicy&) = delete; + LinearSolverPolicy(LinearSolverPolicy&&) = delete; + LinearSolverPolicy & operator=(LinearSolverPolicy&&) = delete; + LinearSolverPolicy & operator=(const LinearSolverPolicy&) = delete; +}; + +template +const bool LinearSolverPolicy::CAN_SOLVE_MAT = LinearSolver::CAN_SOLVE_MAT; + +} // namespace ls2g + +#endif // LINEAR_SOLVER_POLICY_H diff --git a/src/core/linear_solvers/LinearSolverStats.hpp b/src/core/linear_solvers/LinearSolverStats.hpp new file mode 100644 index 00000000..5e2b4d8f --- /dev/null +++ b/src/core/linear_solvers/LinearSolverStats.hpp @@ -0,0 +1,72 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef LINEAR_SOLVER_STATS_H +#define LINEAR_SOLVER_STATS_H + +#include + +namespace ls2g { + +/** +Per-call counters and timings for a linear solver, as tracked by LinearSolverPolicy<> +(and its RefactorRetryLinearSolver<> derivative). All algorithms wrapping a LinearSolver +(NRAlgo, BaseDCAlgo, BaseFDPFAlgo) source their own timer_factor_/timer_refactor_/ +timer_initialize_/timer_solve_ reporting from an instance of this struct instead of +keeping their own separate CustTimer-based bookkeeping. + +Field names for the timers deliberately match TimerJac's (see BaseAlgo.hpp) so callers +can copy them across directly. +**/ +struct LinearSolverStats { + std::size_t nb_reset = 0; + std::size_t nb_analyze = 0; + std::size_t nb_factorize = 0; // includes fallback-triggered factorize() calls + std::size_t nb_refactorize = 0; // refactorize() attempts + std::size_t nb_refactorize_failed = 0; // of those, how many failed + std::size_t nb_fallback_factorize = 0; // subset of nb_factorize triggered by a failed refactorize + std::size_t nb_fallback_factorize_failed = 0; // of those, how many also failed + + std::size_t nb_solve = 0; + + double timer_initialize_ = 0.; // time spent in analyze() + double timer_factor_ = 0.; // time spent in factorize() (incl. fallback factors) + double timer_refactor_ = 0.; // time spent in refactorize() itself (not the fallback factor) + double timer_solve_ = 0.; // time spent in solve() +}; + +namespace detail { + // C++14-safe detection idiom (deliberately not `if constexpr`, which is C++17: this + // project falls back to cxx_std_14 on older compilers, see CMakeLists.txt). Prefers + // the real accessor when the wrapped LinearSolver provides one (LinearSolverPolicy<> + // and derivatives), else returns an all-zero struct for plain, unwrapped solvers. + template + auto get_stats_impl(const T& solver, int) -> decltype(solver.get_linear_solver_stats()) { + return solver.get_linear_solver_stats(); + } + template + LinearSolverStats get_stats_impl(const T&, long) { + return LinearSolverStats{}; + } + + // Same idiom for resetting only the *timer* fields (see LinearSolverPolicy:: + // reset_stats_timers): a no-op for plain, unwrapped solvers. Counters (nb_*) are + // deliberately left alone -- they accumulate over the algorithm's whole lifetime, + // not per powerflow call, so a systematic (vs. occasional) fallback shows up even + // if nobody inspects stats after every single solve. + template + auto reset_stats_timers_impl(T& solver, int) -> decltype(solver.reset_stats_timers(), void()) { + solver.reset_stats_timers(); + } + template + void reset_stats_timers_impl(T&, long) {} +} // namespace detail + +} // namespace ls2g + +#endif // LINEAR_SOLVER_STATS_H diff --git a/src/core/linear_solvers/RefactorRetryLinearSolver.hpp b/src/core/linear_solvers/RefactorRetryLinearSolver.hpp new file mode 100644 index 00000000..7abe8c03 --- /dev/null +++ b/src/core/linear_solvers/RefactorRetryLinearSolver.hpp @@ -0,0 +1,60 @@ +// Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +#ifndef REFACTOR_RETRY_LINEAR_SOLVER_H +#define REFACTOR_RETRY_LINEAR_SOLVER_H + +#include "Utils.hpp" +#include "CustTimer.hpp" +#include "LinearSolverStats.hpp" +#include "LinearSolverPolicy.hpp" + +namespace ls2g { + +/** +Same counting/timing behavior as LinearSolverPolicy, plus: if refactorize() +fails, fall back to a full factorize() (reusing whatever symbolic state the underlying +solver already holds) before giving up. This is a defensive measure recommended by +SuiteSparse's own docs for KLU's klu_refactor/klu_factor pair, generalized here to any +LinearSolver exposing a real factorize/refactorize distinction (KLU, CKTSO -- for +SparseLU/NICSLU, factorize() and refactorize() are the same call, so the fallback is a +harmless no-op there). + +`final`: matches the project's existing convention (e.g. KLULinearSolver, NRAlgo) -- +this is meant to be used only as a concrete, leaf LinearSolver type, never derived from +further. It does NOT enable any virtual-dispatch optimization (there is nothing virtual +here to devirtualize): refactorize() below hides (does not override) the non-virtual +base method of the same name, resolved entirely at compile time since this class is only +ever used as a template parameter, never accessed through a LinearSolverPolicy<>&/* base +reference. +**/ +template +class RefactorRetryLinearSolver final : public LinearSolverPolicy +{ + public: + ErrorType refactorize(const EigenRefConstRealSpMat & J) { + ++this->stats_.nb_refactorize; + auto timer = CustTimer(); + ErrorType res = this->inner_.refactorize(J); + this->stats_.timer_refactor_ += timer.duration(); + if (res != ErrorType::NoError) { + ++this->stats_.nb_refactorize_failed; + ++this->stats_.nb_fallback_factorize; + auto timer_f = CustTimer(); + res = this->inner_.factorize(J); + this->stats_.timer_factor_ += timer_f.duration(); + ++this->stats_.nb_factorize; + if (res != ErrorType::NoError) ++this->stats_.nb_fallback_factorize_failed; + } + return res; + } +}; + +} // namespace ls2g + +#endif // REFACTOR_RETRY_LINEAR_SOLVER_H diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index 1d508026..e3422ef7 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -25,6 +25,7 @@ #include "CustTimer.hpp" #include "BaseConstants.hpp" #include "AlgoConfig.hpp" +#include "linear_solvers/LinearSolverStats.hpp" #include "Eigen/Core" #include "Eigen/Dense" @@ -331,6 +332,14 @@ class LS2G_API BaseAlgo : public BaseConstants return res; } + // Per-call counters and timings for the underlying linear solver (see + // LinearSolverPolicy / RefactorRetryLinearSolver). Default: all-zero, meaning + // "not tracked" -- overridden by NRAlgo/BaseDCAlgo (a single LinearSolver). + // BaseFDPFAlgo has two linear solvers (B'/B'') and exposes them separately + // (get_linear_solver_stats_bp/_bpp on the concrete FDPF_* Python type instead), + // so it does not override this generic single-solver accessor. + virtual LinearSolverStats get_linear_solver_stats() const { return LinearSolverStats{}; } + // Complex AC entry point: solves V . (Ybus . V)* = Sbus. // Every AC solver overrides this; the DC solver does not (it uses `compute_pf_dc`) and // therefore inherits this throwing default (symmetric with `compute_pf_dc` below). diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.hpp b/src/core/powerflow_algorithm/BaseDCAlgo.hpp index 65c30cbd..4c953e4f 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.hpp @@ -11,6 +11,7 @@ #include "BaseAlgo.hpp" #include "HvdcDroopData.hpp" +#include "linear_solvers/LinearSolverStats.hpp" namespace ls2g { @@ -28,9 +29,6 @@ class BaseDCAlgo final: public BaseAlgo _linear_solver(), need_factorize_(true), need_refactor_(true), - timer_factor_(0.), - timer_refactor_(0.), - timer_initialize_(0.), timer_pre_proc_(0.), timer_mismatch_(0.), // used for all the post processing timer_ptdf_(0.), @@ -46,24 +44,22 @@ class BaseDCAlgo final: public BaseAlgo void reset() override; void reset_timer() override{ BaseAlgo::reset_timer(); - timer_refactor_ = 0.; - timer_factor_ = 0.; - timer_initialize_ = 0.; + detail::reset_stats_timers_impl(_linear_solver, 0); timer_pre_proc_ = 0.; timer_mismatch_ = 0.; - timer_solve_ = 0.; timer_ptdf_ = 0.; timer_lodf_ = 0.; } TimerJac get_timers_jacobian() const override { + const LinearSolverStats lsstats = detail::get_stats_impl(_linear_solver, 0); TimerJac res; res.timer_Fx_ = timer_Fx_; - res.timer_solve_ = timer_solve_; - res.timer_factor_ = timer_factor_; - res.timer_refactor_ = timer_refactor_; - res.timer_initialize_ = timer_initialize_; + res.timer_solve_ = lsstats.timer_solve_; + res.timer_factor_ = lsstats.timer_factor_; + res.timer_refactor_ = lsstats.timer_refactor_; + res.timer_initialize_ = lsstats.timer_initialize_; res.timer_check_ = timer_check_; res.timer_total_nr_ = timer_total_nr_; res.timer_pre_proc_ = timer_pre_proc_; @@ -71,6 +67,12 @@ class BaseDCAlgo final: public BaseAlgo return res; } + // Per-call counters and timings for the underlying linear solver -- see + // NRAlgo::get_linear_solver_stats for the full description. + LinearSolverStats get_linear_solver_stats() const override { + return detail::get_stats_impl(_linear_solver, 0); + } + TimerPTDFLODFType get_timers_ptdf_lodf() const override { TimerPTDFLODFType res = { @@ -161,9 +163,6 @@ class BaseDCAlgo final: public BaseAlgo bool need_factorize_; bool need_refactor_; - double timer_factor_; - double timer_refactor_; - double timer_initialize_; double timer_pre_proc_; double timer_mismatch_; // used for all the post processing diff --git a/src/core/powerflow_algorithm/BaseDCAlgo.tpp b/src/core/powerflow_algorithm/BaseDCAlgo.tpp index 988dc6a4..8c1bf114 100644 --- a/src/core/powerflow_algorithm/BaseDCAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseDCAlgo.tpp @@ -171,20 +171,14 @@ bool BaseDCAlgo::compute_pf_dc( bool factorized_now = false; if(need_factorize_){ // std::cout << "\t\t\tneed to factorize\n"; - auto timer_an = CustTimer(); ErrorType status_init = _linear_solver.analyze(sys_mat); - const double dur_an = timer_an.duration(); - timer_initialize_ += dur_an; if(status_init != ErrorType::NoError){ err_ = status_init; timer_total_nr_ += timer.duration(); return false; } - auto timer_fac = CustTimer(); status_init = _linear_solver.factorize(sys_mat); - const double dur_fact = timer_fac.duration(); - timer_factor_ += dur_fact; if(status_init != ErrorType::NoError){ err_ = status_init; timer_total_nr_ += timer.duration(); @@ -247,10 +241,7 @@ bool BaseDCAlgo::compute_pf_dc( // we should end-up here only in case of n-1 simulation (handled in contingency analysis) // set to true in update_internal_Ybus // std::cout << "\t\t\tneed to refactorize\n"; - auto timer_s = CustTimer(); ErrorType error = _linear_solver.refactorize(sys_mat); - const double dur_refacto = timer_s.duration(); - timer_refactor_ += dur_refacto; if(error != ErrorType::NoError){ err_ = error; timer_total_nr_ += timer.duration(); @@ -258,10 +249,7 @@ bool BaseDCAlgo::compute_pf_dc( } } { - auto timer_s = CustTimer(); ErrorType error = _linear_solver.solve(Va_dc_without_slack); - const double dur_solve = timer_s.duration(); - timer_solve_ += dur_solve; if(error != ErrorType::NoError){ err_ = error; timer_total_nr_ += timer.duration(); diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp index 23b9d313..f58a4d7a 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.hpp @@ -10,6 +10,7 @@ #define BASEFDPFALGO_H #include "BaseAlgo.hpp" +#include "linear_solvers/LinearSolverStats.hpp" namespace ls2g { @@ -64,9 +65,38 @@ class BaseFDPFAlgo final: public BaseAlgo Eigen::SparseMatrix debug_get_Bp_python() const { return Bp_;} Eigen::SparseMatrix debug_get_Bpp_python() const { return Bpp_;} + // Two linear solvers (B' and B''): reported separately rather than merged, so + // no information is lost (unlike timer_initialize_ below, which historically + // combines both for backward compatibility with get_timers_jacobian()). + LinearSolverStats get_linear_solver_stats_bp() const { + return detail::get_stats_impl(_linear_solver_Bp, 0); + } + LinearSolverStats get_linear_solver_stats_bpp() const { + return detail::get_stats_impl(_linear_solver_Bpp, 0); + } + + TimerJac get_timers_jacobian() const override + { + const LinearSolverStats bp = detail::get_stats_impl(_linear_solver_Bp, 0); + const LinearSolverStats bpp = detail::get_stats_impl(_linear_solver_Bpp, 0); + TimerJac res; + res.timer_Fx_ = timer_Fx_; + res.timer_solve_ = bp.timer_solve_ + bpp.timer_solve_; + // FDPF never refactorizes (B'/B'' are fixed matrices, factorized once), and + // historically combined analyze+factor into a single timer_initialize_ for + // both solvers -- preserved here for backward compatibility. + res.timer_initialize_ = bp.timer_initialize_ + bp.timer_factor_ + + bpp.timer_initialize_ + bpp.timer_factor_; + res.timer_check_ = timer_check_; + res.timer_total_nr_ = timer_total_nr_; + return res; + } + protected: void reset_timer() override { BaseAlgo::reset_timer(); + detail::reset_stats_timers_impl(_linear_solver_Bp, 0); + detail::reset_stats_timers_impl(_linear_solver_Bpp, 0); } CplxVect evaluate_mismatch(const EigenRefConstCplxSpMat & Ybus, @@ -87,7 +117,6 @@ class BaseFDPFAlgo final: public BaseAlgo Eigen::SparseMatrix & Bpp) const; // defined in Solvers.cpp ! void initialize() { - auto timer = CustTimer(); err_ = ErrorType::NoError; // reset error message // analyze (structure) + factorize (values) for Bp solver ErrorType init_status = _linear_solver_Bp.analyze(Bp_); @@ -98,7 +127,6 @@ class BaseFDPFAlgo final: public BaseAlgo _linear_solver_Bpp.reset(); err_ = init_status; need_factorize_ = true; - timer_initialize_ += timer.duration(); return; } // analyze (structure) + factorize (values) for Bpp solver (if Bp succeeded) @@ -110,24 +138,20 @@ class BaseFDPFAlgo final: public BaseAlgo _linear_solver_Bpp.reset(); err_ = init_status; need_factorize_ = true; - timer_initialize_ += timer.duration(); return; } // everything went well need_factorize_ = false; - timer_initialize_ += timer.duration(); } void solve(LinearSolver& linear_solver, Eigen::Ref b){ - auto timer = CustTimer(); const ErrorType solve_status = linear_solver.solve(b); if(solve_status != ErrorType::NoError){ // std::cout << "solve error: " << solve_status << std::endl; err_ = solve_status; } - timer_solve_ += timer.duration(); } bool has_converged( @@ -211,11 +235,6 @@ class BaseFDPFAlgo final: public BaseAlgo RealVect q_; // (size n_pq) bool need_factorize_; - // timers - double timer_initialize_; - // double timer_dSbus_; - // double timer_fillJ_; - private: // no copy allowed BaseFDPFAlgo(const BaseFDPFAlgo&) = delete; diff --git a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp index 69e3837c..d6c7b4d5 100644 --- a/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp +++ b/src/core/powerflow_algorithm/BaseFDPFAlgo.tpp @@ -142,14 +142,15 @@ bool BaseFDPFAlgo::compute_pf( } timer_total_nr_ += timer.duration(); #ifdef __COUT_TIMES - std::cout << "Computation time: " << "\n\t timer_initialize_: " << timer_initialize_ - << "\n\t timer_dSbus_ (called in _fillJ_): " << timer_dSbus_ - << "\n\t timer_fillJ_: " << timer_fillJ_ - << "\n\t timer_Fx_: " << timer_Fx_ - << "\n\t timer_check_: " << timer_check_ - << "\n\t timer_solve_: " << timer_solve_ - << "\n\t timer_total_nr_: " << timer_total_nr_ - << "\n\n"; + { + const TimerJac tj = get_timers_jacobian(); + std::cout << "Computation time: " << "\n\t timer_initialize_: " << tj.timer_initialize_ + << "\n\t timer_Fx_: " << timer_Fx_ + << "\n\t timer_check_: " << timer_check_ + << "\n\t timer_solve_: " << tj.timer_solve_ + << "\n\t timer_total_nr_: " << timer_total_nr_ + << "\n\n"; + } #endif // __COUT_TIMES return res; } diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 023f11fb..f3bc9f6f 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -13,6 +13,7 @@ #include "NRSystem.hpp" #include "ScalingPolicies.hpp" #include "RefactorPolicies.hpp" +#include "linear_solvers/LinearSolverStats.hpp" namespace ls2g { @@ -42,9 +43,6 @@ class NRAlgo final : public BaseAlgo iw_mu_min_(static_cast(1e-4)), iw_mu_max_(static_cast(1.0)), refactor_every_n_(4), - timer_factor_(0.), - timer_refactor_(0.), - timer_initialize_(0.), timer_dSbus_(0.), timer_fillJ_(0.), timer_Va_Vm_(0.), @@ -115,12 +113,13 @@ class NRAlgo final : public BaseAlgo TimerJac get_timers_jacobian() const override { + const LinearSolverStats lsstats = detail::get_stats_impl(_linear_solver, 0); TimerJac res; res.timer_Fx_ = timer_Fx_; - res.timer_solve_ = timer_solve_; - res.timer_factor_ = timer_factor_; - res.timer_refactor_ = timer_refactor_; - res.timer_initialize_ = timer_initialize_; + res.timer_solve_ = lsstats.timer_solve_; + res.timer_factor_ = lsstats.timer_factor_; + res.timer_refactor_ = lsstats.timer_refactor_; + res.timer_initialize_ = lsstats.timer_initialize_; res.timer_check_ = timer_check_; res.timer_dSbus_ = timer_dSbus_; res.timer_fillJ_ = timer_fillJ_; @@ -132,6 +131,14 @@ class NRAlgo final : public BaseAlgo return res; } + // Per-call counters and timings for the underlying linear solver (factor/refactor/ + // analyze/solve counts, refactor-failure and fallback-factor counts if the wrapped + // LinearSolver tracks them -- see LinearSolverPolicy/RefactorRetryLinearSolver). + // Returns an all-zero LinearSolverStats if LinearSolver doesn't track any. + LinearSolverStats get_linear_solver_stats() const override { + return detail::get_stats_impl(_linear_solver, 0); + } + // ----- powerflow ----------------------------------------------------------- bool compute_pf( @@ -255,13 +262,11 @@ class NRAlgo final : public BaseAlgo protected: void reset_timer() override { BaseAlgo::reset_timer(); - timer_factor_ = 0.; - timer_refactor_ = 0.; + detail::reset_stats_timers_impl(_linear_solver, 0); timer_dSbus_ = 0.; timer_fillJ_ = 0.; timer_Va_Vm_ = 0.; timer_pre_proc_ = 0.; - timer_initialize_ = 0.; timer_scale_ = 0.; timer_mismatch_ = 0.; _system.reset_timers(); @@ -307,9 +312,6 @@ class NRAlgo final : public BaseAlgo int refactor_every_n_; // Timers - double timer_factor_; - double timer_refactor_; - double timer_initialize_; double timer_dSbus_; double timer_fillJ_; double timer_Va_Vm_; diff --git a/src/core/powerflow_algorithm/NRAlgo.tpp b/src/core/powerflow_algorithm/NRAlgo.tpp index 58b308db..fd2b625a 100644 --- a/src/core/powerflow_algorithm/NRAlgo.tpp +++ b/src/core/powerflow_algorithm/NRAlgo.tpp @@ -93,22 +93,14 @@ bool NRAlgo::compute_pf( if (need_init) { // New sparsity pattern: analyze (structure) then factorize (values). - { - auto timer_i = CustTimer(); - err_ = _linear_solver.analyze(_system.J()); - timer_initialize_ += timer_i.duration(); - } + err_ = _linear_solver.analyze(_system.J()); if (err_ == ErrorType::NoError) { - auto timer_f = CustTimer(); err_ = _linear_solver.factorize(_system.J()); - timer_factor_ += timer_f.duration(); } need_init = false; need_factorize_ = false; } else { - auto timer_r = CustTimer(); err_ = _linear_solver.refactorize(_system.J()); - timer_refactor_ += timer_r.duration(); } if (err_ != ErrorType::NoError) { res = false; break; } need_factorize = false; @@ -116,12 +108,8 @@ bool NRAlgo::compute_pf( // Solve J * dx = F (F = mismatch, negated convention; F overwritten with dx) // std::cout << "_linear_solver.solve(F);\n"; - { - auto timer_s = CustTimer(); - err_ = _linear_solver.solve(F); - timer_solve_ += timer_s.duration(); - } - if (err_ != ErrorType::NoError) { + err_ = _linear_solver.solve(F); + if (err_ != ErrorType::NoError) { res = false; break; } @@ -164,15 +152,18 @@ bool NRAlgo::compute_pf( timer_total_nr_ += timer.duration(); #ifdef __COUT_TIMES - std::cout << "Computation time: " - << "\n\t timer_initialize_: " << timer_initialize_ - << "\n\t timer_dSbus_: " << timer_dSbus_ - << "\n\t timer_fillJ_: " << timer_fillJ_ - << "\n\t timer_Fx_: " << timer_Fx_ - << "\n\t timer_check_: " << timer_check_ - << "\n\t timer_solve_: " << timer_solve_ - << "\n\t timer_total_nr_: " << timer_total_nr_ - << "\n\n"; + { + const LinearSolverStats lsstats = detail::get_stats_impl(_linear_solver, 0); + std::cout << "Computation time: " + << "\n\t timer_initialize_: " << lsstats.timer_initialize_ + << "\n\t timer_dSbus_: " << timer_dSbus_ + << "\n\t timer_fillJ_: " << timer_fillJ_ + << "\n\t timer_Fx_: " << timer_Fx_ + << "\n\t timer_check_: " << timer_check_ + << "\n\t timer_solve_: " << lsstats.timer_solve_ + << "\n\t timer_total_nr_: " << timer_total_nr_ + << "\n\n"; + } #endif return res; } From ebdb0f8ce6b32f127a2e6dac4368f69c4f5a637a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 10:52:48 +0000 Subject: [PATCH 141/166] Add Levenberg-Marquardt Newton-Raphson as an out-of-tree example plugin Some real grids have ill-conditioned clusters (high R, heavily loaded, little local generation) where plain NR diverges and the existing step- scaling policies (Iwamoto, MaxVoltageChange) only rescale the same bad Newton direction rather than fixing it. LM regularizes the direction itself by damping J's diagonal, with lambda adapted via an accept/reject loop on the actual mismatch reduction of each trial step. - Core: add NRSystem::update_trailing_feature_values(count, deltas), a small, generic method that directly updates the last `count` declared feature entries in J_.valuePtr(), bypassing fill_internal_variables()/ fill_J(). Not diagonal- or LM-specific; any extension needing a fast partial refresh of its own trailing feature slots can use it. - examples/lm_algorithm/: a new out-of-tree plugin, following the same pattern as examples/dist_slack_algorithm/ and examples/external_algorithm/ (standalone CMakeLists.txt, AlgorithmRegistry self-registration, not wired into the main build/CI). - DiagonalDamping.hpp: DiagonalEntry, a minimal NRSystem extension that reserves one feature slot per existing unknown (for the diagonal load), plus with_diagonal_entry to inject it as the last extension of an existing SingleSlackNRSystem/MultiSlackNRSystem. - LMNRAlgo.hpp/.tpp: the algorithm itself, a BaseAlgo sibling to NRAlgo (not built on it, since the inner accept/reject loop doesn't fit NRAlgo's single solve-per-iteration flow or its RefactorPolicy/ ScalingPolicy abstractions). - LMNRAlgo_plugin.cpp + CMakeLists.txt: registers "NR_LM_SparseLU" and "NR_LM_KLU". - test_plugin.py: registration/load smoke test, same scope as the existing external_algorithm one. Verified: examples/lm_algorithm builds cleanly (incl. -Wall -Wextra) with only the expected core symbols left undefined (Base/Hvdc/VoltageControl, SparseLULinearSolver, AlgorithmRegistry), and the existing dist_slack_algorithm example still builds against the modified NRSystem. Signed-off-by: Claude --- examples/lm_algorithm/CMakeLists.txt | 99 +++++++++ examples/lm_algorithm/DiagonalDamping.hpp | 94 +++++++++ examples/lm_algorithm/LMNRAlgo.hpp | 232 ++++++++++++++++++++++ examples/lm_algorithm/LMNRAlgo.tpp | 144 ++++++++++++++ examples/lm_algorithm/LMNRAlgo_plugin.cpp | 42 ++++ examples/lm_algorithm/test_plugin.py | 97 +++++++++ src/core/powerflow_algorithm/NRSystem.hpp | 13 ++ src/core/powerflow_algorithm/NRSystem.tpp | 10 + 8 files changed, 731 insertions(+) create mode 100644 examples/lm_algorithm/CMakeLists.txt create mode 100644 examples/lm_algorithm/DiagonalDamping.hpp create mode 100644 examples/lm_algorithm/LMNRAlgo.hpp create mode 100644 examples/lm_algorithm/LMNRAlgo.tpp create mode 100644 examples/lm_algorithm/LMNRAlgo_plugin.cpp create mode 100644 examples/lm_algorithm/test_plugin.py diff --git a/examples/lm_algorithm/CMakeLists.txt b/examples/lm_algorithm/CMakeLists.txt new file mode 100644 index 00000000..7c1df8e5 --- /dev/null +++ b/examples/lm_algorithm/CMakeLists.txt @@ -0,0 +1,99 @@ +cmake_minimum_required(VERSION 3.15) +project(lm_algorithm CXX) +set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ----------------------------------------------------------------------- +# Strategy 1: find via installed CMake config +# pip install lightsim2grid → lightsim2grid.get_cmake_dir() +# cmake --install → _install/lib/cmake/lightsim2grid_core/ +# Pass -DLIGHTSIM2GRID_CMAKE_DIR= to hint the location, or rely on +# CMAKE_PREFIX_PATH. +# ----------------------------------------------------------------------- +find_package(lightsim2grid_core CONFIG QUIET + HINTS "${LIGHTSIM2GRID_CMAKE_DIR}") + +# ----------------------------------------------------------------------- +# Strategy 2: fall back to source tree (in-repo development builds). +# ----------------------------------------------------------------------- +if(NOT lightsim2grid_core_FOUND) + if(NOT DEFINED LIGHTSIM2GRID_SRC) + set(LIGHTSIM2GRID_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../src/core") + endif() + if(NOT DEFINED Eigen3_INCLUDE) + set(Eigen3_INCLUDE "${CMAKE_CURRENT_SOURCE_DIR}/../../eigen") + endif() + if(NOT EXISTS "${LIGHTSIM2GRID_SRC}/AlgorithmRegistry.hpp") + message(FATAL_ERROR + "lightsim2grid_core not found.\n" + "Either install lightsim2grid and pass:\n" + " -DLIGHTSIM2GRID_CMAKE_DIR=\n" + "(obtain the path via: python -c \"import lightsim2grid; print(lightsim2grid.get_cmake_dir())\")\n" + "or pass -DLIGHTSIM2GRID_SRC= for a source-tree build.") + endif() + add_library(lightsim2grid_core_iface INTERFACE) + target_include_directories(lightsim2grid_core_iface INTERFACE + "${LIGHTSIM2GRID_SRC}" + "${Eigen3_INCLUDE}" + ) + add_library(lightsim2grid::core ALIAS lightsim2grid_core_iface) + message(STATUS "lightsim2grid_core: using source tree at ${LIGHTSIM2GRID_SRC}") +endif() + +# The plugin is a MODULE (loadable at runtime via dlopen/LoadLibrary). +add_library(lm_algorithm MODULE LMNRAlgo_plugin.cpp) +target_link_libraries(lm_algorithm PRIVATE lightsim2grid::core) + +# Match lightsim2grid_core's -march=native / -O3 -- see docs/solver_plugin.rst +# and examples/cmake/MatchLightsim2gridBuildFlags.cmake for why this matters +# (skipping it is a real, silent heap-corruption risk, not just a perf loss). +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/MatchLightsim2gridBuildFlags.cmake") +ls2g_match_core_build_flags(lm_algorithm) + +# ----------------------------------------------------------------------- +# KLUSolver.hpp (pulled in transitively via Solvers.hpp) needs SuiteSparse's +# own headers (cs.h, klu.h, amd.h, colamd.h, btf.h, SuiteSparse_config.h) to +# parse the KLULinearSolver class declaration. Unlike +# examples/dist_slack_algorithm (which reuses an existing `extern template` +# NRAlgo<..., KLULinearSolver, ...> instantiation and links straight against +# it), this plugin instantiates its own fresh +# LMNRAlgo, ...> / NRSystem<..., +# DiagonalEntry> from headers -- but LinearSolverPolicy is a +# thin counting/timing wrapper: its methods just forward into +# KLULinearSolver's own analyze/factorize/refactorize/solve, which remain +# ordinary, already-compiled-into-core symbols. So we still only need KLU's +# *headers* here, never its implementation. Neither an installed +# lightsim2grid_core CMake package nor a plain source-tree checkout exposes +# those on its public include path (a private implementation detail of the +# core .so), so point at the SuiteSparse submodule directly, header-only, as +# a repo-relative fallback. +# ----------------------------------------------------------------------- +if(NOT DEFINED SUITESPARSE_SRC) + set(SUITESPARSE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../SuiteSparse") +endif() +if(EXISTS "${SUITESPARSE_SRC}/KLU/Include/klu.h") + target_include_directories(lm_algorithm PRIVATE + "${SUITESPARSE_SRC}/KLU/Include" + "${SUITESPARSE_SRC}/AMD/Include" + "${SUITESPARSE_SRC}/COLAMD/Include" + "${SUITESPARSE_SRC}/BTF/Include" + "${SUITESPARSE_SRC}/SuiteSparse_config" + "${SUITESPARSE_SRC}/CXSparse/Include" + ) +endif() + +if(WIN32) + # find_package provides the import lib via the IMPORTED target — nothing extra needed. +elseif(UNIX AND NOT APPLE) + if(NOT lightsim2grid_core_FOUND) + # Source-tree mode: no shared library to link against. + # Symbols are resolved from the already-loaded lightsim2grid_cpp.so at runtime. + target_link_options(lm_algorithm PRIVATE -Wl,--allow-shlib-undefined) + endif() + set_target_properties(lm_algorithm PROPERTIES PREFIX "lib" SUFFIX ".so") +elseif(APPLE) + if(NOT lightsim2grid_core_FOUND) + target_link_options(lm_algorithm PRIVATE -undefined dynamic_lookup) + endif() + set_target_properties(lm_algorithm PROPERTIES PREFIX "lib" SUFFIX ".so") +endif() diff --git a/examples/lm_algorithm/DiagonalDamping.hpp b/examples/lm_algorithm/DiagonalDamping.hpp new file mode 100644 index 00000000..8901a507 --- /dev/null +++ b/examples/lm_algorithm/DiagonalDamping.hpp @@ -0,0 +1,94 @@ +// Copyright (c) 2026 +// Experimental plugin, not part of the lightsim2grid core distribution. +// +// DiagonalEntry: a minimal NRSystem extension that reserves one Jacobian +// feature slot per existing unknown column (i, i), so a diagonal load can +// later be added to J without changing its sparsity pattern -- the standard +// trick behind Levenberg-Marquardt / Tikhonov-regularized Newton: solving +// (J + lambda*D) dx = -F instead of J dx = -F to regularize an ill-conditioned +// or near-singular Newton direction (as opposed to Iwamoto/MaxVoltageChange, +// which only ever rescale the *same*, already-bad, direction). +// +// DiagonalEntry claims no rows/cols of its own (register_in is a no-op +// besides remembering the ledger) and its fill_feature_values is a +// deliberate no-op: the damping value is never written through the normal, +// once-per-outer-NR-iteration component protocol path. It is written +// directly, per LM trial, via NRSystem::update_trailing_feature_values (the +// small, generic core addition this example relies on) -- see LMNRAlgo.hpp +// for how. This keeps every LM-specific concept (lambda, accept/reject, the +// Levenberg-vs-Marquardt-scaled delta choice) out of core entirely; core +// only knows "some extension reserved n trailing feature slots that can be +// updated fast." +// +// Must be the LAST extension in the NRSystem pack: its n +// reserved feature handles are exactly the trailing block of NRSystem's +// internal `sink_`/`feature_pos_`, which is what lets +// update_trailing_feature_values locate them with nothing more than a count +// (see with_diagonal_entry below, which enforces this by construction). + +#ifndef LM_DIAGONAL_DAMPING_H +#define LM_DIAGONAL_DAMPING_H + +#include + +namespace ls2g { + +class DiagonalEntry +{ +public: + void update_state( + const Base * /*nr_system_base_ptr*/, + const LSGrid * /*lsgrid_ptr*/, + const EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_weights*/) {} + + void init_topology( + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/) {} + + // Claims no rows/cols of its own -- just remembers the ledger so + // declare_feature_entries (called strictly later, from build_J_sparsity, + // after every component's register_in has run) can read its final size. + void register_in(NRLedger& ledger) { ledger_ptr_ = &ledger; } + + // One feature slot per EXISTING unknown column (i, i). A pure numerical + // regularization term, not tied to any bus/physical quantity -- it needs + // nothing beyond the final unknown count. + void declare_feature_entries(FeatureSink& sink) { + const int n = static_cast(ledger_ptr_->size()); + for (int i = 0; i < n; ++i) sink.add(i, i); + } + + // No-op on purpose: see the file header comment. Damping values are + // written directly by NRSystem::update_trailing_feature_values, not + // through this once-per-outer-iteration path. + void fill_feature_values(FeatureWriter& /*writer*/, const Eigen::Ref& /*Va*/) const {} + + void adjust_mismatch(const Eigen::Ref& /*V_t*/, + const Eigen::Ref& /*dx*/, + Eigen::Ref /*mis*/) const {} + void fill_custom_rows(Eigen::Ref /*res*/, + const Eigen::Ref& /*Va*/, + const Eigen::Ref& /*Vm*/, + const Eigen::Ref& /*dx*/) const {} + void apply_step(const Eigen::Ref& /*dx*/) {} + + void clear() { ledger_ptr_ = nullptr; } + +private: + const NRLedger* ledger_ptr_ = nullptr; +}; + +// ---- Injects DiagonalEntry as the LAST extension of an existing NRSystem ---- +template struct with_diagonal_entry; +template +struct with_diagonal_entry> { + using type = NRSystem; +}; + +} // namespace ls2g + +#endif // LM_DIAGONAL_DAMPING_H diff --git a/examples/lm_algorithm/LMNRAlgo.hpp b/examples/lm_algorithm/LMNRAlgo.hpp new file mode 100644 index 00000000..2270846e --- /dev/null +++ b/examples/lm_algorithm/LMNRAlgo.hpp @@ -0,0 +1,232 @@ +// Copyright (c) 2026 +// Experimental plugin, not part of the lightsim2grid core distribution. +// +// LMNRAlgo: Newton-Raphson with Levenberg-Marquardt diagonal damping, +// (J + lambda*D) dx = -F instead of J dx = -F, lambda adapted by an +// accept/reject loop on the actual (nonlinear) mismatch reduction of each +// trial step. +// +// Motivation: on some real grids, clusters that are high-resistance, +// heavily loaded and short of local generation make the local Jacobian +// sub-block ill-conditioned/near-singular. Plain NR can diverge there, and +// so can the Iwamoto optimal multiplier (ScalingPolicyType::Iwamoto) or the +// MaxVoltageChange clamp (ScalingPolicyType::MaxVoltageChange): both of +// those only ever rescale the *same* Newton direction J^-1 F, which is +// already the problem when J itself is ill-conditioned. LM instead +// regularizes the direction, by damping J's diagonal before solving. +// +// This is deliberately a SIBLING of NRAlgo, not built on top of it: the +// accept/reject inner loop (potentially several factorize+solve+mismatch-eval +// cycles per outer NR iteration) does not fit NRAlgo's single +// solve-per-iteration control flow, nor its RefactorPolicy (which assumes J's +// numeric values are stable enough across iterations to reuse a +// factorization -- false here, since lambda legitimately changes) or +// ScalingPolicy (whose scale() hook only rescales an already-solved Newton +// step post-hoc; it cannot touch the matrix being solved). +// +// The diagonal damping itself is implemented as a NRSystem extension +// (DiagonalEntry, see DiagonalDamping.hpp) plus one small, generic addition +// to core (NRSystem::update_trailing_feature_values) that directly pokes the +// n reserved diagonal J_.valuePtr() positions -- bypassing +// fill_internal_variables()/fill_J() entirely, so a rejected trial costs +// only a numeric refactor + solve + (allocation-free) mismatch evaluation, +// not a full O(nnz) Jacobian rebuild. +// +// Build/load, once compiled (see CMakeLists.txt in this directory): +// import lightsim2grid +// lightsim2grid.load_algorithm_plugin("examples/lm_algorithm/build/liblm_algorithm.so") +// grid.change_algorithm("NR_LM_KLU") + +#ifndef LM_NR_ALGO_H +#define LM_NR_ALGO_H + +#include +#include +#include +#include + +#include "DiagonalDamping.hpp" + +namespace ls2g { + +template +class LMNRAlgo final : public BaseAlgo +{ +public: + using DampedSystem = typename with_diagonal_entry::type; + + LMNRAlgo() noexcept : + BaseAlgo(true), + need_factorize_(true), + lambda_(static_cast(0.)), + lambda_init_(static_cast(1e-6)), + nu_(static_cast(2.)), + lambda_min_(static_cast(0.)), + lambda_max_(static_cast(1e8)), + lambda_eps_(static_cast(1e-12)), + max_trials_(8), + timer_dSbus_(0.), + timer_fillJ_(0.) {} + + ~LMNRAlgo() noexcept override = default; + + // The underlying system still carries Hvdc + VoltageControl (same + // reasoning as NRAlgo: both SingleSlackNRSystem and MultiSlackNRSystem + // include them unconditionally). + static constexpr bool SUPPORTS_HVDC_DROOP = true; + bool supports_hvdc_droop() const noexcept override { return SUPPORTS_HVDC_DROOP; } + static constexpr bool SUPPORTS_REMOTE_VOLTAGE_CONTROL = true; + bool supports_remote_voltage_control() const noexcept override { return SUPPORTS_REMOTE_VOLTAGE_CONTROL; } + + // ----- Jacobian / VoltageControl introspection: forward to _system, same as NRAlgo ----- + Eigen::Ref> get_J() const override { return _system.J(); } + IntVect get_theta_to_J_col_python() const override { return _to_intvect(_system.theta_to_J_col()); } + IntVect get_vm_to_J_col_python() const override { return _to_intvect(_system.vm_to_J_col()); } + IntVect get_q_to_J_col_python() const override { return _to_intvect(_system.q_to_J_col()); } + IntVect get_p_to_J_row_python() const override { return _to_intvect(_system.p_to_J_row()); } + IntVect get_q_to_J_row_python() const override { return _to_intvect(_system.q_to_J_row()); } + IntVect get_p_buses_python() const override { return _to_intvect(_system.p_buses()); } + IntVect get_p_rows_python() const override { return _to_intvect(_system.p_rows()); } + IntVect get_q_buses_python() const override { return _to_intvect(_system.q_buses()); } + IntVect get_q_rows_python() const override { return _to_intvect(_system.q_rows()); } + IntVect get_theta_buses_python() const override { return _to_intvect(_system.theta_buses()); } + IntVect get_theta_cols_python() const override { return _to_intvect(_system.theta_cols()); } + IntVect get_vm_buses_python() const override { return _to_intvect(_system.vm_buses()); } + IntVect get_vm_cols_python() const override { return _to_intvect(_system.vm_cols()); } + RealVect get_controller_q() const override { return _system.controller_q(); } + IntVect get_controller_kind() const override { return _system.controller_kind(); } + IntVect get_controller_elem_id() const override { return _system.controller_elem_id(); } + IntVect get_controller_q_col() const override { return _system.controller_q_col(); } + int get_slack_col() const override { return _system.slack_col(); } + real_type get_slack_absorbed() const override { return _system.slack_absorbed(); } + + TimerJac get_timers_jacobian() const override + { + const LinearSolverStats lsstats = detail::get_stats_impl(_linear_solver, 0); + TimerJac res; + res.timer_Fx_ = timer_Fx_; + res.timer_solve_ = lsstats.timer_solve_; + res.timer_factor_ = lsstats.timer_factor_; + res.timer_refactor_ = lsstats.timer_refactor_; + res.timer_initialize_ = lsstats.timer_initialize_; + res.timer_check_ = timer_check_; + res.timer_dSbus_ = timer_dSbus_; + res.timer_fillJ_ = timer_fillJ_; + res.timer_total_nr_ = timer_total_nr_; + return res; + } + LinearSolverStats get_linear_solver_stats() const override { + return detail::get_stats_impl(_linear_solver, 0); + } + + bool supports_bus_masking() const override { return true; } + void set_masked_buses(const std::vector& solver_bus_ids) override { + _system.set_masked_buses(solver_bus_ids); + } + + // ----- powerflow ------------------------------------------------------------- + + bool compute_pf( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol + ) override; + + void reset() override { + BaseAlgo::reset(); + _system.clear_jacobian(); + need_factorize_ = true; + n_ = -1; + ErrorType reset_status = _linear_solver.reset(); + if (reset_status != ErrorType::NoError) err_ = reset_status; + } + + // ----- Levenberg-Marquardt tunables ------------------------------------------- + real_type get_lambda_init() const { return lambda_init_; } + void set_lambda_init(real_type v) { lambda_init_ = v; } + real_type get_nu() const { return nu_; } + void set_nu(real_type v) { nu_ = v; } + real_type get_lambda_min() const { return lambda_min_; } + void set_lambda_min(real_type v) { lambda_min_ = v; } + real_type get_lambda_max() const { return lambda_max_; } + void set_lambda_max(real_type v) { lambda_max_ = v; } + real_type get_lambda_eps() const { return lambda_eps_; } + void set_lambda_eps(real_type v) { lambda_eps_ = v; } + int get_max_trials() const { return max_trials_; } + void set_max_trials(int v) { max_trials_ = v; } + // Value lambda was left at when the last compute_pf call returned + // (informational only -- inspect it to see how hard the last solve was). + real_type get_last_lambda() const { return lambda_; } + + // int_params = [max_trials_] + // real_params = [lambda_init_, nu_, lambda_min_, lambda_max_, lambda_eps_] + AlgoConfig get_config() const override { + AlgoConfig cfg; + cfg.int_params = { max_trials_ }; + cfg.real_params = { static_cast(lambda_init_), + static_cast(nu_), + static_cast(lambda_min_), + static_cast(lambda_max_), + static_cast(lambda_eps_) }; + return cfg; + } + void set_config(const AlgoConfig& cfg) override { + if (cfg.int_params.size() < 1) throw std::runtime_error("LMNRAlgo::set_config: int_params must have at least 1 element"); + if (cfg.real_params.size() < 5) throw std::runtime_error("LMNRAlgo::set_config: real_params must have at least 5 elements"); + max_trials_ = cfg.int_params[0]; + lambda_init_ = static_cast(cfg.real_params[0]); + nu_ = static_cast(cfg.real_params[1]); + lambda_min_ = static_cast(cfg.real_params[2]); + lambda_max_ = static_cast(cfg.real_params[3]); + lambda_eps_ = static_cast(cfg.real_params[4]); + } + +protected: + void reset_timer() override { + BaseAlgo::reset_timer(); + detail::reset_stats_timers_impl(_linear_solver, 0); + timer_dSbus_ = 0.; + timer_fillJ_ = 0.; + _system.reset_timers(); + } + +private: + static IntVect _to_intvect(const std::vector& v) { + return Eigen::Map(v.data(), static_cast(v.size())); + } + + LinearSolver _linear_solver; + DampedSystem _system; + + bool need_factorize_; + + // Levenberg-Marquardt state/tunables + real_type lambda_; // current damping factor, persists across outer NR iterations + real_type lambda_init_; // value lambda_ is reset to at the start of each compute_pf call + real_type nu_; // growth factor on rejection + real_type lambda_min_; // floor applied when easing off after an accepted step + real_type lambda_max_; // ceiling + real_type lambda_eps_; // additive floor on rejection, so lambda_==0 is not a fixed point + int max_trials_; // per outer-iteration cap on reject-and-retry + + double timer_dSbus_; + double timer_fillJ_; + + // No copy + LMNRAlgo(const LMNRAlgo&) = delete; + LMNRAlgo(LMNRAlgo&&) = delete; + LMNRAlgo& operator=(LMNRAlgo&&) = delete; + LMNRAlgo& operator=(const LMNRAlgo&) = delete; +}; + +#include "LMNRAlgo.tpp" + +} // namespace ls2g + +#endif // LM_NR_ALGO_H diff --git a/examples/lm_algorithm/LMNRAlgo.tpp b/examples/lm_algorithm/LMNRAlgo.tpp new file mode 100644 index 00000000..266ed065 --- /dev/null +++ b/examples/lm_algorithm/LMNRAlgo.tpp @@ -0,0 +1,144 @@ +// Copyright (c) 2026 +// Experimental plugin, not part of the lightsim2grid core distribution. + +template +bool LMNRAlgo::compute_pf( + const EigenRefConstCplxSpMat & Ybus, + const Eigen::Ref & V, + const Eigen::Ref & Sbus, + const Eigen::Ref & slack_ids, + const Eigen::Ref & slack_weights, + const Eigen::Ref & pv, + const Eigen::Ref & pq, + int max_iter, + real_type tol) +{ + if (!is_linear_solver_valid()) return false; + + reset_timer(); + err_ = ErrorType::NoError; + auto timer = CustTimer(); + + // Determine whether topology rebuild is required (same logic as NRAlgo). + const bool need_rebuild = ( + need_factorize_ || + _solver_control.need_reset_solver() || + _solver_control.has_dimension_changed() || + _solver_control.has_slack_participate_changed() || + _solver_control.ybus_change_sparsity_pattern() || + _solver_control.has_ybus_some_coeffs_zero() || + _solver_control.need_recompute_ybus() || + _solver_control.has_pv_changed() || + _solver_control.has_pq_changed() + ); + + if (need_rebuild) { + ErrorType rs = _linear_solver.reset(); + if (rs != ErrorType::NoError) err_ = rs; + } + if (!((err_ == ErrorType::NotInitError) || (err_ == ErrorType::NoError))) { + timer_total_nr_ += timer.duration(); + return false; + } + + _system.update_state(BaseAlgo::lsgrid_ptr_, Ybus, V, Sbus, slack_weights); + if (need_rebuild) _system.init_topology(slack_ids, slack_weights, pv, pq); + + bool need_init = need_rebuild; + if (need_rebuild) { + _system.build_J_sparsity(); + n_ = static_cast(_system.J().cols()); + } + + RealVect F = _system.mismatch(); + bool converged = _check_for_convergence(F, tol); + nr_iter_ = 0; + bool res = true; + lambda_ = lambda_init_; + + while ((!converged) && (nr_iter_ < max_iter)) { + ++nr_iter_; + + // Baseline (undamped) Jacobian for this outer iteration, once -- LM + // always rebuilds it fresh (lambda_ changes every outer iteration, + // so there is no stable-across-iterations J to reuse the way + // NRAlgo's RefactorPolicy does). + _system.fill_internal_variables(); + _system.fill_J(); + + if (need_init) { + err_ = _linear_solver.analyze(_system.J()); + if (err_ == ErrorType::NoError) err_ = _linear_solver.factorize(_system.J()); + need_init = false; + need_factorize_ = false; + } else { + err_ = _linear_solver.refactorize(_system.J()); + } + if (err_ != ErrorType::NoError) { res = false; break; } + + const real_type F_sq = F.squaredNorm(); + real_type lambda_applied = static_cast(0.); // matches the just-built undamped baseline + bool accepted = false; + int trial = 0; + RealVect dx; + + while (!accepted && trial < max_trials_) { + ++trial; + + const real_type delta = lambda_ - lambda_applied; + if (delta != static_cast(0.)) { + // Fast path: only the n diagonal positions move, not the + // whole Jacobian -- see NRSystem::update_trailing_feature_values. + _system.update_trailing_feature_values(n_, RealVect::Constant(n_, delta)); + lambda_applied = lambda_; + err_ = _linear_solver.refactorize(_system.J()); + if (err_ != ErrorType::NoError) break; + } + + dx = F; // solve overwrites in place (mismatch, negated convention) + err_ = _linear_solver.solve(dx); + if (err_ != ErrorType::NoError) break; + + // Evaluate the TRUE nonlinear residual at the trial step without + // mutating _system's V/Va/Vm -- only commit (apply_step) once accepted. + const real_type trial_sq = _system.mismatch_sq_norm_at(dx); + + if (trial_sq < F_sq) { + _system.apply_step(dx); + accepted = true; + lambda_ = std::max(lambda_ / nu_, lambda_min_); + } else { + lambda_ = std::min(lambda_ * nu_ + lambda_eps_, lambda_max_); + } + } + if (err_ != ErrorType::NoError) { res = false; break; } + if (!accepted) { + // Exhausted max_trials_ without finding a step that reduces the + // mismatch, even at lambda_max_: treat like NRAlgo's "linear + // solver failed" bailout. + err_ = ErrorType::SingularMatrix; + res = false; + break; + } + + F = _system.mismatch(); + if (!F.allFinite()) { err_ = ErrorType::InifiniteValue; res = false; break; } + converged = _check_for_convergence(F, tol); + } + + if (!converged && res) { + if (err_ == ErrorType::NoError) err_ = ErrorType::TooManyIterations; + res = false; + } + + V_ = _system.V(); + Vm_ = _system.Vm(); + Va_ = _system.Va(); + + timer_dSbus_ += _system.timer_dSbus(); + timer_fillJ_ += _system.timer_fillJ(); + _system.reset_timers(); + timer_total_nr_ += timer.duration(); + + return res; +} diff --git a/examples/lm_algorithm/LMNRAlgo_plugin.cpp b/examples/lm_algorithm/LMNRAlgo_plugin.cpp new file mode 100644 index 00000000..1132329a --- /dev/null +++ b/examples/lm_algorithm/LMNRAlgo_plugin.cpp @@ -0,0 +1,42 @@ +// LMNRAlgo plugin registration. +// +// Registers two names with the C++ AlgorithmRegistry at dlopen() time (via a +// static AlgorithmRegistrar in an anonymous namespace -- same mechanism as +// examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp and +// examples/external_algorithm/DummyExternalAlgo.cpp): +// "NR_LM_SparseLU" -> LMNRAlgo, SingleSlackNRSystem> +// "NR_LM_KLU" -> LMNRAlgo, SingleSlackNRSystem> (only if KLU is available) +// +// Single-slack only for this first cut; LMNRAlgo is generic over its +// NRSystem template parameter, so registering MultiSlackNRSystem variants +// later is a one-line addition. +// +// Build (after pip install lightsim2grid, or from a source-tree checkout): +// LS2G_CMAKE=$(python -c "import lightsim2grid; print(lightsim2grid.get_cmake_dir())") +// cmake -S examples/lm_algorithm -B examples/lm_algorithm/build -DLIGHTSIM2GRID_CMAKE_DIR="$LS2G_CMAKE" +// cmake --build examples/lm_algorithm/build +// +// Python usage: +// import lightsim2grid +// lightsim2grid.load_algorithm_plugin("examples/lm_algorithm/build/liblm_algorithm.so") +// grid.change_algorithm("NR_LM_KLU") + +#include + +#include "LMNRAlgo.hpp" + +namespace { + ls2g::AlgorithmRegistrar _lm_sparselu_registrar( + "NR_LM_SparseLU", + []{ return std::unique_ptr( + new ls2g::LMNRAlgo, ls2g::SingleSlackNRSystem>()); } + ); + +#ifdef KLU_SOLVER_AVAILABLE + ls2g::AlgorithmRegistrar _lm_klu_registrar( + "NR_LM_KLU", + []{ return std::unique_ptr( + new ls2g::LMNRAlgo, ls2g::SingleSlackNRSystem>()); } + ); +#endif +} diff --git a/examples/lm_algorithm/test_plugin.py b/examples/lm_algorithm/test_plugin.py new file mode 100644 index 00000000..b43e4dd9 --- /dev/null +++ b/examples/lm_algorithm/test_plugin.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Smoke-test for the LMNRAlgo (Levenberg-Marquardt Newton-Raphson) plugin. + +Build the plugin first: + cd examples/lm_algorithm + mkdir build && cd build + cmake .. + make (or: cmake --build . --config Release on Windows) + +The plugin's CMakeLists.txt automatically matches whatever -march=native / +-O3 the installed lightsim2grid_core was built with (see +examples/cmake/MatchLightsim2gridBuildFlags.cmake and +docs/solver_plugin.rst) -- this is required, not just a performance nicety: +lightsim2grid_core and this plugin are separate shared libraries, and a +mismatched -march=native changes Eigen's alignment assumptions, so an Eigen +object allocated in one and freed in the other silently corrupts the heap. +Nothing to do here unless you're hand-rolling your own build system instead +of the provided CMakeLists.txt. + +Then run: + python test_plugin.py + +This only checks that the plugin loads and registers correctly (same scope +as examples/external_algorithm/test_plugin.py and +lightsim2grid/tests/test_solver_registry.py::TestPluginLoading) -- it does +NOT run a real power flow (that needs a fully configured grid: buses, lines, +loads, generators). To actually exercise the Levenberg-Marquardt iteration on +a real case, build a LightSimBackend on a grid2op environment and call +grid._grid.change_algorithm("NR_LM_KLU") (or "NR_LM_SparseLU") before +runpf(). +""" +import platform +import pathlib + +import lightsim2grid +from lightsim2grid.lightsim2grid_cpp import LSGrid, AlgorithmType + + +def find_plugin(): + build = pathlib.Path(__file__).parent / "build" + if platform.system() == "Windows": + candidates = [ + build / "Release" / "lm_algorithm.dll", + build / "lm_algorithm.dll", + ] + else: + candidates = [build / "liblm_algorithm.so"] + for p in candidates: + if p.exists(): + return str(p) + raise FileNotFoundError( + f"Plugin not found (tried {[str(c) for c in candidates]}). " + "Build it first (see CMakeLists.txt)." + ) + + +def _make_grid(): + """Minimal LSGrid, enough to register/switch solvers (not to run a real pf).""" + gm = LSGrid() + gm.set_sn_mva(100.0) + gm.set_init_vm_pu(1.0) + return gm + + +# ------------------------------------------------------------------ +# Load the plugin +# ------------------------------------------------------------------ +plugin_path = find_plugin() +lightsim2grid.load_algorithm_plugin(plugin_path) +print("Plugin loaded successfully.") + +# ------------------------------------------------------------------ +# Verify registration +# ------------------------------------------------------------------ +gm = _make_grid() +names = gm.available_solver_names() +assert "NR_LM_SparseLU" in names, f"NR_LM_SparseLU not in {names}" +print(f"Registered solvers: {sorted(names)}") + +# ------------------------------------------------------------------ +# Change to the plugin solver(s) +# ------------------------------------------------------------------ +gm.change_algorithm("NR_LM_SparseLU") +assert gm.get_algo_type() == AlgorithmType.Custom, \ + f"Expected AlgorithmType.Custom, got {gm.get_algo_type()}" +print("change_algorithm('NR_LM_SparseLU') OK — solver type is Custom as expected.") + +if "NR_LM_KLU" in names: + gm2 = _make_grid() + gm2.change_algorithm("NR_LM_KLU") + assert gm2.get_algo_type() == AlgorithmType.Custom, \ + f"Expected AlgorithmType.Custom, got {gm2.get_algo_type()}" + print("change_algorithm('NR_LM_KLU') OK — solver type is Custom as expected.") +else: + print("NR_LM_KLU not registered (KLU not available in this build) — skipped.") + +print("All checks passed.") diff --git a/src/core/powerflow_algorithm/NRSystem.hpp b/src/core/powerflow_algorithm/NRSystem.hpp index ddc7302c..794f320d 100644 --- a/src/core/powerflow_algorithm/NRSystem.hpp +++ b/src/core/powerflow_algorithm/NRSystem.hpp @@ -904,6 +904,19 @@ class NRSystem void apply_step(const Eigen::Ref& dx); real_type mismatch_sq_norm_at(const Eigen::Ref& dx) const; + // Direct, allocation-light update of the LAST-registered extension's + // `count` feature entries, identified purely by count and position (the + // caller -- e.g. an extension whose declare_feature_entries reserved + // exactly `count` trailing slots -- is responsible for knowing its own + // count). Adds deltas[i] into the J_.valuePtr() position already + // resolved (in build_J_sparsity) for feature handle + // `sink_.size() - count + i`, bypassing fill_internal_variables()/ + // fill_J() entirely. For extensions whose value needs to change faster + // than the rest of J (e.g. Levenberg-Marquardt diagonal damping; see + // examples/lm_algorithm/). Not diagonal- or LM-specific: it only knows + // "update these already-declared feature positions." + void update_trailing_feature_values(int count, const Eigen::Ref& deltas); + // ----- Housekeeping ---------------------------------------------------------- void clear_jacobian() { diff --git a/src/core/powerflow_algorithm/NRSystem.tpp b/src/core/powerflow_algorithm/NRSystem.tpp index ff4778e6..f956e286 100644 --- a/src/core/powerflow_algorithm/NRSystem.tpp +++ b/src/core/powerflow_algorithm/NRSystem.tpp @@ -176,6 +176,16 @@ inline void NRSystem::fill_J() timer_fillJ_ += timer.duration(); } +template +inline void NRSystem::update_trailing_feature_values(int count, const Eigen::Ref& deltas) +{ + assert(deltas.size() == count); + const int first_h = static_cast(sink_.size()) - count; + real_type* J_values = J_.valuePtr(); + for (int i = 0; i < count; ++i) + J_values[feature_pos_[first_h + i]] += deltas(i); +} + template inline void NRSystem::fill_internal_variables() { From a65beacd78c8ba1a980fcc02b08fc64a6cf01d92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:03:51 +0000 Subject: [PATCH 142/166] core: const-correctness and copy-elimination low-hanging fruit Follow-up to the const/const-ref sweep (#151), picking up member functions and return types the earlier pass missed. All changes are read-only markers or copy-enabling; no behavioural change. Verified: src/core builds and the C++ unit test suite passes (1201 assertions, 49 cases). const on read-only member functions (lets them be called on a const object / through a const ref, and documents intent): - LSGrid: get_bus1_powerline, get_bus2_powerline, get_bus1_trafo, get_bus2_trafo, get_Ybus_solver, get_dcYbus_solver - PandaPowerConverter: get_line_param, get_line_param_legacy, get_trafo_param_pp2, get_trafo_param_pp3, _check_init (whole class is read-only apart from the two setters) - BaseAlgo::is_linear_solver_valid (reads err_ only) drop top-level const on by-value return types (readability-const-return-type): returning `const T` by value blocks move construction / move assignment at the call site (e.g. the pybind copy-to-Python path) and buys nothing. Applied to the freshly-computed getters in LSGrid: get_Ybus, get_dcYbus, get_Sbus, get_dcSbus, get_pv[_numpy], get_pq[_numpy], get_slack_ids[_numpy], get_slack_ids_dc[_numpy], get_slack_weights, get_V, get_Va, get_Vm. misc: std::endl -> '\n' in GeneratorContainer::cout_v (avoid the per-line stream flush; performance-avoid-endl). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LY1xhzf8cUjqFoNNj7y2Gc Signed-off-by: Claude --- src/core/DataConverter.cpp | 10 ++-- src/core/DataConverter.hpp | 10 ++-- src/core/LSGrid.hpp | 46 +++++++++---------- .../element_container/GeneratorContainer.hpp | 2 +- src/core/powerflow_algorithm/BaseAlgo.hpp | 2 +- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/core/DataConverter.cpp b/src/core/DataConverter.cpp index df55f230..9f7d21d8 100644 --- a/src/core/DataConverter.cpp +++ b/src/core/DataConverter.cpp @@ -13,7 +13,7 @@ namespace ls2g { -void PandaPowerConverter::_check_init(){ +void PandaPowerConverter::_check_init() const { if(sn_mva_ <= 0.){ throw std::runtime_error("PandaPowerConverter::_check_init: sn_mva has not been initialized"); } @@ -35,7 +35,7 @@ std::tuple & trafo_vkr_percent, const Eigen::Ref & trafo_sn_trafo_mva, const Eigen::Ref & trafo_pfe_kw, - const Eigen::Ref & trafo_i0_pct) + const Eigen::Ref & trafo_i0_pct) const { //TODO consistency: move this class outside of here _check_init(); @@ -134,7 +134,7 @@ std::tuple & branch_g, const Eigen::Ref & branch_c, const Eigen::Ref & branch_from_kv, - const Eigen::Ref & /*branch_to_kv*/) + const Eigen::Ref & /*branch_to_kv*/) const { //TODO does not use c at the moment! _check_init(); @@ -166,7 +166,7 @@ std::tuple & branch_g, const Eigen::Ref & branch_c, const Eigen::Ref & branch_from_kv, - const Eigen::Ref & /*branch_to_kv*/) + const Eigen::Ref & /*branch_to_kv*/) const { _check_init(); const int nb_line = static_cast(branch_r.size()); @@ -205,7 +205,7 @@ std::tuple & trafo_sn_mva, const Eigen::Ref & trafo_pfe_kw, const Eigen::Ref & trafo_i0_pct, - bool trafo_model_is_t) + bool trafo_model_is_t) const { //TODO consistency: move this class outside of here _check_init(); diff --git a/src/core/DataConverter.hpp b/src/core/DataConverter.hpp index 0765ce0f..cc824fec 100644 --- a/src/core/DataConverter.hpp +++ b/src/core/DataConverter.hpp @@ -52,7 +52,7 @@ class LS2G_API PandaPowerConverter final : public BaseConstants const Eigen::Ref & trafo_sn_trafo_mva, const Eigen::Ref & trafo_pfe_kw, const Eigen::Ref & trafo_i0_pct, - bool trafo_model_is_t); + bool trafo_model_is_t) const; /** This converts the trafo from pandapower to r, x and h (pair unit) (for legacy (<= 2.14.somthing) pandapower) @@ -70,7 +70,7 @@ class LS2G_API PandaPowerConverter final : public BaseConstants const Eigen::Ref & trafo_vkr_percent, const Eigen::Ref & trafo_sn_trafo_mva, const Eigen::Ref & trafo_pfe_kw, - const Eigen::Ref & trafo_i0_pct); + const Eigen::Ref & trafo_i0_pct) const; /** @@ -84,7 +84,7 @@ class LS2G_API PandaPowerConverter final : public BaseConstants const Eigen::Ref & branch_g, const Eigen::Ref & branch_c, const Eigen::Ref & branch_from_kv, - const Eigen::Ref & branch_to_kv); + const Eigen::Ref & branch_to_kv) const; /** pair unit properly the powerlines (for most recent pandapower) **/ @@ -97,14 +97,14 @@ class LS2G_API PandaPowerConverter final : public BaseConstants const Eigen::Ref & branch_g, const Eigen::Ref & branch_c, const Eigen::Ref & branch_from_kv, - const Eigen::Ref & branch_to_kv); + const Eigen::Ref & branch_to_kv) const; private: real_type sn_mva_; real_type f_hz_; private: - void _check_init(); + void _check_init() const; }; // TODO have a converter from ppc ! diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index a1cd465c..0189d89b 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -782,8 +782,8 @@ class LS2G_API LSGrid final void change_bus2_powerline_python(int powerline_id, int new_gridmodel_bus_id) { change_bus2_powerline(powerline_id, GridModelBusId(new_gridmodel_bus_id)); } - int get_bus1_powerline(int powerline_id) {return powerlines_.get_bus_side_1(powerline_id).cast_int();} - int get_bus2_powerline(int powerline_id) {return powerlines_.get_bus_side_2(powerline_id).cast_int();} + int get_bus1_powerline(int powerline_id) const {return powerlines_.get_bus_side_1(powerline_id).cast_int();} + int get_bus2_powerline(int powerline_id) const {return powerlines_.get_bus_side_2(powerline_id).cast_int();} //deactivate trafo void deactivate_trafo(int trafo_id) {trafos_.deactivate(trafo_id, algo_controler_); } @@ -822,10 +822,10 @@ class LS2G_API LSGrid final void change_bus2_trafo_python(int trafo_id, int new_gridmodel_bus_id) { change_bus2_trafo(trafo_id, GridModelBusId(new_gridmodel_bus_id)); } - int get_bus1_trafo(int trafo_id) { + int get_bus1_trafo(int trafo_id) const { return trafos_.get_bus_side_1(trafo_id).cast_int(); } - int get_bus2_trafo(int trafo_id) { + int get_bus2_trafo(int trafo_id) const { return trafos_.get_bus_side_2(trafo_id).cast_int(); } void change_ratio_trafo(int trafo_id, real_type new_ratio){ @@ -1153,7 +1153,7 @@ class LS2G_API LSGrid final * * @return Eigen::SparseMatrix */ - Eigen::SparseMatrix get_Ybus_solver(){ + Eigen::SparseMatrix get_Ybus_solver() const { return Ybus_ac_; // This is copied to python } @@ -1171,7 +1171,7 @@ class LS2G_API LSGrid final * * @return Eigen::SparseMatrix */ - Eigen::SparseMatrix get_dcYbus_solver(){ + Eigen::SparseMatrix get_dcYbus_solver() const { return Bbus_dc_; // This is copied to python (DC admittance matrix is real) } @@ -1227,7 +1227,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - [[nodiscard]] const Eigen::SparseMatrix get_Ybus() const { + [[nodiscard]] Eigen::SparseMatrix get_Ybus() const { return _relabel_matrix(Ybus_ac_, id_ac_solver_to_me_); } @@ -1247,7 +1247,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - [[nodiscard]] const Eigen::SparseMatrix get_dcYbus() const { + [[nodiscard]] Eigen::SparseMatrix get_dcYbus() const { return _relabel_matrix(Bbus_dc_, id_dc_solver_to_me_); } @@ -1265,7 +1265,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - [[nodiscard]] const CplxVect get_Sbus() const { + [[nodiscard]] CplxVect get_Sbus() const { return _relabel_vector(acSbus_, id_ac_solver_to_me_); } @@ -1283,7 +1283,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - [[nodiscard]] const RealVect get_dcSbus() const { + [[nodiscard]] RealVect get_dcSbus() const { return _relabel_vector(dcPbus_, id_dc_solver_to_me_); } @@ -1315,12 +1315,12 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - [[nodiscard]] const GlobalBusIdVect get_pv() const{ + [[nodiscard]] GlobalBusIdVect get_pv() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector2(get_pv_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector2(get_pv_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_pv: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); } - [[nodiscard]] const IntVect get_pv_numpy() const{ + [[nodiscard]] IntVect get_pv_numpy() const{ return get_pv().as_eigen(); // was _to_intvect() } @@ -1352,12 +1352,12 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - [[nodiscard]] const GlobalBusIdVect get_pq() const{ + [[nodiscard]] GlobalBusIdVect get_pq() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector2(get_pq_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector2(get_pq_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_pq: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); } - [[nodiscard]] const IntVect get_pq_numpy() const{ + [[nodiscard]] IntVect get_pq_numpy() const{ return get_pq().as_eigen(); // was _to_intvect() } @@ -1385,10 +1385,10 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - [[nodiscard]] const GlobalBusIdVect get_slack_ids() const { + [[nodiscard]] GlobalBusIdVect get_slack_ids() const { return _relabel_vector2(slack_bus_id_ac_solver_, id_ac_solver_to_me_); } - [[nodiscard]] const IntVect get_slack_ids_numpy() const { + [[nodiscard]] IntVect get_slack_ids_numpy() const { return get_slack_ids().as_eigen(); // was _to_intvect() } @@ -1409,9 +1409,9 @@ class LS2G_API LSGrid final return slack_bus_id_dc_solver_.as_eigen(); // was _to_intvect() } - [[nodiscard]] const GlobalBusIdVect get_slack_ids_dc() const{ + [[nodiscard]] GlobalBusIdVect get_slack_ids_dc() const{ return _relabel_vector2( - slack_bus_id_dc_solver_, + slack_bus_id_dc_solver_, id_dc_solver_to_me_); } /** @@ -1419,7 +1419,7 @@ class LS2G_API LSGrid final * * @return const Eigen::VectorXi */ - [[nodiscard]] const IntVect get_slack_ids_dc_numpy() const{ + [[nodiscard]] IntVect get_slack_ids_dc_numpy() const{ return get_slack_ids_dc().as_eigen(); // was _to_intvect() } @@ -1441,7 +1441,7 @@ class LS2G_API LSGrid final * * @return Eigen::Ref */ - [[nodiscard]] const RealVect get_slack_weights() const{ + [[nodiscard]] RealVect get_slack_weights() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_slack_weights_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_slack_weights_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_slack_weights: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); @@ -1461,7 +1461,7 @@ class LS2G_API LSGrid final * * @return CplxVect */ - [[nodiscard]] const CplxVect get_V() const{ + [[nodiscard]] CplxVect get_V() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_V_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_V_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_V: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); @@ -1481,7 +1481,7 @@ class LS2G_API LSGrid final * * @return const RealVect */ - [[nodiscard]] const RealVect get_Va() const{ + [[nodiscard]] RealVect get_Va() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_Va_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_Va_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_Va: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); @@ -1501,7 +1501,7 @@ class LS2G_API LSGrid final * * @return const RealVect */ - [[nodiscard]] const RealVect get_Vm() const{ + [[nodiscard]] RealVect get_Vm() const{ if(id_ac_solver_to_me_.size() > 0) return _relabel_vector(get_Vm_solver(), id_ac_solver_to_me_); if(id_dc_solver_to_me_.size() > 0) return _relabel_vector(get_Vm_solver(), id_dc_solver_to_me_); throw std::runtime_error("LSGrid::get_Vm: impossible to retrieve the `gridmodel` bus label as it appears no powerflow has run."); diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index ef3004cb..ffb4479c 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -281,7 +281,7 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter void cout_v(){ for(const auto & el : target_vm_pu_){ - std::cout << "V " << el << std::endl; + std::cout << "V " << el << '\n'; } } diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index e3422ef7..3964583a 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -426,7 +426,7 @@ class LS2G_API BaseAlgo : public BaseConstants timer_total_nr_ = 0.; } - bool is_linear_solver_valid(){ + bool is_linear_solver_valid() const { // bool res = true; // if((err_ == ErrorType::NotInitError) || (err_ == ErrorType::LicenseError)) res = false; // cannot use a non intialize solver // return res; From 0a33d4decddb459e52521cfec7273ce1d1f86095 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 20:22:06 +0000 Subject: [PATCH 143/166] Make solver-plugin registration exception-safe at load time Plugins previously self-registered from a static AlgorithmRegistrar constructor firing during dlopen(). Its two failure modes -- an ABI-tag mismatch and a duplicate solver name -- throw, and an exception escaping a static constructor while the dynamic loader runs its init routines unwinds into C loader frames with no handler and calls std::terminate(), aborting the interpreter uncatchably (ctypes cannot translate it either). Registration now runs from an explicitly-called entry point: - Plugins write a void(PluginRegistrar&) function and expose it with the new LS2G_PLUGIN_ENTRY macro, which generates an exported ls2g_register_plugin symbol (LS2G_PLUGIN_EXPORT handles dllexport/visibility). - load_algorithm_plugin() is now a pybind11 C++ function that dlopen/dlsym's the plugin and calls the entry point inside try/catch, so failures surface as catchable Python RuntimeError instead of terminating the process. A rejected plugin is dlclose'd. - AlgorithmRegistry::register_all() commits a whole batch atomically (copy + noexcept swap): ABI-checked once, all-or-nothing, strong exception guarantee, so a plugin exposing several solvers can never half-register. - The entry-point helper is noexcept and copies diagnostics into the caller's buffer with an explicit, overflow-proof bounded write (null/zero-length safe). Tests: - src/tests/test_plugin_registration.cpp (Catch2, runs under valgrind in the cpp_unit_tests workflow): atomic rollback, ABI rejection, entry-point return codes vs exceptions, and short/null error-buffer handling. - lightsim2grid/tests/test_plugin_registration.py + a new plugin_registration CI workflow: build the example plugin against the installed package and load it for real, asserting bad loads raise (and do not crash) the interpreter. The three example plugins and docs/solver_plugin.rst are updated to the new mechanism; load_algorithm_plugin() now raises RuntimeError (not OSError) and rejects loading a plugin whose name is already registered (incl. loading the same plugin twice). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J97bpvJk9BS1qXnGPGYnGp Signed-off-by: Claude --- .github/workflows/plugin_registration.yml | 57 ++++++ CHANGELOG.rst | 16 ++ docs/solver_plugin.rst | 101 +++++++---- .../NRAlgoDistSlack_plugin.cpp | 19 +- .../external_algorithm/DummyExternalAlgo.cpp | 29 +-- examples/lm_algorithm/LMNRAlgo_plugin.cpp | 22 +-- lightsim2grid/__init__.py | 27 ++- .../tests/test_plugin_registration.py | 128 ++++++++++++++ src/bindings/python/binding_module.cpp | 73 ++++++++ src/core/AlgorithmRegistry.cpp | 30 ++++ src/core/AlgorithmRegistry.hpp | 124 +++++++++++-- src/core/ls2g_api.hpp | 16 ++ src/tests/CMakeLists.txt | 1 + src/tests/test_plugin_registration.cpp | 166 ++++++++++++++++++ 14 files changed, 721 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/plugin_registration.yml create mode 100644 lightsim2grid/tests/test_plugin_registration.py create mode 100644 src/tests/test_plugin_registration.cpp diff --git a/.github/workflows/plugin_registration.yml b/.github/workflows/plugin_registration.yml new file mode 100644 index 00000000..ac27991d --- /dev/null +++ b/.github/workflows/plugin_registration.yml @@ -0,0 +1,57 @@ +# Builds lightsim2grid, builds the example out-of-tree solver plugin against +# the *installed* package (so the plugin picks up the exact Eigen / build flags +# lightsim2grid_core was compiled with -- the ABI-match the plugin mechanism +# requires), then runs the end-to-end load_algorithm_plugin() tests. Those +# assert that a bad plugin load raises a catchable Python exception instead of +# aborting the interpreter -- something the C++ Catch2 suite (cpp_unit_tests) +# cannot cover because it never crosses the real dlopen/ctypes boundary. + +name: Plugin registration + +on: + push: + branches: + - '**' + tags: + - 'v*.*.*' + +jobs: + plugin_registration: + name: build + load an out-of-tree solver plugin + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements_compile_ci.txt + + - name: Build and install lightsim2grid + run: python -m pip install . -v + + - name: Build the example solver plugin (against the installed package) + run: | + LS2G_CMAKE=$(python -c "import lightsim2grid; print(lightsim2grid.get_cmake_dir())") + echo "lightsim2grid cmake dir: $LS2G_CMAKE" + cmake -S examples/external_algorithm -B examples/external_algorithm/build \ + -DCMAKE_BUILD_TYPE=Release -DLIGHTSIM2GRID_CMAKE_DIR="$LS2G_CMAKE" + cmake --build examples/external_algorithm/build -j"$(nproc)" + + - name: Run plugin-registration tests + # Run from a scratch dir (via unittest, not a file-path pytest run) so + # `import lightsim2grid` resolves to the *installed* package and the + # source-tree package -- which has no compiled extension -- cannot + # shadow it on sys.path. + working-directory: ${{ runner.temp }} + env: + LS2G_TEST_PLUGIN: ${{ github.workspace }}/examples/external_algorithm/build/libdummy_solver.so + run: python -m unittest -v lightsim2grid.tests.test_plugin_registration diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f8dca542..5d20cf17 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -113,6 +113,22 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding [0.14.0] 2026-xx-yy --------------------- +- [BREAKING] For plugin developers (C++ side): solver plugins no longer self-register + from a static ``AlgorithmRegistrar`` constructor firing during ``dlopen``. Instead a + plugin writes a ``void(ls2g::PluginRegistrar&)`` registration function and exposes it + with the new ``LS2G_PLUGIN_ENTRY(...)`` macro, which generates the exported + ``ls2g_register_plugin`` entry point that ``load_algorithm_plugin()`` calls explicitly + after loading the library. See ``examples/external_algorithm/`` and ``docs/solver_plugin.rst``. +- [FIXED] ``load_algorithm_plugin()`` no longer risks aborting the interpreter: previously + a registration failure (ABI mismatch, or a solver name already registered) threw a C++ + exception out of a static constructor running inside ``dlopen``, which is uncatchable and + calls ``std::terminate()``. Registration now runs from an explicitly-called entry point + wrapped in ``try/catch`` on the C++ side, so every failure surfaces as a catchable Python + ``RuntimeError``. Plugin registration is also atomic (a plugin exposing several solvers + either registers all of them or none) and the loader unloads a rejected plugin. +- [BREAKING] ``lightsim2grid.load_algorithm_plugin()`` now raises ``RuntimeError`` (instead + of ``OSError``) on failure, and rejects loading a plugin whose solver name is already + registered — which includes loading the same plugin twice. - [BREAKING] drop python 3.8 support (end of life end back in 2024) - [BREAKING] `lightsim2grid.SolverType` enum is no more accessible. You can migrate (for now, deprecation pending) to `from lightsim2grid.solver import SolverType` diff --git a/docs/solver_plugin.rst b/docs/solver_plugin.rst index edba0dea..4b9b1f5f 100644 --- a/docs/solver_plugin.rst +++ b/docs/solver_plugin.rst @@ -21,25 +21,36 @@ All solvers are stored in a process-wide singleton called ``AlgorithmRegistry``. On startup the built-in algorithm (NR_SparseLU, NR_KLU, GaussSeidel, DC_KLU, …) are registered. -A plugin library extends this table at -``dlopen``/``LoadLibrary`` time by placing a static ``SolverRegistrar`` -object in one of its translation units. The registrar's constructor fires -automatically when the library is mapped into the process, calling -``SolverRegistry::instance().register_solver(name, factory)`` before any -Python code can observe the new library. +A plugin library extends this table through an exported entry point named +``ls2g_register_plugin``. You do not write that function by hand: the +``LS2G_PLUGIN_ENTRY`` macro generates it from a small registration function you +supply. When ``load_algorithm_plugin()`` loads the library it looks the entry +point up (``dlsym``/``GetProcAddress``) and calls it, staging your solvers and +committing them to the registry atomically. + +This deliberately does **not** register from a static constructor firing during +``dlopen``. Registration can fail — an ABI mismatch, or a solver name that is +already taken — and an exception thrown out of a static constructor while the +dynamic loader is running its init routines cannot be caught: it unwinds into C +loader frames and calls ``std::terminate()``, aborting the interpreter. Running +registration from an explicitly-called entry point instead means every failure +comes back as a normal, catchable Python exception. The lookup flow is: .. code-block:: text Python: lightsim2grid.load_algorithm_plugin("path/to/plugin.so") - └─ ctypes.CDLL(..., RTLD_GLOBAL) # dlopen fires static ctors - └─ SolverRegistrar { "MySolver", factory } - └─ SolverRegistry::instance().register_solver(...) + └─ C++ _load_algorithm_plugin(path) # a pybind11 function (try/catch) + ├─ dlopen(path) / LoadLibrary(path) + ├─ dlsym("ls2g_register_plugin") # the LS2G_PLUGIN_ENTRY function + └─ entry(&AlgorithmRegistry::instance(), errbuf, len) + └─ AlgorithmRegistry::register_all(...) # atomic; ABI-checked + # failure → return code → Python exception Python: grid.change_algorithm("MySolver") └─ AlgorithmSelector::change_algorithm("MySolver") - └─ SolverRegistry::instance().make("MySolver") + └─ AlgorithmRegistry::instance().make("MySolver") └─ factory() → unique_ptr The ``AlgorithmRegistry`` C++ API (defined in ``AlgorithmRegistry.hpp``, installed to ``include/lightsim2grid/``): @@ -51,9 +62,15 @@ The ``AlgorithmRegistry`` C++ API (defined in ``AlgorithmRegistry.hpp``, install // Meyers singleton — one instance per process. static AlgorithmRegistry& instance(); - // Register a factory under a name. Called by SolverRegistrar. + // Register a single factory under a name (used for the built-ins). void register_solver(const std::string& name, Factory f); + // Register a whole batch atomically: all names are added, or none is + // and the registry is left unchanged. This is what a plugin goes + // through. Throws (registry untouched) on an ABI mismatch or a name + // that is already registered. + void register_all(FactoryMap batch, Ls2gAbiTag caller_tag = ls2g_current_abi_tag()); + // Instantiate a solver by name. Throws std::invalid_argument if // the name is unknown. std::unique_ptr make(const std::string& name) const; @@ -61,14 +78,15 @@ The ``AlgorithmRegistry`` C++ API (defined in ``AlgorithmRegistry.hpp``, install bool is_registered(const std::string& name) const; // List of all registered names (built-in + plugins). - std::vector available_algorithms() const; + std::vector available_algorithm_names() const; }; - // Drop a static instance of this in an anonymous namespace to - // register your solver when the .so is loaded — no macro needed. - class AlgorithmRegistrar { + // Staging area handed to your registration function; add() one solver per + // name. The LS2G_PLUGIN_ENTRY macro turns your function into the exported + // ls2g_register_plugin entry point and commits the batch atomically. + class PluginRegistrar { public: - AlgorithmRegistrar(const std::string& name, SolverRegistry::Factory f); + void add(const std::string& name, AlgorithmRegistry::Factory f); }; @@ -228,8 +246,9 @@ Writing a solver plugin **1 — Create the solver class and register it** -Place this in a single ``.cpp`` file. The anonymous-namespace static -object ensures the registration fires exactly once, at ``dlopen`` time. +Place this in a single ``.cpp`` file. Write a small registration function and +hand it to ``LS2G_PLUGIN_ENTRY``, which generates the exported +``ls2g_register_plugin`` entry point ``load_algorithm_plugin()`` calls. .. code-block:: cpp @@ -265,13 +284,14 @@ object ensures the registration fires exactly once, at ``dlopen`` time. } }; - // Self-registration — fires when the .so is dlopen'd. - namespace { - ls2g::ALgorithmRegistrar _reg( - "MySolver", - []{ return std::unique_ptr(new MySolver()); } - ); + // Registration: load_algorithm_plugin() calls this (via the generated + // ls2g_register_plugin entry point) after loading the library. Add one + // solver per name; register a whole batch and they commit atomically. + static void register_plugin(ls2g::PluginRegistrar& reg) { + reg.add("MySolver", + []{ return std::unique_ptr(new MySolver()); }); } + LS2G_PLUGIN_ENTRY(register_plugin) **2 — Write a CMakeLists.txt** @@ -302,7 +322,7 @@ development without a full ``pip install``. if(NOT DEFINED Eigen3_INCLUDE) set(Eigen3_INCLUDE "/path/to/lightsim2grid/eigen") endif() - if(NOT EXISTS "${LIGHTSIM2GRID_SRC}/SolverRegistry.hpp") + if(NOT EXISTS "${LIGHTSIM2GRID_SRC}/AlgorithmRegistry.hpp") message(FATAL_ERROR "lightsim2grid_core not found.\n" "Install lightsim2grid and pass:\n" @@ -416,8 +436,10 @@ Loading and using the plugin from Python import lightsim2grid from lightsim2grid.lightsim2grid_cpp import LSGrid - # 1. Load the plugin — this fires the C++ static constructors, which - # register "MySolver" into the SolverRegistry singleton. + # 1. Load the plugin — this loads the library, calls its + # ls2g_register_plugin entry point, and registers "MySolver" into the + # AlgorithmRegistry singleton. A registration failure (ABI mismatch, + # duplicate name, ...) raises a catchable exception here. lightsim2grid.load_algorithm_plugin("build/libmy_solver.so") # 2. Confirm the solver is available. @@ -440,18 +462,27 @@ Python API reference Load a shared library containing one or more lightsim2grid solver / algorithm plugins. - The library must contain at least one static ``AlgorithmRegistrar`` - object in an anonymous namespace (see the example above). Its - constructor fires at ``dlopen`` time, registering the new solver name - into the ``AlgorithmRegistry`` singleton. + The library must export a ``ls2g_register_plugin`` entry point, which the + ``LS2G_PLUGIN_ENTRY`` macro generates from your registration function (see + the example above). The library is loaded, the entry point is called, and + the solver name(s) it declares are registered into the ``AlgorithmRegistry`` + singleton. After this call the new solver is usable via ``grid.change_algorithm("MyAlgoName")`` and will appear in ``grid.available_algorithm_names()``. + Because registration runs from an explicitly-called entry point rather than + a static constructor during ``dlopen``, every failure is raised here as a + normal, catchable exception instead of aborting the interpreter. Loading a + plugin whose name collides with an already-registered one — including + loading the same plugin twice — is rejected the same way, and the registry + is left unchanged (registration is atomic). + :param path: Absolute or relative path to the ``.so`` / ``.dll`` file. - :raises OSError: If the library cannot be loaded (missing file, - ABI mismatch, unresolved symbols, …). + :raises RuntimeError: If the library cannot be loaded (missing file, + unresolved symbols), does not export the ``ls2g_register_plugin`` entry + point, or its registration is refused (ABI mismatch, duplicate name). .. py:method:: LSGrid.change_algorithm(name: str) -> None @@ -468,7 +499,7 @@ Python API reference .. py:method:: LSGrid.available_algorithm_names() -> list[str] Return all solver names currently registered, including any that were - added via :func:`~lightsim2grid.load_solver_plugin`. + added via :func:`~lightsim2grid.load_algorithm_plugin`. .. code-block:: python @@ -573,7 +604,7 @@ directly from Python:: As defense-in-depth on top of the CMake-level matching above, this is also checked **at runtime**. Every plugin solver registration (``load_algorithm_plugin()`` -/ ``AlgorithmRegistrar``) computes an "ABI tag" — ``EIGEN_MAX_ALIGN_BYTES``, the +/ ``LS2G_PLUGIN_ENTRY``) computes an "ABI tag" — ``EIGEN_MAX_ALIGN_BYTES``, the resolved ``EIGEN_VECTORIZE_SSE``/``AVX``/``AVX2``/``AVX512``/``FMA``/``NEON`` flags, and the full Eigen version (major.minor.patch) — in the plugin's own translation unit, and compares it against the tag ``lightsim2grid_core`` was diff --git a/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp b/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp index 1896c03a..c4fdf404 100644 --- a/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp +++ b/examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp @@ -1,7 +1,7 @@ // NRAlgoDistSlack plugin registration. // -// Registers two names with the C++ AlgorithmRegistry at dlopen() time (via a -// static AlgorithmRegistrar in an anonymous namespace -- same mechanism as +// Registers two names with the C++ AlgorithmRegistry through the exported +// ls2g_register_plugin entry point (LS2G_PLUGIN_ENTRY -- same mechanism as // examples/external_algorithm/DummyExternalAlgo.cpp): // "NRDistSlack_SparseLU" -> NRAlgoDistSlack // "NRDistSlack_KLU" -> NRAlgoDistSlack (only if KLU is available) @@ -20,16 +20,13 @@ #include "NRAlgoDistSlack.hpp" -namespace { - ls2g::AlgorithmRegistrar _dist_slack_sparselu_registrar( - "NRDistSlack_SparseLU", - []{ return std::unique_ptr(new ls2g::NRAlgoDistSlack()); } - ); +static void register_plugin(ls2g::PluginRegistrar& reg) { + reg.add("NRDistSlack_SparseLU", + []{ return std::unique_ptr(new ls2g::NRAlgoDistSlack()); }); #ifdef KLU_SOLVER_AVAILABLE - ls2g::AlgorithmRegistrar _dist_slack_klu_registrar( - "NRDistSlack_KLU", - []{ return std::unique_ptr(new ls2g::NRAlgoDistSlack()); } - ); + reg.add("NRDistSlack_KLU", + []{ return std::unique_ptr(new ls2g::NRAlgoDistSlack()); }); #endif } +LS2G_PLUGIN_ENTRY(register_plugin) diff --git a/examples/external_algorithm/DummyExternalAlgo.cpp b/examples/external_algorithm/DummyExternalAlgo.cpp index 18df5b9c..85c44ea6 100644 --- a/examples/external_algorithm/DummyExternalAlgo.cpp +++ b/examples/external_algorithm/DummyExternalAlgo.cpp @@ -1,19 +1,22 @@ // Minimal lightsim2grid solver plugin example. // -// To register this solver with the C++ SolverRegistry at dlopen() time, we -// declare a static SolverRegistrar in an anonymous namespace. No macro is -// needed — the object's constructor fires when the shared library is loaded, -// which registers "DummyExternal" into the singleton registry. +// The plugin exposes a single exported entry point (generated by the +// LS2G_PLUGIN_ENTRY macro) that lightsim2grid.load_algorithm_plugin() looks up +// and calls after loading the library, registering "DummyExternal" into the +// AlgorithmRegistry singleton. Doing the registration in an explicitly-called +// entry point (rather than a static constructor firing during dlopen) means a +// registration failure comes back as a catchable Python exception instead of +// aborting the interpreter. // // Build (after pip install lightsim2grid): // LS2G_CMAKE=$(python -c "import lightsim2grid; print(lightsim2grid.get_cmake_dir())") // cmake -S . -B build -DLIGHTSIM2GRID_CMAKE_DIR="$LS2G_CMAKE" // cmake --build build // -// Python usage (from examples/external_solver/): +// Python usage (from examples/external_algorithm/): // import lightsim2grid -// lightsim2grid.load_solver_plugin("build/libdummy_solver.so") -// grid.change_algo("DummyExternal") +// lightsim2grid.load_algorithm_plugin("build/libdummy_solver.so") +// grid.change_algorithm("DummyExternal") #include #include @@ -50,11 +53,11 @@ class DummyExternalAlgo : public ls2g::BaseAlgo { }; // --------------------------------------------------------------------------- -// Self-registration: fires when dlopen() / LoadLibrary() maps this .so. +// Registration: load_algorithm_plugin() calls ls2g_register_plugin (generated +// by LS2G_PLUGIN_ENTRY) after loading this library. // --------------------------------------------------------------------------- -namespace { - ls2g::AlgorithmRegistrar _dummy_registrar( - "DummyExternal", - []{ return std::unique_ptr(new DummyExternalAlgo()); } - ); +static void register_plugin(ls2g::PluginRegistrar& reg) { + reg.add("DummyExternal", + []{ return std::unique_ptr(new DummyExternalAlgo()); }); } +LS2G_PLUGIN_ENTRY(register_plugin) diff --git a/examples/lm_algorithm/LMNRAlgo_plugin.cpp b/examples/lm_algorithm/LMNRAlgo_plugin.cpp index 1132329a..f7c1e067 100644 --- a/examples/lm_algorithm/LMNRAlgo_plugin.cpp +++ b/examples/lm_algorithm/LMNRAlgo_plugin.cpp @@ -1,12 +1,15 @@ // LMNRAlgo plugin registration. // -// Registers two names with the C++ AlgorithmRegistry at dlopen() time (via a -// static AlgorithmRegistrar in an anonymous namespace -- same mechanism as +// Registers two names with the C++ AlgorithmRegistry through the exported +// ls2g_register_plugin entry point (LS2G_PLUGIN_ENTRY -- same mechanism as // examples/dist_slack_algorithm/NRAlgoDistSlack_plugin.cpp and // examples/external_algorithm/DummyExternalAlgo.cpp): // "NR_LM_SparseLU" -> LMNRAlgo, SingleSlackNRSystem> // "NR_LM_KLU" -> LMNRAlgo, SingleSlackNRSystem> (only if KLU is available) // +// Both names are registered atomically: if either is already taken, neither is +// added and load_algorithm_plugin() raises instead of half-registering. +// // Single-slack only for this first cut; LMNRAlgo is generic over its // NRSystem template parameter, so registering MultiSlackNRSystem variants // later is a one-line addition. @@ -25,18 +28,15 @@ #include "LMNRAlgo.hpp" -namespace { - ls2g::AlgorithmRegistrar _lm_sparselu_registrar( - "NR_LM_SparseLU", +static void register_plugin(ls2g::PluginRegistrar& reg) { + reg.add("NR_LM_SparseLU", []{ return std::unique_ptr( - new ls2g::LMNRAlgo, ls2g::SingleSlackNRSystem>()); } - ); + new ls2g::LMNRAlgo, ls2g::SingleSlackNRSystem>()); }); #ifdef KLU_SOLVER_AVAILABLE - ls2g::AlgorithmRegistrar _lm_klu_registrar( - "NR_LM_KLU", + reg.add("NR_LM_KLU", []{ return std::unique_ptr( - new ls2g::LMNRAlgo, ls2g::SingleSlackNRSystem>()); } - ); + new ls2g::LMNRAlgo, ls2g::SingleSlackNRSystem>()); }); #endif } +LS2G_PLUGIN_ENTRY(register_plugin) diff --git a/lightsim2grid/__init__.py b/lightsim2grid/__init__.py index 7b4ac7dc..623786da 100644 --- a/lightsim2grid/__init__.py +++ b/lightsim2grid/__init__.py @@ -19,7 +19,6 @@ "get_include", "get_cmake_dir"] -import ctypes as _ctypes import os as _os import sys as _sys @@ -49,10 +48,19 @@ def load_algorithm_plugin(path: str) -> None: """Load a shared library containing a lightsim2grid algorithm plugin. - The library must contain at least one static ``AlgorithmRegistrar`` object - in an anonymous namespace (see ``examples/external_solver/`` for a minimal - example). Its constructor fires when the library is loaded, which - registers the new algorithm / solver into the C++ ``AlgorithmRegistry`` singleton. + The library must export a ``ls2g_register_plugin`` entry point, which the + ``LS2G_PLUGIN_ENTRY`` macro generates from the plugin's registration + function (see ``examples/external_algorithm/`` for a minimal example). The + library is loaded, the entry point is looked up and called, and the new + algorithm(s) it declares are registered into the C++ ``AlgorithmRegistry`` + singleton. + + Registration happens through a function the loader calls explicitly, *not* + inside a static constructor during ``dlopen``, so any failure -- an ABI + mismatch, a solver name that is already registered (which includes loading + the same plugin twice), a library that is not a plugin, a missing file -- + is raised here as a normal, catchable Python exception instead of aborting + the interpreter. After this call the new solver name is usable via:: @@ -64,8 +72,15 @@ def load_algorithm_plugin(path: str) -> None: ---------- path: Absolute or relative path to the ``.so`` / ``.dll`` file. + + Raises + ------ + RuntimeError + If the library cannot be loaded, does not export the plugin entry + point, or its registration is refused (ABI mismatch, duplicate name). """ - _ctypes.CDLL(path, mode=_ctypes.RTLD_GLOBAL) + from lightsim2grid.lightsim2grid_cpp import _load_algorithm_plugin + _load_algorithm_plugin(str(path)) try: from lightsim2grid.lightSimBackend import LightSimBackend # noqa: F401 diff --git a/lightsim2grid/tests/test_plugin_registration.py b/lightsim2grid/tests/test_plugin_registration.py new file mode 100644 index 00000000..82108811 --- /dev/null +++ b/lightsim2grid/tests/test_plugin_registration.py @@ -0,0 +1,128 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""End-to-end tests for load_algorithm_plugin(). + +These load a *real* compiled plugin (the examples/external_algorithm/ dummy +solver) and assert that both the happy path and every error path behave: the +error paths must raise a normal, catchable Python exception, and -- the whole +point of the entry-point design -- must NOT abort the interpreter. If a bad +plugin load crashed the process, the test runner would die here rather than +report a failure, so simply reaching the assertions after each error load is +itself the crash-safety check. + +The plugin must be built first (see examples/external_algorithm/CMakeLists.txt); +the plugin_registration CI workflow does this. When it is not available the +whole case is skipped so the suite still runs in a plain source checkout. +""" + +import os +import sys +import shutil +import tempfile +import unittest + + +def _find_plugin(): + env = os.environ.get("LS2G_TEST_PLUGIN") + if env and os.path.exists(env): + return os.path.abspath(env) + base = os.path.join(os.path.dirname(__file__), "..", "..", + "examples", "external_algorithm", "build") + if sys.platform == "win32": + candidates = [ + os.path.join(base, "Release", "dummy_solver.dll"), + os.path.join(base, "Debug", "dummy_solver.dll"), + os.path.join(base, "dummy_solver.dll"), + ] + else: + candidates = [os.path.join(base, "libdummy_solver.so")] + for cand in candidates: + cand = os.path.abspath(cand) + if os.path.exists(cand): + return cand + return None + + +class TestPluginRegistration(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.plugin = _find_plugin() + if cls.plugin is None: + raise unittest.SkipTest( + "example solver plugin not built; build examples/external_algorithm/ " + "or set LS2G_TEST_PLUGIN to its .so/.dll path") + + def _names(self): + from lightsim2grid.lightsim2grid_cpp import LSGrid + return LSGrid().available_algorithm_names() + + def test_1_load_registers_solver(self): + import lightsim2grid + from lightsim2grid.lightsim2grid_cpp import LSGrid, AlgorithmType + + if "DummyExternal" not in self._names(): + lightsim2grid.load_algorithm_plugin(self.plugin) + + self.assertIn("DummyExternal", self._names()) + gm = LSGrid() + gm.change_algorithm("DummyExternal") + self.assertEqual(gm.get_algo_type(), AlgorithmType.Custom) + + def test_2_duplicate_load_raises_without_crashing(self): + import lightsim2grid + + if "DummyExternal" not in self._names(): + lightsim2grid.load_algorithm_plugin(self.plugin) + + # Loading the same plugin again re-runs its registration, which now + # collides with the already-registered name: a catchable error, not a + # crash (previously this could std::terminate() the interpreter). + with self.assertRaises(Exception): + lightsim2grid.load_algorithm_plugin(self.plugin) + # Still alive, and the registry is intact (atomic rejection). + self.assertIn("DummyExternal", self._names()) + + def test_3_duplicate_from_a_copy_raises_without_crashing(self): + import lightsim2grid + + if "DummyExternal" not in self._names(): + lightsim2grid.load_algorithm_plugin(self.plugin) + + # A different file on disk that registers the same name must also be + # rejected cleanly (exercises the collision path via a fresh dlopen). + suffix = os.path.splitext(self.plugin)[1] or ".so" + tmp = tempfile.NamedTemporaryFile(prefix="ls2g_dupe_", suffix=suffix, delete=False) + tmp.close() + try: + shutil.copyfile(self.plugin, tmp.name) + with self.assertRaises(Exception): + lightsim2grid.load_algorithm_plugin(tmp.name) + self.assertIn("DummyExternal", self._names()) + finally: + os.unlink(tmp.name) + + def test_4_library_without_entry_point_raises(self): + import lightsim2grid + from lightsim2grid import lightsim2grid_cpp + + # The compiled extension itself is a perfectly loadable shared library, + # but it exports no ls2g_register_plugin entry point. + not_a_plugin = lightsim2grid_cpp.__file__ + with self.assertRaises(Exception): + lightsim2grid.load_algorithm_plugin(not_a_plugin) + + def test_5_missing_file_raises(self): + import lightsim2grid + with self.assertRaises(Exception): + lightsim2grid.load_algorithm_plugin( + os.path.join(tempfile.gettempdir(), "ls2g_no_such_plugin_xyz.so")) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/bindings/python/binding_module.cpp b/src/bindings/python/binding_module.cpp index 5e2fe7bd..2fede6c4 100644 --- a/src/bindings/python/binding_module.cpp +++ b/src/bindings/python/binding_module.cpp @@ -8,8 +8,16 @@ #include "binding_declarations.hpp" #include "Ls2gAbiTag.hpp" +#include "AlgorithmRegistry.hpp" #include +#include + +#if defined(_WIN32) +# include +#else +# include +#endif #ifndef KLU_SOLVER_AVAILABLE #define this_KLU_SOLVER_AVAILABLE 0 @@ -48,6 +56,66 @@ #define this_CKTSO_PATH CKTSO_PATH #endif +// Behind lightsim2grid.load_algorithm_plugin(). Load the shared library, look +// up its exported ls2g_register_plugin entry point, and call it -- all from a +// real C++ frame that pybind11 wraps in a try/catch, so any registration +// failure (ABI mismatch, duplicate solver name, missing entry point, missing +// file) surfaces as a normal Python exception. This is the whole safety +// improvement over dlopen'ing the plugin directly from ctypes: there, an +// exception thrown by a static-constructor registration would unwind into the +// C loader frames and std::terminate() the interpreter. A rejected plugin is +// unloaded again so it leaves no trace; a successfully-registered one is +// intentionally kept mapped for the life of the process, since the registry now +// holds factories whose code lives in it. +static void load_algorithm_plugin_impl(const std::string& path) +{ + char errbuf[512]; + errbuf[0] = '\0'; +#if defined(_WIN32) + HMODULE handle = ::LoadLibraryA(path.c_str()); + if (handle == nullptr) { + throw std::runtime_error("lightsim2grid.load_algorithm_plugin: could not load '" + path + + "' (LoadLibrary failed, error code " + std::to_string(::GetLastError()) + ")."); + } + auto entry = reinterpret_cast( + ::GetProcAddress(handle, "ls2g_register_plugin")); + if (entry == nullptr) { + ::FreeLibrary(handle); + throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + + "' is not a lightsim2grid plugin (no 'ls2g_register_plugin' entry point)."); + } + int rc = entry(&ls2g::AlgorithmRegistry::instance(), errbuf, sizeof(errbuf)); + if (rc != 0) { + ::FreeLibrary(handle); + throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + + "' failed to register: " + errbuf); + } +#else + ::dlerror(); // clear any stale error state + void* handle = ::dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL); + if (handle == nullptr) { + const char* e = ::dlerror(); + throw std::runtime_error("lightsim2grid.load_algorithm_plugin: could not load '" + path + + "': " + (e != nullptr ? e : "unknown dlopen error")); + } + ::dlerror(); + auto entry = reinterpret_cast( + ::dlsym(handle, "ls2g_register_plugin")); + const char* sym_err = ::dlerror(); + if (entry == nullptr || sym_err != nullptr) { + ::dlclose(handle); + throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + + "' is not a lightsim2grid plugin (no 'ls2g_register_plugin' entry point)."); + } + int rc = entry(&ls2g::AlgorithmRegistry::instance(), errbuf, sizeof(errbuf)); + if (rc != 0) { + ::dlclose(handle); // reject: unload so a failed plugin leaves nothing behind + throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + + "' failed to register: " + errbuf); + } +#endif +} + PYBIND11_MODULE(lightsim2grid_cpp, m) { // Guard against the exact bug that motivated this check: lightsim2grid_core @@ -88,6 +156,11 @@ PYBIND11_MODULE(lightsim2grid_cpp, m) m.attr("cktso_lib") = py::str(this_CKTSO_PATH); #endif + m.def("_load_algorithm_plugin", &load_algorithm_plugin_impl, py::arg("path"), + "Internal helper behind lightsim2grid.load_algorithm_plugin(): load the given " + "shared library, run its 'ls2g_register_plugin' entry point, and raise a Python " + "exception (rather than crashing the interpreter) if registration fails."); + bind_enums(m); bind_solvers(m); bind_containers(m); diff --git a/src/core/AlgorithmRegistry.cpp b/src/core/AlgorithmRegistry.cpp index 211bbe27..68bd9ae6 100644 --- a/src/core/AlgorithmRegistry.cpp +++ b/src/core/AlgorithmRegistry.cpp @@ -32,6 +32,36 @@ void AlgorithmRegistry::register_solver(const std::string& name, Factory f, Ls2g _factories[name] = std::move(f); } +void AlgorithmRegistry::register_all(FactoryMap batch, Ls2gAbiTag caller_tag) { + const Ls2gAbiTag& this_core_tag = core_abi_tag(); + if (caller_tag != this_core_tag) { + throw std::runtime_error( + "AlgorithmRegistry: refusing to register a solver plugin: it was compiled with " + "different Eigen SIMD/alignment settings than lightsim2grid_core. Loading it would silently " + "corrupt the heap the first time an Eigen object crosses the BaseAlgo interface. " + "lightsim2grid_core was built with [" + this_core_tag.describe() + "], this plugin was built " + "with [" + caller_tag.describe() + "]. Recompile the plugin with matching compiler/-march " + "flags -- see docs/solver_plugin.rst, section \"Matching build flags\"."); + } + // Reject the whole batch if any name is already taken, *before* touching + // the live registry, so a rejected plugin never half-registers. + for (const auto& kv : batch) { + if (_factories.count(kv.first)) { + throw std::invalid_argument( + "AlgorithmRegistry: a solver named '" + kv.first + "' is already registered."); + } + } + // Strong exception guarantee: assemble the merged table off to the side, + // then commit it with a noexcept swap. If anything above or in the merge + // throws (e.g. std::bad_alloc), _factories is left exactly as it was. + FactoryMap merged = _factories; + merged.reserve(_factories.size() + batch.size()); + for (auto& kv : batch) { + merged.emplace(kv.first, std::move(kv.second)); + } + _factories.swap(merged); +} + std::unique_ptr AlgorithmRegistry::make(const std::string& name) const { auto it = _factories.find(name); if (it == _factories.end()) { diff --git a/src/core/AlgorithmRegistry.hpp b/src/core/AlgorithmRegistry.hpp index b814f29d..6092d45e 100644 --- a/src/core/AlgorithmRegistry.hpp +++ b/src/core/AlgorithmRegistry.hpp @@ -9,6 +9,8 @@ #ifndef ALGORITHM_REGISTRY_H #define ALGORITHM_REGISTRY_H +#include +#include #include #include #include @@ -24,37 +26,135 @@ namespace ls2g { class LS2G_API AlgorithmRegistry { public: using Factory = std::function()>; + using FactoryMap = std::unordered_map; static AlgorithmRegistry& instance(); // `caller_tag` defaults to `ls2g_current_abi_tag()` evaluated at the call - // site: since AlgorithmRegistrar's constructor below is defined inline, - // that default is compiled fresh into whichever plugin instantiates it, - // so it automatically captures the plugin's own Eigen SIMD/alignment - // settings with no change needed on the plugin author's side. Throws if - // it disagrees with lightsim2grid_core's own tag -- see Ls2gAbiTag.hpp. + // site: since it is compiled fresh into whichever translation unit calls + // this, the default captures that TU's own Eigen SIMD/alignment settings. + // Throws if it disagrees with lightsim2grid_core's own tag -- see + // Ls2gAbiTag.hpp. Used for the built-in solvers (BuiltinSolversRegistration). void register_solver(const std::string& name, Factory f, Ls2gAbiTag caller_tag = ls2g_current_abi_tag()); + + // Register a whole batch of solvers atomically: either every name in + // `batch` is added, or none is and the registry is left exactly as it was. + // This is what a plugin registers through (see LS2G_PLUGIN_ENTRY below) so + // that a plugin exposing several solvers can never half-register when a + // later name collides with an already-registered one. Throws (leaving the + // registry unchanged) on an ABI-tag mismatch or a name already present. + void register_all(FactoryMap batch, Ls2gAbiTag caller_tag = ls2g_current_abi_tag()); + std::unique_ptr make(const std::string& name) const; bool is_registered(const std::string& name) const; std::vector available_algorithm_names() const; private: AlgorithmRegistry() = default; - std::unordered_map _factories; + FactoryMap _factories; }; -// Helper for plugins: declare a static instance of this class in an anonymous -// namespace inside one translation unit to register a solver at load time. -// No macro needed — the static constructor fires when the .so is dlopen'd. -class LS2G_API AlgorithmRegistrar { +// Staging area a plugin fills in with its solvers before they are committed to +// the real registry. Handed to the plugin's registration function; `add()` +// only stages (and rejects a name the same plugin already staged) -- the actual +// commit happens atomically once the whole function has run without throwing. +class LS2G_API PluginRegistrar { public: - AlgorithmRegistrar(const std::string& name, AlgorithmRegistry::Factory f) { - AlgorithmRegistry::instance().register_solver(name, std::move(f)); + void add(const std::string& name, AlgorithmRegistry::Factory f) { + if (_staged.count(name)) { + throw std::invalid_argument( + "PluginRegistrar: this plugin registers the solver name '" + name + "' twice."); + } + _staged.emplace(name, std::move(f)); } + // Hand the staged solvers over to the caller (the entry-point helper), + // leaving this registrar empty. + AlgorithmRegistry::FactoryMap take() { return std::move(_staged); } + +private: + AlgorithmRegistry::FactoryMap _staged; }; +// Function pointer type every plugin's exported entry point matches. `registry` +// is an `AlgorithmRegistry*` passed as void* to keep the C boundary free of C++ +// types; `errbuf`/`errbuf_len` receive a NUL-terminated diagnostic on failure. +// Returns 0 on success, non-zero on failure (never throws across this boundary). +extern "C" { + typedef int (*ls2g_plugin_entry_fn)(void* registry, char* errbuf, std::size_t errbuf_len); +} + +namespace detail { + +// Body of the exported ls2g_register_plugin entry point (see LS2G_PLUGIN_ENTRY). +// `noexcept` and catching everything is the whole point: registration runs +// C++ code that can throw (ABI mismatch, duplicate name, bad_alloc, a bug in +// the plugin's own factory setup), and none of it may escape across the +// extern "C" boundary back into the C loader frames -- there it would be an +// uncatchable std::terminate(). Instead every failure becomes a return code +// plus a message the host turns into a normal Python exception. +inline int ls2g_run_plugin_entry(void* registry_ptr, + char* errbuf, + std::size_t errbuf_len, + void (*register_fn)(PluginRegistrar&), + Ls2gAbiTag plugin_tag) noexcept { + // Bounded, overflow-proof copy of `msg` into `errbuf`: writes at most + // errbuf_len-1 bytes and always NUL-terminates; a null or zero-length + // buffer is simply left untouched. Written out by hand (rather than + // strncpy) so the bound is obvious to a reader and to valgrind. + auto write_err = [errbuf, errbuf_len](const char* msg) noexcept { + if (errbuf == nullptr || errbuf_len == 0) return; + std::size_t i = 0; + const std::size_t last = errbuf_len - 1; + if (msg != nullptr) { + for (; i < last && msg[i] != '\0'; ++i) errbuf[i] = msg[i]; + } + errbuf[i] = '\0'; + }; + try { + if (registry_ptr == nullptr) { + write_err("lightsim2grid plugin loader passed a null registry pointer."); + return 2; + } + AlgorithmRegistry& reg = *static_cast(registry_ptr); + PluginRegistrar staging; + register_fn(staging); // plugin stages its solvers + reg.register_all(staging.take(), plugin_tag); // atomic commit + ABI check + return 0; + } catch (const std::exception& e) { + write_err(e.what()); + return 1; + } catch (...) { + write_err("unknown C++ exception during lightsim2grid plugin registration."); + return 1; + } +} + +} // namespace detail } // namespace ls2g +// Declare the exported entry point of a solver plugin. `register_fn` is a +// `void(ls2g::PluginRegistrar&)` the plugin author writes, calling reg.add(...) +// once per solver it provides. The generated ls2g_register_plugin function is +// what lightsim2grid.load_algorithm_plugin() looks up (via dlsym/GetProcAddress) +// and calls *after* the library is loaded -- so registration no longer happens +// inside a static constructor during dlopen(), and any failure comes back as a +// catchable Python exception instead of aborting the interpreter. +// +// static void register_plugin(ls2g::PluginRegistrar& reg) { +// reg.add("MyAlgo", []{ return std::make_unique(); }); +// } +// LS2G_PLUGIN_ENTRY(register_plugin) +// +// ls2g_current_abi_tag() is evaluated here, in the plugin's own translation +// unit, so it captures the plugin's Eigen SIMD/alignment flags automatically. +#define LS2G_PLUGIN_ENTRY(register_fn) \ + extern "C" LS2G_PLUGIN_EXPORT int \ + ls2g_register_plugin(void* registry_ptr, char* errbuf, std::size_t errbuf_len) { \ + return ::ls2g::detail::ls2g_run_plugin_entry( \ + registry_ptr, errbuf, errbuf_len, ®ister_fn, \ + ::ls2g::ls2g_current_abi_tag()); \ + } + #endif // ALGORITHM_REGISTRY_H diff --git a/src/core/ls2g_api.hpp b/src/core/ls2g_api.hpp index 04d45c5f..11d0337e 100644 --- a/src/core/ls2g_api.hpp +++ b/src/core/ls2g_api.hpp @@ -27,4 +27,20 @@ # endif #endif +// A solver plugin (a separate .so/.dll loaded at runtime via +// lightsim2grid.load_algorithm_plugin()) must *export* its registration entry +// point, regardless of which side it is built on. LS2G_API resolves to +// dllimport inside a plugin (LS2G_BUILDING_CORE is not defined there), so it is +// the wrong macro for a symbol the plugin itself provides -- hence this +// dedicated always-export macro. See LS2G_PLUGIN_ENTRY in AlgorithmRegistry.hpp. +#ifndef LS2G_PLUGIN_EXPORT +# if defined(_MSC_VER) +# define LS2G_PLUGIN_EXPORT __declspec(dllexport) +# elif defined(__GNUC__) || defined(__clang__) +# define LS2G_PLUGIN_EXPORT __attribute__((visibility("default"))) +# else +# define LS2G_PLUGIN_EXPORT +# endif +#endif + #endif // LS2G_API_H diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 604a0e20..9d8e69b7 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -56,6 +56,7 @@ add_executable(lightsim2grid_unit_tests test_lsgrid.cpp test_case_exotic_elements.cpp test_ls2g_abi_tag.cpp + test_plugin_registration.cpp ) target_link_libraries(lightsim2grid_unit_tests PRIVATE lightsim2grid_core Catch2::Catch2WithMain) diff --git a/src/tests/test_plugin_registration.cpp b/src/tests/test_plugin_registration.cpp new file mode 100644 index 00000000..16be85e8 --- /dev/null +++ b/src/tests/test_plugin_registration.cpp @@ -0,0 +1,166 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Registration-safety tests for the solver-plugin mechanism. The concern these +// guard is specifically the plugin boundary: a plugin registers solvers from +// code that lightsim2grid loads at runtime, and the two failure modes that +// mechanism has -- an ABI-tag mismatch and a duplicate solver name -- must +// (a) never leave the registry half-updated, and (b) never let a C++ exception +// escape the exported entry point (where, running under dlopen's C frames, it +// would be an uncatchable std::terminate). The errbuf-handling tests also run +// under valgrind in the cpp_unit_tests workflow, which is what actually proves +// the bounded copy never writes out of bounds. + +#include + +#include +#include +#include + +#include "AlgorithmRegistry.hpp" +#include "Ls2gAbiTag.hpp" + +namespace { + +ls2g::AlgorithmRegistry::Factory null_factory() { + // Registration must never need to *build* a solver, so the factory can be + // a no-op: these tests only exercise the registration bookkeeping. + return []() -> std::unique_ptr { return nullptr; }; +} + +} // namespace + +TEST_CASE("register_all commits every name on success", "[plugin]") +{ + ls2g::AlgorithmRegistry::FactoryMap batch; + batch.emplace("__plugin_ok_A__", null_factory()); + batch.emplace("__plugin_ok_B__", null_factory()); + + ls2g::AlgorithmRegistry::instance().register_all(std::move(batch)); + + REQUIRE(ls2g::AlgorithmRegistry::instance().is_registered("__plugin_ok_A__")); + REQUIRE(ls2g::AlgorithmRegistry::instance().is_registered("__plugin_ok_B__")); +} + +TEST_CASE("register_all is atomic: a colliding name rolls back the whole batch", "[plugin]") +{ + // Pre-register a name the batch will collide with. + ls2g::AlgorithmRegistry::instance().register_solver("__plugin_existing__", null_factory()); + + ls2g::AlgorithmRegistry::FactoryMap batch; + batch.emplace("__plugin_fresh_should_not_survive__", null_factory()); + batch.emplace("__plugin_existing__", null_factory()); // collision + + REQUIRE_THROWS_AS( + ls2g::AlgorithmRegistry::instance().register_all(std::move(batch)), + std::invalid_argument); + + // The fresh name from the same (rejected) batch must NOT have been added. + REQUIRE_FALSE(ls2g::AlgorithmRegistry::instance().is_registered("__plugin_fresh_should_not_survive__")); + // ... and the pre-existing one is untouched. + REQUIRE(ls2g::AlgorithmRegistry::instance().is_registered("__plugin_existing__")); +} + +TEST_CASE("register_all rejects a mismatched ABI tag and commits nothing", "[plugin]") +{ + ls2g::Ls2gAbiTag mismatched = ls2g::ls2g_current_abi_tag(); + mismatched.eigen_max_align_bytes += 16; // guaranteed to differ from core's tag + + ls2g::AlgorithmRegistry::FactoryMap batch; + batch.emplace("__plugin_abi_reject__", null_factory()); + + REQUIRE_THROWS_AS( + ls2g::AlgorithmRegistry::instance().register_all(std::move(batch), mismatched), + std::runtime_error); + + REQUIRE_FALSE(ls2g::AlgorithmRegistry::instance().is_registered("__plugin_abi_reject__")); +} + +TEST_CASE("PluginRegistrar rejects a name the same plugin stages twice", "[plugin]") +{ + ls2g::PluginRegistrar reg; + reg.add("__plugin_intra_dup__", null_factory()); + REQUIRE_THROWS_AS(reg.add("__plugin_intra_dup__", null_factory()), std::invalid_argument); +} + +TEST_CASE("plugin entry helper: success returns 0 and registers", "[plugin]") +{ + char errbuf[256] = {'x'}; // deliberately not empty to confirm it stays untouched-but-terminated + int rc = ls2g::detail::ls2g_run_plugin_entry( + &ls2g::AlgorithmRegistry::instance(), errbuf, sizeof(errbuf), + +[](ls2g::PluginRegistrar& r) { r.add("__plugin_entry_ok__", null_factory()); }, + ls2g::ls2g_current_abi_tag()); + + REQUIRE(rc == 0); + REQUIRE(ls2g::AlgorithmRegistry::instance().is_registered("__plugin_entry_ok__")); +} + +TEST_CASE("plugin entry helper: a registration failure is a return code, never an exception", "[plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver("__plugin_entry_dup__", null_factory()); + + char errbuf[256] = {'\0'}; + int rc = ls2g::detail::ls2g_run_plugin_entry( + &ls2g::AlgorithmRegistry::instance(), errbuf, sizeof(errbuf), + +[](ls2g::PluginRegistrar& r) { r.add("__plugin_entry_dup__", null_factory()); }, + ls2g::ls2g_current_abi_tag()); + + REQUIRE(rc != 0); + REQUIRE(std::strlen(errbuf) > 0); // a diagnostic was written +} + +TEST_CASE("plugin entry helper: a short error buffer is never overflowed", "[plugin]") +{ + // Force a (long) error message, then hand the helper a tiny buffer. + ls2g::AlgorithmRegistry::instance().register_solver("__plugin_short_buf_dup__", null_factory()); + + constexpr std::size_t N = 8; + char errbuf[N]; + std::memset(errbuf, 'Z', sizeof(errbuf)); // no NUL anywhere to start with + + int rc = ls2g::detail::ls2g_run_plugin_entry( + &ls2g::AlgorithmRegistry::instance(), errbuf, N, + +[](ls2g::PluginRegistrar& r) { r.add("__plugin_short_buf_dup__", null_factory()); }, + ls2g::ls2g_current_abi_tag()); + + REQUIRE(rc != 0); + REQUIRE(errbuf[N - 1] == '\0'); // always NUL-terminated within the bound + REQUIRE(std::strlen(errbuf) <= N - 1); // never wrote past the buffer +} + +TEST_CASE("plugin entry helper: a null / zero-length buffer is handled safely", "[plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver("__plugin_nullbuf_dup__", null_factory()); + + // null buffer on an error path -> no write, no crash. + int rc1 = ls2g::detail::ls2g_run_plugin_entry( + &ls2g::AlgorithmRegistry::instance(), nullptr, 0, + +[](ls2g::PluginRegistrar& r) { r.add("__plugin_nullbuf_dup__", null_factory()); }, + ls2g::ls2g_current_abi_tag()); + REQUIRE(rc1 != 0); + + // zero-length (non-null) buffer: the single byte must not be written. + char one = 'Q'; + int rc2 = ls2g::detail::ls2g_run_plugin_entry( + &ls2g::AlgorithmRegistry::instance(), &one, 0, + +[](ls2g::PluginRegistrar& r) { r.add("__plugin_nullbuf_dup__", null_factory()); }, + ls2g::ls2g_current_abi_tag()); + REQUIRE(rc2 != 0); + REQUIRE(one == 'Q'); +} + +TEST_CASE("plugin entry helper: a null registry pointer is reported, not dereferenced", "[plugin]") +{ + char errbuf[128] = {'\0'}; + int rc = ls2g::detail::ls2g_run_plugin_entry( + nullptr, errbuf, sizeof(errbuf), + +[](ls2g::PluginRegistrar& r) { r.add("__plugin_never__", null_factory()); }, + ls2g::ls2g_current_abi_tag()); + REQUIRE(rc == 2); + REQUIRE(std::strlen(errbuf) > 0); +} From 10e56b6d0de6afe6cdd9db63e541b2bdf5ff60c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 07:04:25 +0000 Subject: [PATCH 144/166] examples: use available_algorithm_names() in the plugin smoke tests Follow-up to the plugin-registration rework: the two example test_plugin.py scripts still called the deprecated available_solver_names() alias and had a couple of stale references (an "external_solver" build path that should read "external_algorithm", and a "change_solver(...)" log line for a change_algorithm(...) call). Switch to available_algorithm_names() and fix the wording so the examples match the current API. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J97bpvJk9BS1qXnGPGYnGp Signed-off-by: Claude --- examples/external_algorithm/test_plugin.py | 6 +++--- examples/lm_algorithm/test_plugin.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/external_algorithm/test_plugin.py b/examples/external_algorithm/test_plugin.py index 6b02e399..945c9309 100644 --- a/examples/external_algorithm/test_plugin.py +++ b/examples/external_algorithm/test_plugin.py @@ -2,7 +2,7 @@ """Smoke-test for the DummyExternal solver plugin. Build the plugin first: - cd examples/external_solver + cd examples/external_algorithm mkdir build && cd build cmake .. make (or: cmake --build . --config Release on Windows) @@ -56,7 +56,7 @@ def find_plugin(): # Verify registration # ------------------------------------------------------------------ gm = LSGrid() -names = gm.available_solver_names() +names = gm.available_algorithm_names() assert "DummyExternal" in names, f"DummyExternal not in {names}" print(f"Registered solvers: {sorted(names)}") @@ -66,6 +66,6 @@ def find_plugin(): gm.change_algorithm("DummyExternal") assert gm.get_algo_type() == AlgorithmType.Custom, \ f"Expected AlgorithmType.Custom, got {gm.get_algo_type()}" -print("change_solver('DummyExternal') OK — solver type is Custom as expected.") +print("change_algorithm('DummyExternal') OK — solver type is Custom as expected.") print("All checks passed.") diff --git a/examples/lm_algorithm/test_plugin.py b/examples/lm_algorithm/test_plugin.py index b43e4dd9..864c70ea 100644 --- a/examples/lm_algorithm/test_plugin.py +++ b/examples/lm_algorithm/test_plugin.py @@ -73,7 +73,7 @@ def _make_grid(): # Verify registration # ------------------------------------------------------------------ gm = _make_grid() -names = gm.available_solver_names() +names = gm.available_algorithm_names() assert "NR_LM_SparseLU" in names, f"NR_LM_SparseLU not in {names}" print(f"Registered solvers: {sorted(names)}") From a9d308de35c8f64a1bfbaeb1d17c2495a787a653 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 07:39:25 +0000 Subject: [PATCH 145/166] Make the plugin-loading unittest actually run in CI (and fix it) lightsim2grid/tests/test_solver_registry.py::TestPluginLoading was silently skipping instead of exercising the plugin: it looked for the example under examples/external_solver/ (the directory is examples/external_algorithm/) and imported a load_solver_plugin symbol that does not exist (it is load_algorithm_plugin). Run against the pip-installed package its relative path could not reach the source-tree examples/ either. - Fix the directory name and the import, and make the plugin path resolvable via LS2G_TEST_PLUGIN / LS2G_TEST_PLUGIN_DIR so it works against the installed package (whose __file__ is in site-packages). The plugin load is now idempotent w.r.t. other test modules in the same process, since loading a plugin whose solver name is already registered now raises. - Apply the same env-based finder to test_plugin_registration.py. - Run both modules in the existing main.yml `test_plugin_against_installed` job (ubuntu/windows/macos), which already builds the plugin after installing lightsim2grid -- so TestPluginLoading genuinely runs, and the crash-safety tests get cross-platform coverage (incl. the Windows LoadLibrary path). - Drop the now-redundant standalone plugin_registration.yml workflow; its test run moved into the job above. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J97bpvJk9BS1qXnGPGYnGp Signed-off-by: Claude --- .github/workflows/main.yml | 14 +++++ .github/workflows/plugin_registration.yml | 57 ----------------- .../tests/test_plugin_registration.py | 38 ++++++----- lightsim2grid/tests/test_solver_registry.py | 63 ++++++++++++------- 4 files changed, 80 insertions(+), 92 deletions(-) delete mode 100644 .github/workflows/plugin_registration.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4073be0e..87ffd1b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -723,6 +723,20 @@ jobs: - name: Run plugin test run: python examples/external_algorithm/test_plugin.py + - name: Run plugin registration unittests (against the installed package) + shell: bash + # Run from the scratch dir so `import lightsim2grid` resolves to the + # pip-installed package, not the source tree (which has no built + # extension). LS2G_TEST_PLUGIN_DIR points the tests at the plugin built + # above; the finders pick the right per-platform file name themselves. + working-directory: _tmp_cmake_dir + env: + LS2G_TEST_PLUGIN_DIR: ${{ github.workspace }}/examples/external_algorithm/build + run: | + python -m unittest -v \ + lightsim2grid.tests.test_solver_registry \ + lightsim2grid.tests.test_plugin_registration + test_klu_strategies: name: Test KLU detection strategies (Ubuntu) runs-on: ubuntu-latest diff --git a/.github/workflows/plugin_registration.yml b/.github/workflows/plugin_registration.yml deleted file mode 100644 index ac27991d..00000000 --- a/.github/workflows/plugin_registration.yml +++ /dev/null @@ -1,57 +0,0 @@ -# Builds lightsim2grid, builds the example out-of-tree solver plugin against -# the *installed* package (so the plugin picks up the exact Eigen / build flags -# lightsim2grid_core was compiled with -- the ABI-match the plugin mechanism -# requires), then runs the end-to-end load_algorithm_plugin() tests. Those -# assert that a bad plugin load raises a catchable Python exception instead of -# aborting the interpreter -- something the C++ Catch2 suite (cpp_unit_tests) -# cannot cover because it never crosses the real dlopen/ctypes boundary. - -name: Plugin registration - -on: - push: - branches: - - '**' - tags: - - 'v*.*.*' - -jobs: - plugin_registration: - name: build + load an out-of-tree solver plugin - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v4 - with: - submodules: true - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements_compile_ci.txt - - - name: Build and install lightsim2grid - run: python -m pip install . -v - - - name: Build the example solver plugin (against the installed package) - run: | - LS2G_CMAKE=$(python -c "import lightsim2grid; print(lightsim2grid.get_cmake_dir())") - echo "lightsim2grid cmake dir: $LS2G_CMAKE" - cmake -S examples/external_algorithm -B examples/external_algorithm/build \ - -DCMAKE_BUILD_TYPE=Release -DLIGHTSIM2GRID_CMAKE_DIR="$LS2G_CMAKE" - cmake --build examples/external_algorithm/build -j"$(nproc)" - - - name: Run plugin-registration tests - # Run from a scratch dir (via unittest, not a file-path pytest run) so - # `import lightsim2grid` resolves to the *installed* package and the - # source-tree package -- which has no compiled extension -- cannot - # shadow it on sys.path. - working-directory: ${{ runner.temp }} - env: - LS2G_TEST_PLUGIN: ${{ github.workspace }}/examples/external_algorithm/build/libdummy_solver.so - run: python -m unittest -v lightsim2grid.tests.test_plugin_registration diff --git a/lightsim2grid/tests/test_plugin_registration.py b/lightsim2grid/tests/test_plugin_registration.py index 82108811..5f04eee3 100644 --- a/lightsim2grid/tests/test_plugin_registration.py +++ b/lightsim2grid/tests/test_plugin_registration.py @@ -28,24 +28,34 @@ import unittest +def _candidates_in(build_dir): + if sys.platform == "win32": + names = ["Release/dummy_solver.dll", "Debug/dummy_solver.dll", "dummy_solver.dll"] + else: + names = ["libdummy_solver.so"] # .so on macOS too (see the plugin CMakeLists) + return [os.path.join(build_dir, n) for n in names] + + def _find_plugin(): + # Full path to the built plugin, when known (set by CI). env = os.environ.get("LS2G_TEST_PLUGIN") if env and os.path.exists(env): return os.path.abspath(env) - base = os.path.join(os.path.dirname(__file__), "..", "..", - "examples", "external_algorithm", "build") - if sys.platform == "win32": - candidates = [ - os.path.join(base, "Release", "dummy_solver.dll"), - os.path.join(base, "Debug", "dummy_solver.dll"), - os.path.join(base, "dummy_solver.dll"), - ] - else: - candidates = [os.path.join(base, "libdummy_solver.so")] - for cand in candidates: - cand = os.path.abspath(cand) - if os.path.exists(cand): - return cand + + build_dirs = [] + env_dir = os.environ.get("LS2G_TEST_PLUGIN_DIR") + if env_dir: + build_dirs.append(env_dir) + # source-tree fallback (in-repo dev run, not the installed package, whose + # __file__ is in site-packages and cannot reach the source-tree examples/). + build_dirs.append(os.path.join(os.path.dirname(__file__), "..", "..", + "examples", "external_algorithm", "build")) + + for build_dir in build_dirs: + for cand in _candidates_in(build_dir): + cand = os.path.abspath(cand) + if os.path.exists(cand): + return cand return None diff --git a/lightsim2grid/tests/test_solver_registry.py b/lightsim2grid/tests/test_solver_registry.py index 247ba5c3..49472adb 100644 --- a/lightsim2grid/tests/test_solver_registry.py +++ b/lightsim2grid/tests/test_solver_registry.py @@ -130,38 +130,59 @@ def test_klu_in_available_solver_names(self): class TestPluginLoading(unittest.TestCase): - """Plugin loading via load_solver_plugin (skipped if example not built).""" - - def _get_plugin_path(self): + """Plugin loading via load_algorithm_plugin (skipped if example not built). + + The DummyExternal solver lives in examples/external_algorithm/. Point the + test at the built plugin with either ``LS2G_TEST_PLUGIN`` (full path to the + .so/.dll) or ``LS2G_TEST_PLUGIN_DIR`` (its build directory) -- needed when + running against the *installed* package, whose __file__ is in site-packages + and cannot reach the source-tree examples/ by relative path. Without either, + a source-tree relative path is tried, and the test skips if nothing is found. + """ + + @staticmethod + def _candidates_in(build_dir): import sys - base = os.path.join(os.path.dirname(__file__), "../../examples/external_solver") if sys.platform == "win32": - candidates = [ - os.path.join(base, "build/Release/dummy_solver.dll"), - os.path.join(base, "build/Debug/dummy_solver.dll"), - os.path.join(base, "build/dummy_solver.dll"), - ] + names = ["Release/dummy_solver.dll", "Debug/dummy_solver.dll", "dummy_solver.dll"] else: - candidates = [ - os.path.join(base, "build/libdummy_solver.so"), - os.path.join(base, "libdummy_solver.so"), - ] - for p in candidates: - if os.path.exists(os.path.abspath(p)): - return os.path.abspath(p) + names = ["libdummy_solver.so"] # .so on macOS too (see the plugin CMakeLists) + return [os.path.join(build_dir, n) for n in names] + + def _get_plugin_path(self): + env_file = os.environ.get("LS2G_TEST_PLUGIN") + if env_file and os.path.exists(env_file): + return os.path.abspath(env_file) + + build_dirs = [] + env_dir = os.environ.get("LS2G_TEST_PLUGIN_DIR") + if env_dir: + build_dirs.append(env_dir) + # source-tree fallback (in-repo dev run, not the installed package) + build_dirs.append(os.path.join( + os.path.dirname(__file__), "..", "..", "examples", "external_algorithm", "build")) + + for build_dir in build_dirs: + for p in self._candidates_in(build_dir): + if os.path.exists(os.path.abspath(p)): + return os.path.abspath(p) return None def test_load_plugin_and_change_solver(self): path = self._get_plugin_path() if path is None: self.skipTest( - "Example plugin not built. " - "Build examples/external_solver/ first to run this test.") - from lightsim2grid import load_solver_plugin - load_solver_plugin(path) + "Example plugin not built. Build examples/external_algorithm/ (or set " + "LS2G_TEST_PLUGIN / LS2G_TEST_PLUGIN_DIR) to run this test.") + from lightsim2grid import load_algorithm_plugin + + # Idempotent: another test module in the same process may already have + # loaded it, and loading a plugin whose name is registered now raises. + if "DummyExternal" not in _make_grid().available_algorithm_names(): + load_algorithm_plugin(path) gm = _make_grid() - names = gm.available_solver_names() + names = gm.available_algorithm_names() self.assertIn("DummyExternal", names) gm.change_algorithm("DummyExternal") self.assertEqual(gm.get_algo_type(), AlgorithmType.Custom) From 044fceaec0d1fc51602421375625df1402976edc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:33:39 +0000 Subject: [PATCH 146/166] Harden plugin error-buffer handling (PR review) Address two review points on load_algorithm_plugin_impl's diagnostic buffer: - Name the 512 magic number: `static const std::size_t max_errbuf_size = 512`. - Guarantee the host can never over-read the buffer, by design and independently of the plugin: after calling the plugin entry point, force a NUL into the last slot (errbuf[max_errbuf_size - 1] = '\0') before reading it as a C string, so even a plugin that ignores the size contract cannot make the read run past the buffer. Refactored the load so the entry call + buffer handling happen exactly once (via an `unload` closure) instead of being duplicated per platform. The write side is already bounded by construction for plugins using LS2G_PLUGIN_ENTRY (ls2g_run_plugin_entry's write_err respects errbuf_len). Add the explicit test the reviewer asked for: a >512-char solver name driven through the real entry helper with the same 512-byte buffer the host uses, asserting truncation + in-bounds NUL termination (and valgrind-verified as out-of-bounds-write-free in the cpp_unit_tests workflow). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J97bpvJk9BS1qXnGPGYnGp Signed-off-by: Claude --- src/bindings/python/binding_module.cpp | 56 ++++++++++++++++---------- src/tests/test_plugin_registration.cpp | 29 +++++++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/bindings/python/binding_module.cpp b/src/bindings/python/binding_module.cpp index 2fede6c4..1f1f1539 100644 --- a/src/bindings/python/binding_module.cpp +++ b/src/bindings/python/binding_module.cpp @@ -10,6 +10,8 @@ #include "Ls2gAbiTag.hpp" #include "AlgorithmRegistry.hpp" +#include +#include #include #include @@ -67,29 +69,28 @@ // unloaded again so it leaves no trace; a successfully-registered one is // intentionally kept mapped for the life of the process, since the registry now // holds factories whose code lives in it. +// Size of the stack diagnostic buffer handed to a plugin's entry point. The +// entry point is told this size and its contract is to write at most this many +// bytes, NUL included; plugins built with LS2G_PLUGIN_ENTRY honour it by +// construction (the bounded write in ls2g::detail::ls2g_run_plugin_entry). +static const std::size_t max_errbuf_size = 512; + static void load_algorithm_plugin_impl(const std::string& path) { - char errbuf[512]; - errbuf[0] = '\0'; + // Platform-specific load + symbol lookup. Both branches leave `entry` set + // to the resolved entry point (or null) and `unload` able to undo the load, + // so the entry call and all buffer handling below happen exactly once. + ls2g::ls2g_plugin_entry_fn entry = nullptr; + std::function unload; #if defined(_WIN32) HMODULE handle = ::LoadLibraryA(path.c_str()); if (handle == nullptr) { throw std::runtime_error("lightsim2grid.load_algorithm_plugin: could not load '" + path + "' (LoadLibrary failed, error code " + std::to_string(::GetLastError()) + ")."); } - auto entry = reinterpret_cast( + unload = [handle]{ ::FreeLibrary(handle); }; + entry = reinterpret_cast( ::GetProcAddress(handle, "ls2g_register_plugin")); - if (entry == nullptr) { - ::FreeLibrary(handle); - throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + - "' is not a lightsim2grid plugin (no 'ls2g_register_plugin' entry point)."); - } - int rc = entry(&ls2g::AlgorithmRegistry::instance(), errbuf, sizeof(errbuf)); - if (rc != 0) { - ::FreeLibrary(handle); - throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + - "' failed to register: " + errbuf); - } #else ::dlerror(); // clear any stale error state void* handle = ::dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL); @@ -98,22 +99,35 @@ static void load_algorithm_plugin_impl(const std::string& path) throw std::runtime_error("lightsim2grid.load_algorithm_plugin: could not load '" + path + "': " + (e != nullptr ? e : "unknown dlopen error")); } + unload = [handle]{ ::dlclose(handle); }; ::dlerror(); - auto entry = reinterpret_cast( + entry = reinterpret_cast( ::dlsym(handle, "ls2g_register_plugin")); - const char* sym_err = ::dlerror(); - if (entry == nullptr || sym_err != nullptr) { - ::dlclose(handle); + if (::dlerror() != nullptr) entry = nullptr; +#endif + if (entry == nullptr) { + unload(); throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + "' is not a lightsim2grid plugin (no 'ls2g_register_plugin' entry point)."); } - int rc = entry(&ls2g::AlgorithmRegistry::instance(), errbuf, sizeof(errbuf)); + + // errbuf is written only by `entry`, which is told its full size and must + // not exceed it. Force a NUL into the last slot afterwards regardless: even + // a misbehaving plugin that ignored the size contract then cannot make the + // C-string read below run past the buffer -- the host side cannot over-read + // by construction, independently of what the plugin wrote. + char errbuf[max_errbuf_size]; + errbuf[0] = '\0'; + const int rc = entry(&ls2g::AlgorithmRegistry::instance(), errbuf, max_errbuf_size); + errbuf[max_errbuf_size - 1] = '\0'; + if (rc != 0) { - ::dlclose(handle); // reject: unload so a failed plugin leaves nothing behind + unload(); // reject: unload so a failed plugin leaves nothing behind throw std::runtime_error("lightsim2grid.load_algorithm_plugin: '" + path + "' failed to register: " + errbuf); } -#endif + // success: keep the library mapped for the life of the process, since the + // registry now holds factories whose code lives in it. } PYBIND11_MODULE(lightsim2grid_cpp, m) diff --git a/src/tests/test_plugin_registration.cpp b/src/tests/test_plugin_registration.cpp index 16be85e8..b7bb7eeb 100644 --- a/src/tests/test_plugin_registration.cpp +++ b/src/tests/test_plugin_registration.cpp @@ -33,6 +33,10 @@ ls2g::AlgorithmRegistry::Factory null_factory() { return []() -> std::unique_ptr { return nullptr; }; } +// Referenced from the captureless registration lambda in the overflow test +// below (a function pointer cannot capture, but may read a static). +std::string g_long_solver_name; + } // namespace TEST_CASE("register_all commits every name on success", "[plugin]") @@ -133,6 +137,31 @@ TEST_CASE("plugin entry helper: a short error buffer is never overflowed", "[plu REQUIRE(std::strlen(errbuf) <= N - 1); // never wrote past the buffer } +TEST_CASE("plugin entry helper: a solver name longer than the buffer cannot overflow it", "[plugin]") +{ + // The scenario from the review: a plugin registers a name so long that the + // resulting error message far exceeds the host's fixed 512-byte diagnostic + // buffer. The bounded write must truncate it, never overflow. Under valgrind + // (cpp_unit_tests workflow) this also proves there is no out-of-bounds write. + g_long_solver_name = std::string(1000, 'x'); // >> 512 + ls2g::AlgorithmRegistry::instance().register_solver(g_long_solver_name, null_factory()); + + constexpr std::size_t host_buf_size = 512; // matches load_algorithm_plugin_impl + char errbuf[host_buf_size]; + std::memset(errbuf, 'Z', sizeof(errbuf)); // start with no NUL anywhere + + int rc = ls2g::detail::ls2g_run_plugin_entry( + &ls2g::AlgorithmRegistry::instance(), errbuf, host_buf_size, + // staging the (already-registered) long name triggers the long + // "... is already registered" message, which contains the 1000-char name + +[](ls2g::PluginRegistrar& r) { r.add(g_long_solver_name, null_factory()); }, + ls2g::ls2g_current_abi_tag()); + + REQUIRE(rc != 0); + REQUIRE(errbuf[host_buf_size - 1] == '\0'); // terminated within bounds + REQUIRE(std::strlen(errbuf) <= host_buf_size - 1); // never wrote past the buffer +} + TEST_CASE("plugin entry helper: a null / zero-length buffer is handled safely", "[plugin]") { ls2g::AlgorithmRegistry::instance().register_solver("__plugin_nullbuf_dup__", null_factory()); From 133e262c4040cff6f3af1709793a6567994476b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 08:49:14 +0000 Subject: [PATCH 147/166] Add LSGrid::check_grid() whole-grid consistency validation Release wheels are built -O3 -DNDEBUG, so Eigen and std bounds checks are compiled out. Index fields carried by the grid (element bus ids, substation ids, position in the topology vector, generator slack and remote-regulated bus references) are used to index internal vectors on the powerflow and topology-update hot paths without range checks. set_state() (used by both the pickle and the fast binary format) previously validated only array lengths, not values -- so a well-formed but semantically poisoned state (e.g. an out-of-range bus id) loaded cleanly and then caused an out-of-bounds read/write on the next powerflow. Add a whole-grid validator: - GenericContainer gains a virtual check_valid() (default no-op) and a static check_all_finite() helper (real and complex). - OneSideContainer / OneSideContainer_PQ / GeneratorContainer / TwoSidesContainer / TwoSidesContainer_rxh_A override check_valid() to range-check bus_id / subid / pos_topo_vect, slack weights and regulated_bus_id, and to reject NaN/Inf in the electrical inputs (r, x, half-line shunts, thermal limits, p/q/vm/min_q/max_q targets). subid and pos_topo_vect are optional and only checked when populated. - LSGrid::check_grid() drives the per-container checks with the grid-wide bus/substation totals, then verifies the collected pos_topo_vect entries form a permutation of [0, dim_topo) (in range + unique). check_grid() is called at the end of LSGrid::set_state(), so loading a pickle or binary file that is well-formed but inconsistent now raises a clean std::out_of_range / std::runtime_error instead of causing an out-of-bounds access later. It runs in O(number of elements), off the solver hot path. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- src/core/LSGrid.cpp | 57 +++++++++++++ src/core/LSGrid.hpp | 15 ++++ .../element_container/GeneratorContainer.cpp | 59 ++++++++++++++ .../element_container/GeneratorContainer.hpp | 8 ++ .../element_container/GenericContainer.hpp | 48 +++++++++++ .../element_container/OneSideContainer.hpp | 80 +++++++++++++++++++ .../element_container/OneSideContainer_PQ.hpp | 22 +++++ .../element_container/TwoSidesContainer.hpp | 13 +++ .../TwoSidesContainer_rxh_A.hpp | 20 ++++- 9 files changed, 321 insertions(+), 1 deletion(-) diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 3c215524..4b1d1c9a 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -250,8 +250,65 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // fused-bus representative lookup -- must run after substations_ is restored // above, same reasoning as set_ls_to_orig() (validates against total_bus()). set_bus_fusion_rep(IntVect::Map(bus_fusion_rep.data(), bus_fusion_rep.size())); + + // Now that every container has been restored, validate the whole grid: a + // pickle or binary file is only length-checked while being read, so an + // out-of-range bus / substation / topo-vector index (or a NaN electrical + // value) would otherwise slip through and cause an out-of-bounds access on + // the next powerflow. check_grid() turns that into a clean exception here. + check_grid(); }; +void LSGrid::check_grid() const +{ + const int nb_bus = static_cast(substations_.nb_bus()); + const int nb_sub = substations_.nb_sub(); + + // Per-container range + finiteness checks. Each container appends the + // pos_topo_vect entries it carries (an optional field) to all_pos_topo_vect + // for the global permutation check below. + std::vector all_pos_topo_vect; + powerlines_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + trafos_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + shunts_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + generators_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + loads_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + sgens_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + storages_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + hvdc_lines_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + svcs_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + + // pos_topo_vect is grid2op-specific and optional: it is either set on every + // topology-participating element or on none. When set, the collected values + // must be a permutation of [0, dim_topo) -- that is exactly what makes it + // safe to index the (dim_topo-sized) arrays passed to update_topo(). K here + // is dim_topo; K distinct values all in [0, K) is precisely a permutation. + if(!all_pos_topo_vect.empty()) + { + const int dim_topo = static_cast(all_pos_topo_vect.size()); + std::vector seen(dim_topo, 0); + for(int pos : all_pos_topo_vect) + { + if((pos < 0) || (pos >= dim_topo)) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: a position in the topology vector (" << pos + << ") is out of range [0, " << dim_topo << "). The pos_topo_vect of all " + << "elements must form a permutation of [0, dim_topo)."; + throw std::out_of_range(exc_.str()); + } + if(seen[pos]) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the position " << pos << " in the topology vector is " + << "assigned to more than one element (pos_topo_vect values must be unique)."; + throw std::runtime_error(exc_.str()); + } + seen[pos] = 1; + } + } +} + void LSGrid::save_binary(const std::string & path, bool atomic) const { ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index a1cd465c..8325d5fd 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -513,6 +513,21 @@ class LS2G_API LSGrid final LSGrid::StateRes get_state() const ; void set_state(LSGrid::StateRes & my_state) ; + // Whole-grid consistency check. Verifies that every index the grid carries + // (element bus ids, substation ids, position in the topology vector, + // generator slack and remote-regulated bus references) is in range for this + // grid, and that the physical input arrays contain no NaN / +-Inf. Throws + // std::out_of_range on an out-of-range index and std::runtime_error on a + // structural / finiteness error. + // + // Called automatically at the end of set_state() (so loading a pickle or a + // binary file cannot leave an out-of-range index that would later cause an + // out-of-bounds read/write during a powerflow), and exposed to Python so the + // grid loaders (pandapower / pypowsybl / matpower / powermodels) can call it + // right after building a grid. Runs in O(number of elements), off the solver + // hot path. + void check_grid() const; + // fast binary serialization (additive alternative to pickle, see // BinaryArchive.hpp -- readable by any lightsim2grid version sharing // the same BINARY_FORMAT_VERSION) diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 66757932..5a044d36 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -11,6 +11,7 @@ #include #include +#include // for std::isfinite (check_valid) namespace ls2g { @@ -126,6 +127,64 @@ void GeneratorContainer::set_state(GeneratorContainer::StateRes & my_state) reset_results(); } +void GeneratorContainer::check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const +{ + // one-side (bus / subid / pos_topo_vect) + (p, q) finiteness checks + check_valid_osc_pq(nb_bus, nb_sub, substations, all_pos_topo_vect, "generator"); + + // generator-specific electrical inputs must be finite + check_all_finite(target_vm_pu_, "generator target_vm_pu"); + check_all_finite(min_q_, "generator min_q"); + check_all_finite(max_q_, "generator max_q"); + + // slack coherence + remote-regulated bus id range + const int nb_gen = nb(); + const bool has_slack_info = !gen_slackbus_.empty(); + const bool has_reg_info = regulated_bus_id_.size() > 0; + bool any_slack = false; + bool any_connected_slack = false; + for(int gen_id = 0; gen_id < nb_gen; ++gen_id) + { + if(has_reg_info) + { + // regulated bus may be -1 (no remote regulation / disconnected) + const int reg = regulated_bus_id_(gen_id); + if((reg != _deactivated_bus_id) && ((reg < 0) || (reg >= nb_bus))) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: generator id " << gen_id << " regulates bus id " + << reg << " which is out of range [0, " << nb_bus << ")."; + throw std::out_of_range(exc_.str()); + } + } + if(has_slack_info && gen_slackbus_[gen_id]) + { + any_slack = true; + const real_type w = gen_slack_weight_[gen_id]; + if((!std::isfinite(w)) || (w <= _tol_equal_float)) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: generator id " << gen_id + << " is flagged as a slack but has a non-positive or non-finite slack weight (" + << w << ")."; + throw std::runtime_error(exc_.str()); + } + if(status_[gen_id]) any_connected_slack = true; + } + } + // if a slack is declared at all, at least one slack generator must be connected + // (the powerflow cannot solve otherwise). We do NOT require a slack to exist: + // that stays the solver's responsibility, exactly as before. + if(any_slack && !any_connected_slack) + { + throw std::runtime_error("LSGrid::check_grid: at least one generator is flagged as a " + "slack, but none of the slack generators is connected."); + } +} + RealVect GeneratorContainer::get_slack_weights_solver( size_t nb_bus_solver, const SolverBusIdVect & id_grid_to_solver){ diff --git a/src/core/element_container/GeneratorContainer.hpp b/src/core/element_container/GeneratorContainer.hpp index ef3004cb..cb625196 100644 --- a/src/core/element_container/GeneratorContainer.hpp +++ b/src/core/element_container/GeneratorContainer.hpp @@ -93,6 +93,14 @@ class LS2G_API GeneratorContainer final: public OneSideContainer_PQ, public Iter GeneratorContainer::StateRes get_state() const; void set_state(GeneratorContainer::StateRes & my_state ); + // Whole-grid semantic validation (see GenericContainer::check_valid): the + // (p, q) one-side checks plus generator-specific ones -- slack weights and + // remote-regulated bus ids. + void check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const override; + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path, bool atomic = true) const; static GeneratorContainer load_binary(const std::string & path); diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index b4d597ad..f394ea2d 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -10,6 +10,7 @@ #define GENERIC_CONTAINER_H #include // for std::find +#include // for std::isfinite (see check_all_finite) #include "Eigen/Core" #include "Eigen/Dense" @@ -110,6 +111,28 @@ class LS2G_API GenericContainer : public BaseConstants // nothing to do by default }; + /** + Whole-grid semantic validation (see LSGrid::check_grid). + + Checks that every index this container carries (bus ids, substation ids, + position in the topology vector, slack references...) is in range for a + grid with `nb_bus` buses and `nb_sub` substations, and that the physical + (electrical) input arrays contain no NaN / +-Inf. Throws std::out_of_range + on a bad index and std::runtime_error on a structural / finiteness error. + + `all_pos_topo_vect` is an accumulator: each container appends the + `pos_topo_vect` values it actually carries (the field is optional and may + be empty), so LSGrid can afterwards check they form a valid permutation. + + The default does nothing; element containers override it. + **/ + virtual void check_valid(int /*nb_bus*/, + int /*nb_sub*/, + const SubstationContainer & /*substations*/, + std::vector & /*all_pos_topo_vect*/) const { + // nothing to validate by default + }; + void set_names(const std::vector & names){ names_ = names; } @@ -131,6 +154,31 @@ class LS2G_API GenericContainer : public BaseConstants if(static_cast(container.size()) != size) throw std::runtime_error(container_name + " do not have the proper size"); } + /** + check that every element of `v` is a finite number (no NaN, no +-Inf). + Works for real vectors (Eigen or std) and complex vectors (both real and + imaginary parts must be finite). Throws std::runtime_error otherwise. + Used by check_valid() to keep NaN / Inf out of the physical input arrays. + **/ + static bool _is_finite_val(real_type v) noexcept { return std::isfinite(v); } + static bool _is_finite_val(const cplx_type & v) noexcept { + return std::isfinite(v.real()) && std::isfinite(v.imag()); + } + template // a std::vector or an Eigen vector, real or complex + static void check_all_finite(const VectCLS & v, const std::string & name) + { + auto sz = v.size(); + for(decltype(sz) i = 0; i < sz; ++i) + { + if(!_is_finite_val(v[i])) + { + std::ostringstream exc_; + exc_ << name << " contains a non-finite value (NaN or +-Inf) at position " << i << "."; + throw std::runtime_error(exc_.str()); + } + } + } + /** activation / deactivation of elements **/ diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index dcc53ca9..79c5e3be 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -467,6 +467,15 @@ class OneSideContainer : public GenericContainer // nothing to do by default }; + // Whole-grid semantic validation (see GenericContainer::check_valid / LSGrid::check_grid). + void check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const override + { + check_valid_osc(nb_bus, nb_sub, substations, all_pos_topo_vect, "element"); + } + void _check_pos_topo_vect_filled(){ if((nb() > 0) && (pos_topo_vect_.size() == 0)){ // TODO DEBUG MODE: only check in debug mode @@ -493,6 +502,77 @@ class OneSideContainer : public GenericContainer Eigen::Ref get_res_q() {return res_q_;} Eigen::Ref get_res_v() {return res_v_;} + // Range-checks bus_id_ / subid_ / pos_topo_vect_ for a grid with `nb_bus` + // buses and `nb_sub` substations, and appends the (optional) pos_topo_vect + // entries to `all_pos_topo_vect`. `el_name` is used only in error messages. + // NB subid_ and pos_topo_vect_ are optional (may be empty) -- they are only + // checked when populated. + void check_valid_osc(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect, + const std::string & el_name) const + { + const int nb_el = nb(); + const bool has_subid = subid_.size() > 0; // optional + const bool has_topo = pos_topo_vect_.size() > 0; // optional + for(int el_id = 0; el_id < nb_el; ++el_id) + { + const int bus = bus_id_(el_id).cast_int(); + const bool connected = status_[el_id]; + if((bus != _deactivated_bus_id) && ((bus < 0) || (bus >= nb_bus))) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: " << el_name << " id " << el_id; + if(el_id < static_cast(names_.size())) exc_ << " ('" << names_[el_id] << "')"; + exc_ << " is on bus id " << bus << " which is out of range [0, " << nb_bus + << ") (only " << _deactivated_bus_id << " is allowed, meaning disconnected)."; + throw std::out_of_range(exc_.str()); + } + if(connected) + { + if(bus == _deactivated_bus_id) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: " << el_name << " id " << el_id + << " is marked as connected (its status is true) but its bus id is " + << _deactivated_bus_id << " (meaning disconnected)."; + throw std::runtime_error(exc_.str()); + } + if(!substations.is_bus_connected(GridModelBusId(bus))) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: " << el_name << " id " << el_id + << " is connected to bus id " << bus << " which is not an active bus."; + throw std::runtime_error(exc_.str()); + } + } + if(has_subid) + { + const int sub = subid_(el_id); + if((sub < 0) || (sub >= nb_sub)) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: " << el_name << " id " << el_id + << " has substation id " << sub << " out of range [0, " << nb_sub << ")."; + throw std::out_of_range(exc_.str()); + } + } + if(has_topo) + { + const int pos = pos_topo_vect_(el_id); + if(pos < 0) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: " << el_name << " id " << el_id + << " has a negative position in the topology vector (" << pos << ")."; + throw std::out_of_range(exc_.str()); + } + all_pos_topo_vect.push_back(pos); + } + } + } + protected: // physical properties diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index 5e9065cc..dff5eede 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -61,6 +61,15 @@ class OneSideContainer_PQ : public OneSideContainer Eigen::Ref get_target_p() const {return target_p_mw_;} + // Whole-grid semantic validation (see GenericContainer::check_valid). + void check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const override + { + check_valid_osc_pq(nb_bus, nb_sub, substations, all_pos_topo_vect, "element"); + } + // base function that can be called void gen_p_per_bus(std::vector & res) const override { @@ -124,6 +133,19 @@ class OneSideContainer_PQ : public OneSideContainer >; protected: + // check_valid() for elements carrying (p, q) targets: the OneSideContainer + // index checks plus finiteness of the target_p / target_q input arrays. + void check_valid_osc_pq(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect, + const std::string & el_name) const + { + check_valid_osc(nb_bus, nb_sub, substations, all_pos_topo_vect, el_name); + check_all_finite(target_p_mw_, el_name + " target_p_mw"); + check_all_finite(target_q_mvar_, el_name + " target_q_mvar"); + } + OneSideContainer_PQ::StateRes get_osc_pq_state() const // osc: one side element { std::vector target_p_mw(target_p_mw_.begin(), target_p_mw_.end()); diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index f10a4780..0c601bdb 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -120,6 +120,19 @@ class TwoSidesContainer : public GenericContainer // public generic API size_t nb() const { return side_1_.nb(); } + + // Whole-grid semantic validation (see GenericContainer::check_valid): + // each side is a full one-side container, so validate both. Derived + // classes (eg TwoSidesContainer_rxh_A) add the branch electrical checks. + void check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const override + { + side_1_.check_valid(nb_bus, nb_sub, substations, all_pos_topo_vect); + side_2_.check_valid(nb_bus, nb_sub, substations, all_pos_topo_vect); + } + GridModelBusId get_bus_side_1(int el_id) const {return side_1_.get_bus(el_id);} GridModelBusId get_bus_side_2(int el_id) const {return side_2_.get_bus(el_id);} // Per-side connectivity: an element can be `connected_global` (the diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 7282cd17..4adee714 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -158,7 +158,25 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer public: TwoSidesContainer_rxh_A() noexcept = default; ~TwoSidesContainer_rxh_A() noexcept override = default; - + + // Whole-grid semantic validation (see GenericContainer::check_valid): the + // two-sides (bus / subid / pos_topo_vect) checks plus finiteness of the + // branch electrical inputs (r, x, half-line shunts and thermal limits). + void check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const override + { + TwoSidesContainer::check_valid(nb_bus, nb_sub, substations, all_pos_topo_vect); + this->check_all_finite(r_, "branch r"); + this->check_all_finite(x_, "branch x"); + this->check_all_finite(h_side_1_, "branch h_side_1"); + this->check_all_finite(h_side_2_, "branch h_side_2"); + // thermal limits are optional (may be empty) + if(limit_a1_ka_.size() > 0) this->check_all_finite(limit_a1_ka_, "branch limit_a1_ka"); + if(limit_a2_ka_.size() > 0) this->check_all_finite(limit_a2_ka_, "branch limit_a2_ka"); + } + // pickle // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< From 227b86c091b58d2c59559d449610890959c22f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 08:54:32 +0000 Subject: [PATCH 148/166] Expose check_grid() to Python and call it from every grid loader Bind LSGrid::check_grid() as GridModel.check_grid() (with a docstring in the DocLSGrid help namespace), and call model.check_grid() at the end of each grid loader -- from_pandapower, from_powermodels (which also covers matpower, routed through it) and from_pypowsybl -- right after the grid is built. set_state() already validates grids loaded from a pickle or the binary format; this covers the construction path (the loaders build via init_*, not set_state) and surfaces a format-specific error early when a source grid is inconsistent. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- .../network/from_pandapower/initLSGrid.py | 4 +++ .../network/from_powermodels/initLSGrid.py | 6 ++++ .../network/from_pypowsybl/initLSGrid.py | 5 ++++ src/bindings/python/binding_lsgrid.cpp | 3 ++ src/core/help_fun_msg.cpp | 29 +++++++++++++++++++ src/core/help_fun_msg.hpp | 2 ++ 6 files changed, 49 insertions(+) diff --git a/lightsim2grid/network/from_pandapower/initLSGrid.py b/lightsim2grid/network/from_pandapower/initLSGrid.py index bf8bad2f..0d936fcd 100644 --- a/lightsim2grid/network/from_pandapower/initLSGrid.py +++ b/lightsim2grid/network/from_pandapower/initLSGrid.py @@ -171,4 +171,8 @@ def init(pp_net: "pandapower.auxiliary.pandapowerNet", # deal with slack bus _aux_add_slack(model, pp_net, pp_to_ls, pp_orig_file) + # make sure the grid we just built is internally consistent (bus / substation + # / topology-vector indices in range, no NaN/Inf in the physical inputs) + model.check_grid() + return model diff --git a/lightsim2grid/network/from_powermodels/initLSGrid.py b/lightsim2grid/network/from_powermodels/initLSGrid.py index a2917f81..6dffc5dd 100644 --- a/lightsim2grid/network/from_powermodels/initLSGrid.py +++ b/lightsim2grid/network/from_powermodels/initLSGrid.py @@ -135,4 +135,10 @@ def init(network: dict, # init the HVDC lines, if any _aux_add_dc_line(model, network, pm_to_ls, isolated_ls_bus) + # make sure the grid we just built is internally consistent (bus / substation + # / topology-vector indices in range, no NaN/Inf in the physical inputs). + # This also covers grids loaded from matpower, which are converted to the + # powermodels format and routed through this function. + model.check_grid() + return model diff --git a/lightsim2grid/network/from_pypowsybl/initLSGrid.py b/lightsim2grid/network/from_pypowsybl/initLSGrid.py index 59d21974..f38fba59 100644 --- a/lightsim2grid/network/from_pypowsybl/initLSGrid.py +++ b/lightsim2grid/network/from_pypowsybl/initLSGrid.py @@ -305,6 +305,11 @@ def init(net : pypo.network.Network, model.set_line_to_sub2_id(lex_sub["sub_id"].values) model.set_trafo_to_sub1_id(tor_sub["sub_id"].values) model.set_trafo_to_sub2_id(tex_sub["sub_id"].values) + + # make sure the grid we just built is internally consistent (bus / substation + # / topology-vector indices in range, no NaN/Inf in the physical inputs) + model.check_grid() + if not return_sub_id: return model else: diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 0b83c586..0549fe1c 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -67,6 +67,9 @@ views (eg `LightsimResultNetwork`), never by any C++ powerflow logic. add_pickle(lsgrid_cls, "LSGrid"); add_binary_serialization(lsgrid_cls); lsgrid_cls + // whole-grid consistency validation + .def("check_grid", &LSGrid::check_grid, DocLSGrid::check_grid.c_str()) + // algo config (scaling/refactor policy params) .def("get_ac_algo_config", &LSGrid::get_ac_algo_config, "Return the AC solver's AlgoConfig (scaling/refactor policy type and parameters).") diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index a8dd654a..e1d24bab 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -1843,6 +1843,35 @@ const std::string DocLSGrid::LSGrid = R"mydelimiter( )mydelimiter"; +const std::string DocLSGrid::check_grid = R"mydelimiter( + Check that the grid is internally consistent and safe to run a powerflow on. + + It verifies that every index the grid carries is in range: the bus id of each + element (load, generator, static generator, storage, shunt, line, transformer, + hvdc line, static var compensator), the substation id and the position in the + topology vector (both optional), and the generator slack / remote-regulated bus + references. It also checks that the physical input arrays (line/transformer r, + x and shunt values, thermal limits, and the p / q / voltage set-points) contain + no ``NaN`` nor infinite value. + + This is called automatically when a grid is loaded (from a pickle or from the + fast binary format), and by the grid loaders (from pandapower, pypowsybl, + matpower or powermodels). You normally do not need to call it yourself; it is + exposed so you can validate a grid you built or modified by hand. + + Raises + ------ + An ``IndexError`` (C++ ``std::out_of_range``) if an index is out of range, or a + ``RuntimeError`` (C++ ``std::runtime_error``) on a structural inconsistency or a + non-finite physical value. Returns ``None`` if the grid is consistent. + + Notes + ----- + Runs in time proportional to the number of elements in the grid, so it is cheap + compared to a powerflow. + +)mydelimiter"; + const std::string DocLSGrid::change_algorithm = R"mydelimiter( This function allows to control which solver is used during the powerflow. See the section :ref:`available-powerflow-solvers` for more information about them. diff --git a/src/core/help_fun_msg.hpp b/src/core/help_fun_msg.hpp index 5ec75581..25835acd 100644 --- a/src/core/help_fun_msg.hpp +++ b/src/core/help_fun_msg.hpp @@ -181,6 +181,8 @@ struct LS2G_API DocLSGrid static const std::string LSGrid; + static const std::string check_grid; + static const std::string change_algorithm; static const std::string available_algorithm_names; static const std::string available_default_algorithms; From 2e18a3e0442e19decd3ab308f72ae951aefdbfc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 08:59:01 +0000 Subject: [PATCH 149/166] Sanity-check solver output before consuming it (plugin seam) A converged solve reads V/Va/Vm back from the algorithm and indexes them, without a size or finiteness check, in compute_results() and _get_results_back_to_orig_nodes(). Built-in solvers always honour the contract, but a plugin / custom solver reached through the same BaseAlgo interface may not: a wrong-sized V would cause an out-of-bounds read, and non-finite values would silently propagate NaN/Inf into every element result. Add LSGrid::_check_solver_output(), called at the top of process_results() right after a solve reports convergence: - V/Va/Vm not sized to the solver's bus count = a broken contract -> throw std::runtime_error naming the algorithm (catchable in Python); - non-finite values -> mark the solve non-converged (ErrorType::InifiniteValue) and return false, so results are reset instead of propagating NaN. A well-behaved solver already reports this itself; this covers the ones that do not. Adds a small BaseAlgo::set_error() (forwarded by AlgorithmSelector) so the non-convergence can be recorded. The check is O(nb_bus) once per solve and covers built-in and plugin solvers alike. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- src/core/AlgorithmSelector.hpp | 4 +++ src/core/LSGrid.cpp | 38 +++++++++++++++++++++++ src/core/LSGrid.hpp | 8 +++++ src/core/powerflow_algorithm/BaseAlgo.hpp | 7 +++++ 4 files changed, 57 insertions(+) diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 0e91a4ab..9f22696d 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -335,6 +335,10 @@ class LS2G_API AlgorithmSelector final return get_prt_solver("get_error", true)->get_error(); } + void set_error(ErrorType error) { + get_prt_solver("set_error", true)->set_error(error); + } + int get_nb_iter() const { return get_prt_solver("get_nb_iter", true)->get_nb_iter(); } diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 4b1d1c9a..80cb45c8 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -1199,6 +1199,13 @@ void LSGrid::process_results(bool conv, bool ac, SolverBusIdVect & id_me_to_solver) { + if (conv){ + // A (possibly plugin / custom) solver can claim convergence but return + // malformed voltages. Validate their size/finiteness before we index them + // below (compute_results / _get_results_back_to_orig_nodes both index the + // solver vectors with an unchecked operator()). + conv = _check_solver_output(ac); + } if (conv){ if(compute_results_){ // compute the results of the flows, P,Q,V of loads etc. @@ -1221,6 +1228,37 @@ void LSGrid::process_results(bool conv, } } +bool LSGrid::_check_solver_output(bool ac) +{ + const Eigen::Ref V = ac ? _algo.get_V() : _dc_algo.get_V(); + const Eigen::Ref Va = ac ? _algo.get_Va() : _dc_algo.get_Va(); + const Eigen::Ref Vm = ac ? _algo.get_Vm() : _dc_algo.get_Vm(); + const int nb_bus_solver = ac ? static_cast(id_ac_solver_to_me_.size()) + : static_cast(id_dc_solver_to_me_.size()); + const char * algo_name = ac ? "AC" : "DC"; + + if((V.size() != nb_bus_solver) || (Va.size() != nb_bus_solver) || (Vm.size() != nb_bus_solver)) + { + // wrong size = the solver broke its contract. This would cause an + // out-of-bounds read in compute_results / _get_results_back_to_orig_nodes. + std::ostringstream exc_; + exc_ << "LSGrid::process_results: the " << algo_name << " algorithm reported convergence but " + << "returned voltage vectors of an unexpected size (V: " << V.size() << ", Va: " << Va.size() + << ", Vm: " << Vm.size() << ", while the solver problem has " << nb_bus_solver + << " buses). This is a bug in the (possibly plugin) solver."; + throw std::runtime_error(exc_.str()); + } + if((!V.allFinite()) || (!Va.allFinite()) || (!Vm.allFinite())) + { + // Non-finite voltage: a well-behaved solver reports this itself + // (ErrorType::InifiniteValue, non-convergence); a misbehaving one may not. + // Treat it as a non-converged solve so no NaN/Inf propagates to the results. + (ac ? _algo : _dc_algo).set_error(ErrorType::InifiniteValue); + return false; + } + return true; +} + void LSGrid::init_converter_bus_id(SolverBusIdVect& id_me_to_solver, GlobalBusIdVect& id_solver_to_me){ diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 8325d5fd..6092ad21 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1997,6 +1997,14 @@ class LS2G_API LSGrid final void process_results(bool conv, CplxVect & res, const Eigen::Ref & Vinit, bool ac, SolverBusIdVect & id_me_to_solver); + // Sanity-check the voltages a (possibly plugin) solver returned before they + // are consumed. A wrong-sized V/Va/Vm is a contract violation -> throws + // std::runtime_error. Non-finite values -> the solve is marked as + // non-converged (ErrorType::InifiniteValue) and this returns false, so no + // NaN/Inf propagates and no out-of-bounds access happens downstream. + // Returns true when the outputs are usable. + bool _check_solver_output(bool ac); + /** Compute the results vector from the Va, Vm post powerflow **/ diff --git a/src/core/powerflow_algorithm/BaseAlgo.hpp b/src/core/powerflow_algorithm/BaseAlgo.hpp index e3422ef7..e57e7ca5 100644 --- a/src/core/powerflow_algorithm/BaseAlgo.hpp +++ b/src/core/powerflow_algorithm/BaseAlgo.hpp @@ -296,6 +296,13 @@ class LS2G_API BaseAlgo : public BaseConstants ErrorType get_error() const { return err_; } + // Force the reported error state. Used by LSGrid to mark a solve as + // non-converged when a (possibly plugin) solver returned non-finite + // voltages while still claiming convergence -- see + // LSGrid::process_results / LSGrid::_check_solver_output. + void set_error(ErrorType error) { + err_ = error; + } int get_nb_iter() const { return nr_iter_; } From 2d653cf7619ad159ed03b7b1a213d4ab5fb7e64d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 09:04:52 +0000 Subject: [PATCH 150/166] Add C++ tests for check_grid() and the solver-output check New test_check_grid.cpp (Catch2), wired into the lightsim2grid_unit_tests binary so it runs under ctest and valgrind (cpp_unit_tests workflow): - a valid grid, and its get_state/set_state round-trip, are accepted; - a state poisoned with an out-of-range or negative bus id, a connected element on the -1 bus, a slack generator with a non-positive weight, an out-of-range remote-regulated bus id, or a NaN physical value is rejected by set_state() with std::out_of_range / std::runtime_error -- never a crash; - pos_topo_vect is accepted as a valid permutation and rejected when duplicated or out of range; - a solver returning a wrong-sized voltage vector makes the powerflow throw (instead of an out-of-bounds read), and one returning non-finite voltages is reported as non-converged (empty result), with no NaN leaking out. Running the whole binary under valgrind is what proves the poisoned inputs cause no out-of-bounds access. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- src/tests/CMakeLists.txt | 1 + src/tests/test_check_grid.cpp | 293 ++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/tests/test_check_grid.cpp diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9d8e69b7..81d80ecf 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -57,6 +57,7 @@ add_executable(lightsim2grid_unit_tests test_case_exotic_elements.cpp test_ls2g_abi_tag.cpp test_plugin_registration.cpp + test_check_grid.cpp ) target_link_libraries(lightsim2grid_unit_tests PRIVATE lightsim2grid_core Catch2::Catch2WithMain) diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp new file mode 100644 index 00000000..0d5b7190 --- /dev/null +++ b/src/tests/test_check_grid.cpp @@ -0,0 +1,293 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Tests for LSGrid::check_grid() (the whole-grid consistency validator) and for +// the solver-output sanity check at the algorithm->grid seam. Everything is +// driven from C++, no python / grid2op / external grid file. +// +// The concern: release wheels are built -O3 -DNDEBUG, so an out-of-range index +// (bus / substation / topology-vector) restored from a well-formed but poisoned +// pickle or binary file would cause an out-of-bounds read/write on the next +// powerflow rather than a clean error. check_grid() (called at the end of +// set_state) must turn every such case into a std::out_of_range / runtime_error. +// These cases also run under valgrind in the cpp_unit_tests workflow, which is +// what proves no out-of-bounds access happens on the poisoned inputs. + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "LSGrid.hpp" +#include "AlgorithmRegistry.hpp" +#include "powerflow_algorithm/BaseAlgo.hpp" + +using ls2g::LSGrid; +using ls2g::CplxVect; +using ls2g::RealVect; +using ls2g::IntVect; +using ls2g::cplx_type; +using ls2g::real_type; + +namespace { + +// Three 138 kV buses (3 substations, 1 busbar each), two lines 0-1 and 1-2, +// one 50 MW / 10 MVar load at bus 2 and one slack generator (1.02 pu) at bus 0. +// A valid, internally-consistent grid: check_grid() must accept it. +LSGrid make_valid_grid() +{ + LSGrid grid; + grid.set_sn_mva(100.); + grid.set_init_vm_pu(1.0); + + RealVect bus_vn_kv(3); + bus_vn_kv << 138., 138., 138.; + grid.init_bus(3, 1, bus_vn_kv, 0, 0); + + RealVect branch_r(2), branch_x(2); + branch_r << 0.01, 0.01; + branch_x << 0.1, 0.1; + const CplxVect branch_h = CplxVect::Zero(2); + Eigen::VectorXi from_id(2), to_id(2); + from_id << 0, 1; + to_id << 1, 2; + grid.init_powerlines(branch_r, branch_x, branch_h, from_id, to_id); + + RealVect load_p(1), load_q(1); + load_p << 50.; + load_q << 10.; + Eigen::VectorXi load_bus(1); + load_bus << 2; + grid.init_loads(load_p, load_q, load_bus); + + RealVect gen_p(1), gen_v(1), gen_min_q(1), gen_max_q(1); + gen_p << 0.; + gen_v << 1.02; + gen_min_q << -1000.; + gen_max_q << 1000.; + Eigen::VectorXi gen_bus(1); + gen_bus << 0; + grid.init_generators(gen_p, gen_v, gen_min_q, gen_max_q, gen_bus); + grid.add_gen_slackbus(0, 1.); + return grid; +} + +CplxVect flat_start(const LSGrid & grid) +{ + return CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); +} + +// --- accessors into the (deeply nested) StateRes tuple ----------------------- +// LOAD_ID -> LoadContainer::StateRes = tuple +// -> [0] OneSideContainer_PQ::StateRes = tuple +// -> [0] OneSideContainer::StateRes = tuple +std::vector& load_bus_id(LSGrid::StateRes & st) +{ + return std::get<1>(std::get<0>(std::get<0>(std::get(st)))); +} +std::vector& load_p_mw(LSGrid::StateRes & st) +{ + return std::get<1>(std::get<0>(std::get(st))); +} +// GEN_ID -> GeneratorContainer::StateRes = tuple +std::vector& gen_slack_weight(LSGrid::StateRes & st) +{ + return std::get<7>(std::get(st)); +} +std::vector& gen_regulated_bus(LSGrid::StateRes & st) +{ + return std::get<8>(std::get(st)); +} + +// A BaseAlgo that claims convergence but returns V/Va/Vm one entry too long. +class WrongSizeAlgo : public ls2g::BaseAlgo { +public: + WrongSizeAlgo() : ls2g::BaseAlgo(/*is_ac=*/true) {} + bool compute_pf(const ls2g::EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & V, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/, + int /*max_iter*/, real_type /*tol*/) override + { + const int bad = static_cast(V.size()) + 1; + V_ = CplxVect::Constant(bad, cplx_type(1., 0.)); + Va_ = RealVect::Zero(bad); + Vm_ = RealVect::Constant(bad, 1.); + n_ = bad; + nr_iter_ = 1; + err_ = ls2g::ErrorType::NoError; // wrongly claims success + return true; + } +}; + +// A BaseAlgo that claims convergence but injects a NaN into the result. +class NaNAlgo : public ls2g::BaseAlgo { +public: + NaNAlgo() : ls2g::BaseAlgo(/*is_ac=*/true) {} + bool compute_pf(const ls2g::EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & V, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/, + int /*max_iter*/, real_type /*tol*/) override + { + V_ = V; + if (V_.size() > 0) V_(0) = cplx_type(std::numeric_limits::quiet_NaN(), 0.); + Va_ = V_.array().arg(); + Vm_ = V_.array().abs(); + n_ = static_cast(V_.size()); + nr_iter_ = 1; + err_ = ls2g::ErrorType::NoError; // wrongly claims success + return true; + } +}; + +} // namespace + +TEST_CASE("check_grid accepts a valid grid and its state round-trip", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + CHECK_NOTHROW(grid.check_grid()); + + LSGrid::StateRes st = grid.get_state(); + LSGrid restored; + CHECK_NOTHROW(restored.set_state(st)); // set_state runs check_grid internally +} + +TEST_CASE("check_grid rejects an out-of-range bus id on load (set_state)", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(load_bus_id(st).size() == 1); + load_bus_id(st)[0] = 9999; // >> nb_bus (3) + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("check_grid rejects a negative (non-sentinel) bus id", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + load_bus_id(st)[0] = -5; // -1 would mean "disconnected"; -5 is invalid + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("check_grid rejects a connected element sitting on the -1 (disconnected) bus", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + load_bus_id(st)[0] = -1; // sentinel, but the load's status stays connected + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects a slack generator with a non-positive weight", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(gen_slack_weight(st).size() == 1); + gen_slack_weight(st)[0] = 0.; // gen 0 is the (only) slack + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects an out-of-range remote-regulated bus id", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + gen_regulated_bus(st)[0] = 9999; // >> nb_bus + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("check_grid rejects a non-finite physical value (NaN load p)", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + load_p_mw(st)[0] = std::numeric_limits::quiet_NaN(); + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid accepts a valid pos_topo_vect permutation", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + // only the load and the generator carry a pos_topo_vect here: {0, 1} is a + // valid permutation of [0, 2). + IntVect load_pos(1); load_pos << 0; + IntVect gen_pos(1); gen_pos << 1; + grid.set_load_pos_topo_vect(load_pos); + grid.set_gen_pos_topo_vect(gen_pos); + CHECK_NOTHROW(grid.check_grid()); +} + +TEST_CASE("check_grid rejects a duplicated pos_topo_vect entry", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + IntVect load_pos(1); load_pos << 0; + IntVect gen_pos(1); gen_pos << 0; // collides with the load's slot + grid.set_load_pos_topo_vect(load_pos); + grid.set_gen_pos_topo_vect(gen_pos); + CHECK_THROWS_AS(grid.check_grid(), std::runtime_error); +} + +TEST_CASE("check_grid rejects an out-of-range pos_topo_vect entry", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + IntVect load_pos(1); load_pos << 0; + IntVect gen_pos(1); gen_pos << 7; // only 2 topo slots exist -> out of range + grid.set_load_pos_topo_vect(load_pos); + grid.set_gen_pos_topo_vect(gen_pos); + CHECK_THROWS_AS(grid.check_grid(), std::out_of_range); +} + +TEST_CASE("a solver returning a wrong-sized voltage vector is rejected, not indexed", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__check_grid_wrong_size__", + [] { return std::unique_ptr(new WrongSizeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__check_grid_wrong_size__"); + CHECK_THROWS_AS(grid.ac_pf(flat_start(grid), 20, 1e-8), std::runtime_error); +} + +TEST_CASE("a solver returning non-finite voltages is reported as non-converged", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__check_grid_nan__", + [] { return std::unique_ptr(new NaNAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__check_grid_nan__"); + CplxVect res; + CHECK_NOTHROW(res = grid.ac_pf(flat_start(grid), 20, 1e-8)); + CHECK(res.size() == 0); // diverged => empty result + CHECK_FALSE(grid.get_algo().converged()); // marked non-converged, no NaN leaked +} From f9ea7023754c5dcf0704da3685a67cd980417376 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 09:07:15 +0000 Subject: [PATCH 151/166] Add Python tests for check_grid() and run them under ASan/UBSan New lightsim2grid/tests/test_check_grid.py, added to the module list run by the asan_ubsan job in .github/workflows/sanitizers.yml so it executes under AddressSanitizer + UndefinedBehaviorSanitizer: - a real pandapower grid (case14) loads and passes check_grid() -- the loader calls it, so a false positive would make the loader itself raise; - check_grid() is reachable from Python and rejects a duplicated, out-of-range or negative topology-vector position with a clean IndexError / RuntimeError; - a valid grid round-trips through pickle (which runs check_grid on load). The deep set_state value-poison cases (bus / substation ids, slack weights, NaN inputs) are covered by the C++ suite, which also runs under valgrind. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- .github/workflows/sanitizers.yml | 3 +- lightsim2grid/tests/test_check_grid.py | 85 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 lightsim2grid/tests/test_check_grid.py diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 1156e33f..989f03cc 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -67,7 +67,8 @@ jobs: test_DCSecurityAnlysis \ test_SecurityAnlysis_cpp \ test_ContingencyAnalysis_limit_violations \ - test_ContingencyAnalysis_split + test_ContingencyAnalysis_split \ + test_check_grid debug_asserts: # Release wheels are compiled with NDEBUG, which silences every Eigen diff --git a/lightsim2grid/tests/test_check_grid.py b/lightsim2grid/tests/test_check_grid.py new file mode 100644 index 00000000..3737b734 --- /dev/null +++ b/lightsim2grid/tests/test_check_grid.py @@ -0,0 +1,85 @@ +# Copyright (c) 2020-2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +""" +Tests for ``LSGrid.check_grid()`` (the whole-grid consistency validator) from +Python. + +``check_grid()`` range-checks every index the grid carries (bus / substation / +topology-vector) and the finiteness of the physical inputs; it is called +automatically when a grid is loaded (pickle / binary format, via ``set_state``) +and by every grid loader (pandapower / pypowsybl / matpower / powermodels). The +deep ``set_state`` poison cases are covered by the C++ suite (test_check_grid.cpp, +also run under ASan/UBSan/valgrind); here we check the Python-visible behaviour: + + * a real grid loaded from pandapower passes (the loader calls check_grid, so a + false positive would make the loader itself raise); + * check_grid() is reachable from Python and rejects an inconsistent + topology-vector with a clean exception (``IndexError`` for an out-of-range + index -- C++ ``std::out_of_range`` --, ``RuntimeError`` for a duplicate -- + C++ ``std::runtime_error``), never a crash. +""" + +import unittest +import warnings +import numpy as np + +import pandapower.networks as pn +from lightsim2grid.network import init_from_pandapower + + +# an id far above any element count, but still inside the C `int` range +BIG = 10 ** 6 + + +class TestCheckGrid(unittest.TestCase): + def setUp(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + net = pn.case14() + self.grid = init_from_pandapower(net) # the loader calls check_grid() + self.n_load = len(self.grid.get_loads()) + self.assertGreater(self.n_load, 1) # case14 has several loads + + def test_loader_accepts_valid_grid(self): + # init_from_pandapower already ran check_grid() in setUp without raising; + # calling it again explicitly must also succeed and return None. + self.assertIsNone(self.grid.check_grid()) + + def test_duplicated_pos_topo_vect_is_rejected(self): + # put every load at the same position in the topology vector: the collected + # pos_topo_vect entries can then no longer be a permutation of [0, dim_topo). + self.grid.set_load_pos_topo_vect(np.zeros(self.n_load, dtype=np.int32)) + with self.assertRaises(RuntimeError): + self.grid.check_grid() + + def test_out_of_range_pos_topo_vect_is_rejected(self): + # distinct positions, but one is far out of range. + pos = np.arange(self.n_load, dtype=np.int32) + pos[-1] = BIG + self.grid.set_load_pos_topo_vect(pos) + with self.assertRaises(IndexError): + self.grid.check_grid() + + def test_negative_pos_topo_vect_is_rejected(self): + pos = np.arange(self.n_load, dtype=np.int32) + pos[0] = -1 + self.grid.set_load_pos_topo_vect(pos) + with self.assertRaises(IndexError): + self.grid.check_grid() + + def test_pickle_roundtrip_of_valid_grid(self): + # pickling goes through set_state on load, which runs check_grid(); a valid + # grid must round-trip cleanly. + import pickle + restored = pickle.loads(pickle.dumps(self.grid)) + self.assertIsNone(restored.check_grid()) + + +if __name__ == "__main__": + unittest.main() From 65641018275ba1e34d5fdf8180f9e7f41ebce64c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 09:08:56 +0000 Subject: [PATCH 152/166] Document the trust boundaries and check_grid() (docs + CHANGELOG) Add docs/security.rst (linked from the technical-documentation toctree): a "Security and trust boundaries" page stating plainly that unpickling a grid and loading a solver plugin are trusted-input-only by design (arbitrary code execution), while the binary format is hardened against untrusted input and validated with check_grid() on load. It documents check_grid() as public API and the solver-output contract for plugin authors. Update CHANGELOG.rst with the check_grid() additions, the automatic validation on load / in the loaders, the solver-output sanity check, and the new security page. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- CHANGELOG.rst | 18 ++++++++ docs/index.rst | 1 + docs/security.rst | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 docs/security.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5d20cf17..be404464 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -113,6 +113,24 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding [0.14.0] 2026-xx-yy --------------------- +- [ADDED] ``GridModel.check_grid()`` (C++ ``LSGrid::check_grid()``): a whole-grid + consistency check that verifies every index the grid carries (element bus ids, + substation ids, position in the topology vector, generator slack and + remote-regulated bus references) is in range, and that the physical input arrays + contain no ``NaN`` / ``Inf``. It raises ``IndexError`` / ``RuntimeError`` on an + inconsistency. +- [ADDED] the grid is now validated automatically with ``check_grid()`` when it is + loaded (from a pickle or the binary format, via ``set_state``) and by every grid + loader (``init_from_pandapower`` / ``init_from_pypowsybl`` / ``init_from_matpower`` + / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an + out-of-range bus id in a crafted binary file) now raises a clean exception instead + of causing an out-of-bounds access during the next powerflow. +- [ADDED] a sanity check on the voltages returned by a (possibly plugin / custom) + solver before they are consumed: a wrong-sized result raises, and a non-finite one + is reported as a non-converged solve instead of propagating ``NaN`` / ``Inf``. +- [ADDED] a "Security" documentation page describing the trust boundaries (pickle and + solver plugins are trusted-input-only; the binary format is hardened against + untrusted input and validated with ``check_grid`` on load). - [BREAKING] For plugin developers (C++ side): solver plugins no longer self-register from a static ``AlgorithmRegistrar`` constructor firing during ``dlopen``. Instead a plugin writes a ``void(ls2g::PluginRegistrar&)`` registration function and exposes it diff --git a/docs/index.rst b/docs/index.rst index d00dc70e..56f0ae35 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -64,6 +64,7 @@ This is a work in progress at the moment cpp_library solver_plugin binary_serialization + security Indices and tables diff --git a/docs/security.rst b/docs/security.rst new file mode 100644 index 00000000..ab1bcbd5 --- /dev/null +++ b/docs/security.rst @@ -0,0 +1,109 @@ +.. _security: + +Security and trust boundaries +============================= + +This page describes what lightsim2grid does, and does **not**, protect you from +when you load a grid or a solver that you did not produce yourself. It is meant +to help you decide which inputs are safe to accept from a third party. + +The short version +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Channel + - Trust required + * - Unpickling a grid (``pickle.load`` / ``joblib`` / ...) + - **Trusted input only.** Loading a pickle can execute arbitrary Python + code *by design* — this is a property of Python's ``pickle`` module, not + of lightsim2grid. Never unpickle a grid (or anything else) that came from + an untrusted source. + * - Loading a solver plugin (:func:`lightsim2grid.load_algorithm_plugin`) + - **Trusted code only.** A plugin is a native shared library that runs + in-process; once loaded it can do anything the host process can. Only + load plugins you built or otherwise trust. + * - Loading a grid from the fast binary format (``save_binary`` / + ``load_binary``) + - **Hardened against untrusted input.** See below. + * - Building a grid from a source file (pandapower, pypowsybl, matpower, + powermodels) + - **Validated on load.** See below. + +Pickle and plugins are trusted-input-only by design +--------------------------------------------------- + +Unpickling arbitrary data and loading a native plugin are both, fundamentally, +ways of running code chosen by whoever produced the input. No amount of +validation inside lightsim2grid changes that, so the only safe rule is: **do not +load a pickle or a plugin from a source you do not trust.** This is the same +posture as ``numpy``, ``pandas`` (``read_pickle``), ``torch.load`` and any other +library that supports pickling. + +The version string embedded in a pickled grid is a *compatibility* check (it +refuses to load a grid saved by a different lightsim2grid version); it is **not** +a security control. + +The binary format is the channel to use for untrusted data +---------------------------------------------------------- + +The fast binary format (:ref:`binary-serialization`) was designed as a safe, +additive alternative to pickle. Loading a binary file never executes code from +the file, and it is validated at two levels: + +* **Byte level.** The reader checks the file's magic number, format version and + object type, validates every length field against the actual file size + *before* allocating (so a corrupted size cannot trigger a huge allocation), + and requires the file to be fully consumed. Malformed, truncated or corrupted + bytes raise ``RuntimeError``. +* **Semantic level.** After the bytes are read back into a grid, ``check_grid()`` + runs automatically (see below): a file that is byte-wise well-formed but + *inconsistent* — for example one with an out-of-range bus id — raises a clean + exception instead of leaving the grid in a state that would cause an + out-of-bounds memory access on the next powerflow. + +``check_grid()``: whole-grid consistency validation +--------------------------------------------------- + +:py:meth:`LSGrid.check_grid` (also available as ``GridModel.check_grid``) verifies that a grid +is internally consistent and safe to run a powerflow on. It checks that every +index the grid carries is in range — the bus id of every element, the substation +id and the position in the topology vector (both optional), and the generator +slack and remote-regulated bus references — and that the physical input arrays +(line / transformer ``r``, ``x`` and shunt values, thermal limits, and the +``p`` / ``q`` / voltage set-points) contain no ``NaN`` or infinite value. It +raises ``IndexError`` (an out-of-range index) or ``RuntimeError`` (a structural +inconsistency or a non-finite value), and returns ``None`` for a consistent grid. + +You normally do not need to call it yourself: + +* it runs automatically when a grid is loaded from a pickle or the binary format + (via ``set_state``); +* every grid loader (``init_from_pandapower``, ``init_from_pypowsybl``, + ``init_from_matpower``, ``init_from_powermodels``) calls it before returning. + +It is exposed so you can validate a grid you built or modified by hand. It runs +in time proportional to the number of elements in the grid, so it is cheap +compared to a powerflow. + +.. note:: + + Release wheels are compiled with ``-O3 -DNDEBUG``, which removes Eigen's and + the standard library's own bounds checks. ``check_grid()`` (and the size / + finiteness check applied to the voltages a solver returns) are deliberately + *not* gated behind that flag: they always run, because they are what turns a + malformed input into a clean exception rather than an out-of-bounds access. + +A note for solver-plugin authors +--------------------------------- + +A solver plugin is trusted code, so lightsim2grid does not try to sandbox it. +It does, however, sanity-check the voltages your ``compute_pf`` returns before +using them: if you report convergence but return a voltage vector of the wrong +size, the powerflow raises ``RuntimeError`` (this would otherwise be an +out-of-bounds read); if you return non-finite values, the solve is reported as +non-converged rather than propagating ``NaN`` / ``Inf`` into the results. Writing +a correct plugin therefore means returning ``V`` / ``Va`` / ``Vm`` sized to the +solver's bus count, and reporting non-convergence yourself when appropriate. From 39027ae00c2c72d8398ca882aaccb727b21b7c6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 11:32:37 +0000 Subject: [PATCH 153/166] Run test_LSGrid_out_of_bounds under ASan/UBSan test_LSGrid_out_of_bounds.py is an existing memory-safety regression test (out-of-range element ids / mis-shaped arrays must raise a clean IndexError / RuntimeError instead of reaching unchecked C++ indexing). Add it to the module list the asan_ubsan job runs, so it executes in the environment that would actually catch an out-of-bounds access. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- .github/workflows/sanitizers.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 989f03cc..ca796653 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -68,7 +68,8 @@ jobs: test_SecurityAnlysis_cpp \ test_ContingencyAnalysis_limit_violations \ test_ContingencyAnalysis_split \ - test_check_grid + test_check_grid \ + test_LSGrid_out_of_bounds debug_asserts: # Release wheels are compiled with NDEBUG, which silences every Eigen From e0daf36e8a1035c0c44433bd9ed417bb6e8a43df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 12:03:09 +0000 Subject: [PATCH 154/166] Accept IndexError from check_grid on the binary corruption sweep check_grid() now runs when a grid is loaded from the binary format (via set_state), and it raises IndexError (C++ std::out_of_range) for an out-of-range bus / substation / topology-vector index. TestCorruptionSweep in test_binary_serialization.py only accepted RuntimeError, so a byte corruption that produced a byte-wise well-formed but semantically inconsistent grid (an out-of-range index) made load_binary raise IndexError and failed the sweep. Keep the clearer IndexError contract for out-of-range indices and widen the sweep instead: TestCorruptionSweep._check_load now accepts RuntimeError (byte layer) OR IndexError (semantic layer, check_grid) as a clean rejection, and still fails on anything else (a crash or a different exception). A local byte-flip sweep over a saved grid gives 1335 RuntimeError, 32 IndexError and 0 disallowed outcomes. Document the semantic-validation layer in docs/binary_serialization.rst and in the load_binary docstring (binary_helpers.hpp): load_binary now also rejects a well-formed-but-inconsistent grid via check_grid. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- docs/binary_serialization.rst | 16 +++++++++++----- lightsim2grid/tests/test_binary_serialization.py | 12 +++++++++--- src/bindings/python/binary_helpers.hpp | 11 +++++++++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 0e2bcd28..17e6b969 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -60,7 +60,12 @@ Individual element containers work the same way: corrupted internal sizes (every count in the file is checked against the real file size *before* anything is allocated), files that contain an object of a different type (*eg* loading a ``LoadContainer`` file with ``StorageContainer.load_binary``), and files with unexpected trailing - bytes are all rejected. ``save_binary`` on the other hand is **atomic by default**: it writes to a + bytes are all rejected. On top of that byte-level validation, loading a whole grid also runs + :py:meth:`LSGrid.check_grid` (the same consistency check used everywhere a grid is loaded): a file + that is byte-wise well-formed but carries an out-of-range index (bus / substation / + topology-vector) or a non-finite physical value is rejected with an ``IndexError`` (out-of-range + index) or a ``RuntimeError`` (structural inconsistency) rather than being loaded into a state that + would fault on the next powerflow. ``save_binary`` on the other hand is **atomic by default**: it writes to a temporary file that only replaces the destination once fully written, so an interrupted save never destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead (marginally faster -- it skips one temporary file and rename -- but without that protection). @@ -70,10 +75,11 @@ Individual element containers work the same way: rules to follow: - **Never load a binary file from a source you do not trust.** ``load_binary`` - rejects structurally ill-formed input (see above), but it cannot tell a - legitimate grid from a maliciously crafted one that happens to be well-formed -- - loading it still means trusting its content outright, the same way loading a - pickle file does. + rejects structurally ill-formed input and, via ``check_grid``, a grid with + out-of-range indices or non-finite values (see above), but a well-formed, + internally-consistent file can still carry adversarial *values* -- loading it + means trusting its content, the same way loading a pickle file does. See also + :ref:`security`. - **Generate the file on the machine that will consume it, with the same lightsim2grid version.** The format stores raw native data (same endianness, ``real_type``, ...) with no checksum and no cross-platform migration; the diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 681d4ce4..451bea00 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -481,11 +481,17 @@ def _check_load(self, corrupted, description): f.write(corrupted) try: self.grid_cls.load_binary(bad_path) - except RuntimeError: - pass # corruption detected and rejected cleanly: fine + except (RuntimeError, IndexError): + # Corruption detected and rejected cleanly. RuntimeError comes from the + # byte layer (BinaryArchive: bad magic/version/type, truncation, + # corrupted length). IndexError comes from the semantic layer + # (check_grid, run on load): a file that is byte-wise well-formed but + # carries an out-of-range index (bus / substation / topology-vector). + # Either way it is a clean, catchable exception -- never a crash. + pass except BaseException as exc: self.fail(f"{description}: load_binary raised {type(exc).__name__} " - f"instead of RuntimeError: {exc}") + f"instead of RuntimeError / IndexError: {exc}") def test_single_byte_flips(self): """Invert one byte at every offset of the file.""" diff --git a/src/bindings/python/binary_helpers.hpp b/src/bindings/python/binary_helpers.hpp index d320c8ff..f8ec9b84 100644 --- a/src/bindings/python/binary_helpers.hpp +++ b/src/bindings/python/binary_helpers.hpp @@ -21,7 +21,11 @@ namespace py = pybind11; // Files are readable by any lightsim2grid version sharing the same // BINARY_FORMAT_VERSION (see BinaryArchive.hpp), and ill-formed input // (garbage, truncation, corrupted sizes, wrong object type, trailing bytes) -// raises RuntimeError instead of crashing or over-allocating. +// raises RuntimeError instead of crashing or over-allocating. Loading a whole +// grid additionally runs LSGrid::check_grid() (via set_state), so a byte-wise +// well-formed but inconsistent grid -- an out-of-range bus / substation / +// topology-vector index, or a non-finite value -- is rejected with an +// IndexError (out-of-range index) or a RuntimeError (structural inconsistency). // // These lambdas call ls2g::save_binary_generic/load_binary_generic directly // (rather than T::save_binary/T::load_binary): VERSION_MAJOR/MEDIUM/MINOR are @@ -46,7 +50,10 @@ void add_binary_serialization(py::class_& cls) { "Load an object previously saved with save_binary(). Raises RuntimeError on " "an incompatible binary format, a wrong object type, or a corrupted / " "truncated file (including corrupted internal sizes: no attempt is made to " - "allocate more data than the file actually contains)."); + "allocate more data than the file actually contains). Loading a whole grid " + "additionally validates its consistency (see check_grid): a byte-wise " + "well-formed but inconsistent grid raises IndexError (out-of-range index) " + "or RuntimeError (structural inconsistency)."); } #endif // BINARY_HELPERS_HPP From d32499452fd90738c49216d14f717578c45bf4b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 18:15:00 +0000 Subject: [PATCH 155/166] check_grid: drop input-finiteness checks (false positives on real grids) check_grid() rejected any NaN / Inf in the physical input arrays (r, x, half-line shunts, thermal limits, p/q/vm/min_q/max_q). That produced false positives on legitimate grids: pypowsybl IEEE cases (ieee14/30/57/118) encode "no thermal limit" as a non-finite limit_a_ka, and unbounded generator reactive limits are commonly stored as +/-Inf. init_from_pypowsybl therefore raised on every one of those grids (CI: test_legacy_pypowsybl and the full test suite). Finiteness of these floats is not a memory-safety concern -- they are not used as indices, so a NaN/Inf cannot cause an out-of-bounds access; it only yields a NaN result, which the solver-output check at the algorithm->grid seam already turns into a reported non-convergence. Remove the input-finiteness checks and keep check_grid focused on index-safety (bus / substation / topology-vector indices, slack and remote-regulated bus references), which is the actual OOB guard. The generator slack-weight finiteness check stays (a weight is never legitimately non-finite), as does the solver-output finiteness check. Removes the now-unused GenericContainer::check_all_finite helper, the OneSideContainer_PQ / TwoSidesContainer_rxh_A check_valid overrides (they only added finiteness; index checks are inherited), the NaN test case, and updates the docs/CHANGELOG accordingly. Verified: pypowsybl ieee14/30/118 and pandapower / grid2op grids all load; C++ suite (70) + valgrind clean. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- CHANGELOG.rst | 5 ++- docs/binary_serialization.rst | 10 +++--- docs/security.rst | 8 ++--- src/bindings/python/binary_helpers.hpp | 4 +-- src/core/LSGrid.hpp | 5 ++- .../element_container/GeneratorContainer.cpp | 9 ++---- .../element_container/GenericContainer.hpp | 31 ++----------------- .../element_container/OneSideContainer.hpp | 2 ++ .../element_container/OneSideContainer_PQ.hpp | 22 ------------- .../TwoSidesContainer_rxh_A.hpp | 18 ----------- src/core/help_fun_msg.cpp | 8 ++--- src/tests/test_check_grid.cpp | 14 --------- 12 files changed, 23 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index be404464..4abae709 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -116,9 +116,8 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding - [ADDED] ``GridModel.check_grid()`` (C++ ``LSGrid::check_grid()``): a whole-grid consistency check that verifies every index the grid carries (element bus ids, substation ids, position in the topology vector, generator slack and - remote-regulated bus references) is in range, and that the physical input arrays - contain no ``NaN`` / ``Inf``. It raises ``IndexError`` / ``RuntimeError`` on an - inconsistency. + remote-regulated bus references) is in range. It raises ``IndexError`` / + ``RuntimeError`` on an inconsistency. - [ADDED] the grid is now validated automatically with ``check_grid()`` when it is loaded (from a pickle or the binary format, via ``set_state``) and by every grid loader (``init_from_pandapower`` / ``init_from_pypowsybl`` / ``init_from_matpower`` diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index 17e6b969..e6d3603b 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -63,9 +63,9 @@ Individual element containers work the same way: bytes are all rejected. On top of that byte-level validation, loading a whole grid also runs :py:meth:`LSGrid.check_grid` (the same consistency check used everywhere a grid is loaded): a file that is byte-wise well-formed but carries an out-of-range index (bus / substation / - topology-vector) or a non-finite physical value is rejected with an ``IndexError`` (out-of-range - index) or a ``RuntimeError`` (structural inconsistency) rather than being loaded into a state that - would fault on the next powerflow. ``save_binary`` on the other hand is **atomic by default**: it writes to a + topology-vector) is rejected with an ``IndexError`` (out-of-range index) or a ``RuntimeError`` + (structural inconsistency) rather than being loaded into a state that would fault on the next + powerflow. ``save_binary`` on the other hand is **atomic by default**: it writes to a temporary file that only replaces the destination once fully written, so an interrupted save never destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead (marginally faster -- it skips one temporary file and rename -- but without that protection). @@ -76,8 +76,8 @@ Individual element containers work the same way: - **Never load a binary file from a source you do not trust.** ``load_binary`` rejects structurally ill-formed input and, via ``check_grid``, a grid with - out-of-range indices or non-finite values (see above), but a well-formed, - internally-consistent file can still carry adversarial *values* -- loading it + out-of-range indices (see above), but a well-formed, internally-consistent + file can still carry adversarial *values* -- loading it means trusting its content, the same way loading a pickle file does. See also :ref:`security`. - **Generate the file on the machine that will consume it, with the same diff --git a/docs/security.rst b/docs/security.rst index ab1bcbd5..374764be 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -71,11 +71,9 @@ the file, and it is validated at two levels: is internally consistent and safe to run a powerflow on. It checks that every index the grid carries is in range — the bus id of every element, the substation id and the position in the topology vector (both optional), and the generator -slack and remote-regulated bus references — and that the physical input arrays -(line / transformer ``r``, ``x`` and shunt values, thermal limits, and the -``p`` / ``q`` / voltage set-points) contain no ``NaN`` or infinite value. It -raises ``IndexError`` (an out-of-range index) or ``RuntimeError`` (a structural -inconsistency or a non-finite value), and returns ``None`` for a consistent grid. +slack and remote-regulated bus references. It raises ``IndexError`` (an +out-of-range index) or ``RuntimeError`` (a structural inconsistency), and returns +``None`` for a consistent grid. You normally do not need to call it yourself: diff --git a/src/bindings/python/binary_helpers.hpp b/src/bindings/python/binary_helpers.hpp index f8ec9b84..484bce38 100644 --- a/src/bindings/python/binary_helpers.hpp +++ b/src/bindings/python/binary_helpers.hpp @@ -24,8 +24,8 @@ namespace py = pybind11; // raises RuntimeError instead of crashing or over-allocating. Loading a whole // grid additionally runs LSGrid::check_grid() (via set_state), so a byte-wise // well-formed but inconsistent grid -- an out-of-range bus / substation / -// topology-vector index, or a non-finite value -- is rejected with an -// IndexError (out-of-range index) or a RuntimeError (structural inconsistency). +// topology-vector index -- is rejected with an IndexError (out-of-range index) +// or a RuntimeError (structural inconsistency). // // These lambdas call ls2g::save_binary_generic/load_binary_generic directly // (rather than T::save_binary/T::load_binary): VERSION_MAJOR/MEDIUM/MINOR are diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 6092ad21..bb32fe84 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -516,9 +516,8 @@ class LS2G_API LSGrid final // Whole-grid consistency check. Verifies that every index the grid carries // (element bus ids, substation ids, position in the topology vector, // generator slack and remote-regulated bus references) is in range for this - // grid, and that the physical input arrays contain no NaN / +-Inf. Throws - // std::out_of_range on an out-of-range index and std::runtime_error on a - // structural / finiteness error. + // grid. Throws std::out_of_range on an out-of-range index and + // std::runtime_error on a structural error. // // Called automatically at the end of set_state() (so loading a pickle or a // binary file cannot leave an out-of-range index that would later cause an diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 5a044d36..62e6768b 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -132,13 +132,8 @@ void GeneratorContainer::check_valid(int nb_bus, const SubstationContainer & substations, std::vector & all_pos_topo_vect) const { - // one-side (bus / subid / pos_topo_vect) + (p, q) finiteness checks - check_valid_osc_pq(nb_bus, nb_sub, substations, all_pos_topo_vect, "generator"); - - // generator-specific electrical inputs must be finite - check_all_finite(target_vm_pu_, "generator target_vm_pu"); - check_all_finite(min_q_, "generator min_q"); - check_all_finite(max_q_, "generator max_q"); + // one-side index checks (bus / subid / pos_topo_vect) + check_valid_osc(nb_bus, nb_sub, substations, all_pos_topo_vect, "generator"); // slack coherence + remote-regulated bus id range const int nb_gen = nb(); diff --git a/src/core/element_container/GenericContainer.hpp b/src/core/element_container/GenericContainer.hpp index f394ea2d..63b83ffd 100644 --- a/src/core/element_container/GenericContainer.hpp +++ b/src/core/element_container/GenericContainer.hpp @@ -10,7 +10,6 @@ #define GENERIC_CONTAINER_H #include // for std::find -#include // for std::isfinite (see check_all_finite) #include "Eigen/Core" #include "Eigen/Dense" @@ -116,9 +115,8 @@ class LS2G_API GenericContainer : public BaseConstants Checks that every index this container carries (bus ids, substation ids, position in the topology vector, slack references...) is in range for a - grid with `nb_bus` buses and `nb_sub` substations, and that the physical - (electrical) input arrays contain no NaN / +-Inf. Throws std::out_of_range - on a bad index and std::runtime_error on a structural / finiteness error. + grid with `nb_bus` buses and `nb_sub` substations. Throws std::out_of_range + on a bad index and std::runtime_error on a structural error. `all_pos_topo_vect` is an accumulator: each container appends the `pos_topo_vect` values it actually carries (the field is optional and may @@ -154,31 +152,6 @@ class LS2G_API GenericContainer : public BaseConstants if(static_cast(container.size()) != size) throw std::runtime_error(container_name + " do not have the proper size"); } - /** - check that every element of `v` is a finite number (no NaN, no +-Inf). - Works for real vectors (Eigen or std) and complex vectors (both real and - imaginary parts must be finite). Throws std::runtime_error otherwise. - Used by check_valid() to keep NaN / Inf out of the physical input arrays. - **/ - static bool _is_finite_val(real_type v) noexcept { return std::isfinite(v); } - static bool _is_finite_val(const cplx_type & v) noexcept { - return std::isfinite(v.real()) && std::isfinite(v.imag()); - } - template // a std::vector or an Eigen vector, real or complex - static void check_all_finite(const VectCLS & v, const std::string & name) - { - auto sz = v.size(); - for(decltype(sz) i = 0; i < sz; ++i) - { - if(!_is_finite_val(v[i])) - { - std::ostringstream exc_; - exc_ << name << " contains a non-finite value (NaN or +-Inf) at position " << i << "."; - throw std::runtime_error(exc_.str()); - } - } - } - /** activation / deactivation of elements **/ diff --git a/src/core/element_container/OneSideContainer.hpp b/src/core/element_container/OneSideContainer.hpp index 79c5e3be..1c8e3357 100644 --- a/src/core/element_container/OneSideContainer.hpp +++ b/src/core/element_container/OneSideContainer.hpp @@ -467,6 +467,7 @@ class OneSideContainer : public GenericContainer // nothing to do by default }; + public: // Whole-grid semantic validation (see GenericContainer::check_valid / LSGrid::check_grid). void check_valid(int nb_bus, int nb_sub, @@ -476,6 +477,7 @@ class OneSideContainer : public GenericContainer check_valid_osc(nb_bus, nb_sub, substations, all_pos_topo_vect, "element"); } + protected: void _check_pos_topo_vect_filled(){ if((nb() > 0) && (pos_topo_vect_.size() == 0)){ // TODO DEBUG MODE: only check in debug mode diff --git a/src/core/element_container/OneSideContainer_PQ.hpp b/src/core/element_container/OneSideContainer_PQ.hpp index dff5eede..5e9065cc 100644 --- a/src/core/element_container/OneSideContainer_PQ.hpp +++ b/src/core/element_container/OneSideContainer_PQ.hpp @@ -61,15 +61,6 @@ class OneSideContainer_PQ : public OneSideContainer Eigen::Ref get_target_p() const {return target_p_mw_;} - // Whole-grid semantic validation (see GenericContainer::check_valid). - void check_valid(int nb_bus, - int nb_sub, - const SubstationContainer & substations, - std::vector & all_pos_topo_vect) const override - { - check_valid_osc_pq(nb_bus, nb_sub, substations, all_pos_topo_vect, "element"); - } - // base function that can be called void gen_p_per_bus(std::vector & res) const override { @@ -133,19 +124,6 @@ class OneSideContainer_PQ : public OneSideContainer >; protected: - // check_valid() for elements carrying (p, q) targets: the OneSideContainer - // index checks plus finiteness of the target_p / target_q input arrays. - void check_valid_osc_pq(int nb_bus, - int nb_sub, - const SubstationContainer & substations, - std::vector & all_pos_topo_vect, - const std::string & el_name) const - { - check_valid_osc(nb_bus, nb_sub, substations, all_pos_topo_vect, el_name); - check_all_finite(target_p_mw_, el_name + " target_p_mw"); - check_all_finite(target_q_mvar_, el_name + " target_q_mvar"); - } - OneSideContainer_PQ::StateRes get_osc_pq_state() const // osc: one side element { std::vector target_p_mw(target_p_mw_.begin(), target_p_mw_.end()); diff --git a/src/core/element_container/TwoSidesContainer_rxh_A.hpp b/src/core/element_container/TwoSidesContainer_rxh_A.hpp index 4adee714..d9cce6bf 100644 --- a/src/core/element_container/TwoSidesContainer_rxh_A.hpp +++ b/src/core/element_container/TwoSidesContainer_rxh_A.hpp @@ -159,24 +159,6 @@ class TwoSidesContainer_rxh_A: public TwoSidesContainer TwoSidesContainer_rxh_A() noexcept = default; ~TwoSidesContainer_rxh_A() noexcept override = default; - // Whole-grid semantic validation (see GenericContainer::check_valid): the - // two-sides (bus / subid / pos_topo_vect) checks plus finiteness of the - // branch electrical inputs (r, x, half-line shunts and thermal limits). - void check_valid(int nb_bus, - int nb_sub, - const SubstationContainer & substations, - std::vector & all_pos_topo_vect) const override - { - TwoSidesContainer::check_valid(nb_bus, nb_sub, substations, all_pos_topo_vect); - this->check_all_finite(r_, "branch r"); - this->check_all_finite(x_, "branch x"); - this->check_all_finite(h_side_1_, "branch h_side_1"); - this->check_all_finite(h_side_2_, "branch h_side_2"); - // thermal limits are optional (may be empty) - if(limit_a1_ka_.size() > 0) this->check_all_finite(limit_a1_ka_, "branch limit_a1_ka"); - if(limit_a2_ka_.size() > 0) this->check_all_finite(limit_a2_ka_, "branch limit_a2_ka"); - } - // pickle // /!\ if you change this layout, bump BINARY_FORMAT_VERSION (BinaryArchive.hpp) using StateRes = std::tuple< diff --git a/src/core/help_fun_msg.cpp b/src/core/help_fun_msg.cpp index e1d24bab..14c3c75e 100644 --- a/src/core/help_fun_msg.cpp +++ b/src/core/help_fun_msg.cpp @@ -1850,9 +1850,7 @@ const std::string DocLSGrid::check_grid = R"mydelimiter( element (load, generator, static generator, storage, shunt, line, transformer, hvdc line, static var compensator), the substation id and the position in the topology vector (both optional), and the generator slack / remote-regulated bus - references. It also checks that the physical input arrays (line/transformer r, - x and shunt values, thermal limits, and the p / q / voltage set-points) contain - no ``NaN`` nor infinite value. + references. This is called automatically when a grid is loaded (from a pickle or from the fast binary format), and by the grid loaders (from pandapower, pypowsybl, @@ -1862,8 +1860,8 @@ const std::string DocLSGrid::check_grid = R"mydelimiter( Raises ------ An ``IndexError`` (C++ ``std::out_of_range``) if an index is out of range, or a - ``RuntimeError`` (C++ ``std::runtime_error``) on a structural inconsistency or a - non-finite physical value. Returns ``None`` if the grid is consistent. + ``RuntimeError`` (C++ ``std::runtime_error``) on a structural inconsistency. + Returns ``None`` if the grid is consistent. Notes ----- diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index 0d5b7190..45a8d5c7 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -97,10 +97,6 @@ std::vector& load_bus_id(LSGrid::StateRes & st) { return std::get<1>(std::get<0>(std::get<0>(std::get(st)))); } -std::vector& load_p_mw(LSGrid::StateRes & st) -{ - return std::get<1>(std::get<0>(std::get(st))); -} // GEN_ID -> GeneratorContainer::StateRes = tuple @@ -225,16 +221,6 @@ TEST_CASE("check_grid rejects an out-of-range remote-regulated bus id", "[check_ CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); } -TEST_CASE("check_grid rejects a non-finite physical value (NaN load p)", "[check_grid]") -{ - LSGrid grid = make_valid_grid(); - LSGrid::StateRes st = grid.get_state(); - load_p_mw(st)[0] = std::numeric_limits::quiet_NaN(); - - LSGrid restored; - CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); -} - TEST_CASE("check_grid accepts a valid pos_topo_vect permutation", "[check_grid]") { LSGrid grid = make_valid_grid(); From 0bafd6dbf7cf7856a58c9b39b5e1d4b608d070aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:50:56 +0000 Subject: [PATCH 156/166] Address PR review: docs wording, subid tests, plugin-only output check Review feedback from PR #155: - Only run the solver-output sanity check for *external* solvers. This was the reviewer's question ("is it possible to run this check only for external plugins?") and the answer is yes, for free: AlgorithmType::Custom already means "not one of the built-in solvers" (name_to_algo_type returns it for any name outside the built-in set), so process_results now gates the check on it. Built-in solvers, which the test suite already covers, pay nothing. - docs/security.rst: * grid2op environments are a code-execution channel too (grid2op reads and executes python shipped with the environment): add them to the table, to the section title and to the rule ("do not load a pickle or a plugin, nor call grid2op.make, on something you do not trust"); * mention grid2op among the ways a grid gets built and validated; * describe the binary format as the "least dangerous" channel rather than "the channel to use for untrusted data", with an explicit warning that least dangerous is not safe -- loading untrusted data stays a bad idea; * drop the deprecated GridModel alias, LSGrid is the current name; * "relatively cheap" rather than "cheap". - Add substation-id (*_to_subid) tests mirroring the pos_topo_vect ones: out-of-range, negative, off-by-one (n_sub itself) and a valid mapping, for both loads and generators. - Make the external-solver coupling explicit in the C++ seam tests (assert the test solver is AlgorithmType::Custom). Verified: C++ suite (70) + valgrind clean; 376 python tests pass across test_check_grid, test_LSGrid_out_of_bounds, test_ACPF, test_DCPF, test_solver_control, test_solver_registry and the two pypowsybl suites. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- CHANGELOG.rst | 13 ++++--- docs/security.rst | 54 ++++++++++++++++---------- lightsim2grid/tests/test_check_grid.py | 42 ++++++++++++++++++++ src/core/LSGrid.cpp | 15 ++++--- src/core/LSGrid.hpp | 6 ++- src/tests/test_check_grid.cpp | 5 +++ 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4abae709..56c18ebe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -124,12 +124,13 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an out-of-range bus id in a crafted binary file) now raises a clean exception instead of causing an out-of-bounds access during the next powerflow. -- [ADDED] a sanity check on the voltages returned by a (possibly plugin / custom) - solver before they are consumed: a wrong-sized result raises, and a non-finite one - is reported as a non-converged solve instead of propagating ``NaN`` / ``Inf``. -- [ADDED] a "Security" documentation page describing the trust boundaries (pickle and - solver plugins are trusted-input-only; the binary format is hardened against - untrusted input and validated with ``check_grid`` on load). +- [ADDED] a sanity check on the voltages returned by an **external** (plugin) solver + before they are consumed: a wrong-sized result raises, and a non-finite one is + reported as a non-converged solve instead of propagating ``NaN`` / ``Inf``. Built-in + solvers skip this check entirely, so they pay nothing for it. +- [ADDED] a "Security" documentation page describing the trust boundaries (pickle, + solver plugins and grid2op environments are trusted-input-only; the binary format is + the least dangerous channel, and is validated with ``check_grid`` on load). - [BREAKING] For plugin developers (C++ side): solver plugins no longer self-register from a static ``AlgorithmRegistrar`` constructor firing during ``dlopen``. Instead a plugin writes a ``void(ls2g::PluginRegistrar&)`` registration function and exposes it diff --git a/docs/security.rst b/docs/security.rst index 374764be..b2f02fbf 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -25,31 +25,43 @@ The short version - **Trusted code only.** A plugin is a native shared library that runs in-process; once loaded it can do anything the host process can. Only load plugins you built or otherwise trust. + * - Creating a grid2op environment (``grid2op.make``) + - **Trusted input only.** An environment is not just data: grid2op reads + and executes python code shipped with it (its ``config.py``, the classes + it points to, ...). Only use environments you trust. * - Loading a grid from the fast binary format (``save_binary`` / ``load_binary``) - - **Hardened against untrusted input.** See below. + - **Least dangerous, still not a safe channel.** See below. * - Building a grid from a source file (pandapower, pypowsybl, matpower, - powermodels) + powermodels — including through a grid2op environment) - **Validated on load.** See below. -Pickle and plugins are trusted-input-only by design ---------------------------------------------------- +Pickle, plugins and grid2op environments are trusted-input-only by design +------------------------------------------------------------------------ -Unpickling arbitrary data and loading a native plugin are both, fundamentally, -ways of running code chosen by whoever produced the input. No amount of -validation inside lightsim2grid changes that, so the only safe rule is: **do not -load a pickle or a plugin from a source you do not trust.** This is the same -posture as ``numpy``, ``pandas`` (``read_pickle``), ``torch.load`` and any other -library that supports pickling. +Unpickling arbitrary data, loading a native plugin or using a grid2op +environment are all, fundamentally, ways of running code chosen by whoever +produced the input. No amount of validation inside lightsim2grid changes that, +so the only safe rule is: **do not load a pickle or a plugin, nor call** +``grid2op.make``\ **, on something coming from a source you do not trust.** This +is the same posture as ``numpy``, ``pandas`` (``read_pickle``), ``torch.load`` +and any other library that supports pickling. The version string embedded in a pickled grid is a *compatibility* check (it refuses to load a grid saved by a different lightsim2grid version); it is **not** a security control. -The binary format is the channel to use for untrusted data ----------------------------------------------------------- +The binary format is the least dangerous channel +------------------------------------------------- + +.. warning:: + "Least dangerous" is not "safe": loading data you do not trust is a bad + idea whatever the format, and none of the validation described here makes + it a good one. The point below is only that this format gives an attacker + strictly less to work with than pickle does — not that it is meant for + untrusted input. -The fast binary format (:ref:`binary-serialization`) was designed as a safe, +The fast binary format (:ref:`binary-serialization`) was designed as an additive alternative to pickle. Loading a binary file never executes code from the file, and it is validated at two levels: @@ -67,7 +79,7 @@ the file, and it is validated at two levels: ``check_grid()``: whole-grid consistency validation --------------------------------------------------- -:py:meth:`LSGrid.check_grid` (also available as ``GridModel.check_grid``) verifies that a grid +:py:meth:`LSGrid.check_grid` verifies that a grid is internally consistent and safe to run a powerflow on. It checks that every index the grid carries is in range — the bus id of every element, the substation id and the position in the topology vector (both optional), and the generator @@ -83,23 +95,25 @@ You normally do not need to call it yourself: ``init_from_matpower``, ``init_from_powermodels``) calls it before returning. It is exposed so you can validate a grid you built or modified by hand. It runs -in time proportional to the number of elements in the grid, so it is cheap -compared to a powerflow. +in time proportional to the number of elements in the grid, so it is relatively +cheap compared to a powerflow. .. note:: Release wheels are compiled with ``-O3 -DNDEBUG``, which removes Eigen's and the standard library's own bounds checks. ``check_grid()`` (and the size / - finiteness check applied to the voltages a solver returns) are deliberately - *not* gated behind that flag: they always run, because they are what turns a - malformed input into a clean exception rather than an out-of-bounds access. + finiteness check applied to the voltages an *external* solver returns) are + deliberately *not* gated behind that flag: they always run, because they are + what turns a malformed input into a clean exception rather than an + out-of-bounds access. A note for solver-plugin authors --------------------------------- A solver plugin is trusted code, so lightsim2grid does not try to sandbox it. It does, however, sanity-check the voltages your ``compute_pf`` returns before -using them: if you report convergence but return a voltage vector of the wrong +using them — for *external* solvers only, so the built-in ones pay nothing for +it: if you report convergence but return a voltage vector of the wrong size, the powerflow raises ``RuntimeError`` (this would otherwise be an out-of-bounds read); if you return non-finite values, the solve is reported as non-converged rather than propagating ``NaN`` / ``Inf`` into the results. Writing diff --git a/lightsim2grid/tests/test_check_grid.py b/lightsim2grid/tests/test_check_grid.py index 3737b734..022d8815 100644 --- a/lightsim2grid/tests/test_check_grid.py +++ b/lightsim2grid/tests/test_check_grid.py @@ -44,6 +44,8 @@ def setUp(self): net = pn.case14() self.grid = init_from_pandapower(net) # the loader calls check_grid() self.n_load = len(self.grid.get_loads()) + self.n_gen = len(self.grid.get_generators()) + self.n_sub = self.grid.get_n_sub() self.assertGreater(self.n_load, 1) # case14 has several loads def test_loader_accepts_valid_grid(self): @@ -73,6 +75,46 @@ def test_negative_pos_topo_vect_is_rejected(self): with self.assertRaises(IndexError): self.grid.check_grid() + # ------------------------------------------------------------------ + # substation ids (`*_to_subid`), same treatment as pos_topo_vect above + # ------------------------------------------------------------------ + def test_out_of_range_subid_is_rejected(self): + # one element sits on a substation id far above the substation count. + for name, n in [("set_load_to_subid", self.n_load), + ("set_gen_to_subid", self.n_gen)]: + with self.subTest(name): + sub = np.zeros(n, dtype=np.int32) + sub[-1] = BIG + getattr(self.grid, name)(sub) + with self.assertRaises(IndexError): + self.grid.check_grid() + # restore a valid mapping so the next subTest starts clean + getattr(self.grid, name)(np.zeros(n, dtype=np.int32)) + + def test_negative_subid_is_rejected(self): + for name, n in [("set_load_to_subid", self.n_load), + ("set_gen_to_subid", self.n_gen)]: + with self.subTest(name): + sub = np.zeros(n, dtype=np.int32) + sub[0] = -1 + getattr(self.grid, name)(sub) + with self.assertRaises(IndexError): + self.grid.check_grid() + getattr(self.grid, name)(np.zeros(n, dtype=np.int32)) + + def test_subid_just_out_of_range_is_rejected(self): + # off-by-one: `n_sub` itself is the first invalid substation id. + sub = np.zeros(self.n_load, dtype=np.int32) + sub[-1] = self.n_sub + self.grid.set_load_to_subid(sub) + with self.assertRaises(IndexError): + self.grid.check_grid() + + def test_valid_subid_is_accepted(self): + # every element on the last valid substation: in range, must not raise. + self.grid.set_load_to_subid(np.full(self.n_load, self.n_sub - 1, dtype=np.int32)) + self.assertIsNone(self.grid.check_grid()) + def test_pickle_roundtrip_of_valid_grid(self): # pickling goes through set_state on load, which runs check_grid(); a valid # grid must round-trip cleanly. diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 80cb45c8..926fb8b0 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -1200,11 +1200,16 @@ void LSGrid::process_results(bool conv, SolverBusIdVect & id_me_to_solver) { if (conv){ - // A (possibly plugin / custom) solver can claim convergence but return - // malformed voltages. Validate their size/finiteness before we index them - // below (compute_results / _get_results_back_to_orig_nodes both index the - // solver vectors with an unchecked operator()). - conv = _check_solver_output(ac); + // An external (plugin) solver can claim convergence but return malformed + // voltages. Validate their size/finiteness before we index them below + // (compute_results / _get_results_back_to_orig_nodes both index the solver + // vectors with an unchecked operator()). + // Only external solvers are checked: AlgorithmType::Custom is precisely + // "not one of the built-in solvers" (see name_to_algo_type), and the + // built-in ones are covered by the test suite, so they pay nothing here. + const bool is_external_algo = + (ac ? _algo.get_type() : _dc_algo.get_type()) == AlgorithmType::Custom; + if (is_external_algo) conv = _check_solver_output(ac); } if (conv){ if(compute_results_){ diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index bb32fe84..3c2a96c6 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1996,12 +1996,14 @@ class LS2G_API LSGrid final void process_results(bool conv, CplxVect & res, const Eigen::Ref & Vinit, bool ac, SolverBusIdVect & id_me_to_solver); - // Sanity-check the voltages a (possibly plugin) solver returned before they - // are consumed. A wrong-sized V/Va/Vm is a contract violation -> throws + // Sanity-check the voltages a solver returned before they are consumed. + // A wrong-sized V/Va/Vm is a contract violation -> throws // std::runtime_error. Non-finite values -> the solve is marked as // non-converged (ErrorType::InifiniteValue) and this returns false, so no // NaN/Inf propagates and no out-of-bounds access happens downstream. // Returns true when the outputs are usable. + // NB only called for external (plugin) solvers, ie those whose type is + // AlgorithmType::Custom -- built-in solvers pay nothing for this. bool _check_solver_output(bool ac); /** diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index 45a8d5c7..84fe3a3c 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -261,6 +261,10 @@ TEST_CASE("a solver returning a wrong-sized voltage vector is rejected, not inde LSGrid grid = make_valid_grid(); grid.change_algorithm("__check_grid_wrong_size__"); + // the name is not one of the built-ins, so this solver is an *external* one + // (AlgorithmType::Custom) -- which is exactly the case the output check + // guards; built-in solvers deliberately skip it. + REQUIRE(grid.get_algo_type() == ls2g::AlgorithmType::Custom); CHECK_THROWS_AS(grid.ac_pf(flat_start(grid), 20, 1e-8), std::runtime_error); } @@ -272,6 +276,7 @@ TEST_CASE("a solver returning non-finite voltages is reported as non-converged", LSGrid grid = make_valid_grid(); grid.change_algorithm("__check_grid_nan__"); + REQUIRE(grid.get_algo_type() == ls2g::AlgorithmType::Custom); // external solver CplxVect res; CHECK_NOTHROW(res = grid.ac_pf(flat_start(grid), 20, 1e-8)); CHECK(res.size() == 0); // diverged => empty result From fec1f9e932c77f85496089918efa20f136035b2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 20:11:26 +0000 Subject: [PATCH 157/166] Fix a SIGFPE reachable from the solver / newtonpf entry point `refactor_every_n` is used as `iter % refactor_every_n_` in NRAlgo::should_refactor_policy, but nothing validated it. Setting it to 0 therefore caused an integer division by zero: a SIGFPE that kills the interpreter instead of raising a catchable error. Two ways to reach it, both confirmed (exit code 136): # 1. the python solver API (lightsim2grid.algorithm, ie the newtonpf entry point) solver.set_refactor_policy(RefactorPolicyType.EveryN) solver.set_refactor_every_n(0) solver.solve(...) # -> SIGFPE # 2. the serialized state: AlgoConfig is part of LSGrid::StateRes, so the # value survives a pickle / binary round-trip (check_grid does not cover # it -- it is not an index) and crashes on the next powerflow grid.set_ac_algo_config(poisoned); pickle.loads(pickle.dumps(grid)).ac_pf(...) Validate the NR policy parameters where they enter, so both paths are covered: set_refactor_every_n() requires >= 1, and set_config() -- the deserialization path, fed by an AlgoConfig that can come straight from a file -- runs the same checks instead of assigning raw. Also validate the other policy parameters whose contract is unambiguous: ls_c / ls_rho in (0, 1), ls_max_iter >= 0, max_dVa / max_dVm finite and > 0, iw_mu_min / iw_mu_max finite. Tests: C++ cases feeding a poisoned AlgoConfig through set_state (0, negative, out-of-range ls_rho, non-finite max_dVa) plus a valid round-trip, and python cases for the setters and for set_ac_algo_config. Existing callers all use sane values, so nothing else changes. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- lightsim2grid/tests/test_check_grid.py | 39 +++++++++++ src/core/powerflow_algorithm/NRAlgo.hpp | 90 ++++++++++++++++++++----- src/tests/test_check_grid.cpp | 67 ++++++++++++++++++ 3 files changed, 180 insertions(+), 16 deletions(-) diff --git a/lightsim2grid/tests/test_check_grid.py b/lightsim2grid/tests/test_check_grid.py index 022d8815..8c5baa35 100644 --- a/lightsim2grid/tests/test_check_grid.py +++ b/lightsim2grid/tests/test_check_grid.py @@ -115,6 +115,45 @@ def test_valid_subid_is_accepted(self): self.grid.set_load_to_subid(np.full(self.n_load, self.n_sub - 1, dtype=np.int32)) self.assertIsNone(self.grid.check_grid()) + # ------------------------------------------------------------------ + # solver policy parameters (the `lightsim2grid.algorithm` / newtonpf entry + # point, and the AlgoConfig carried by the serialized grid state) + # ------------------------------------------------------------------ + def test_refactor_every_n_zero_is_rejected(self): + # `refactor_every_n` is used as `iter % refactor_every_n`: 0 used to be an + # integer division by zero, ie a SIGFPE that killed the interpreter. + from lightsim2grid.algorithm import NR_SparseLU + solver = NR_SparseLU() + for bad in (0, -1): + with self.subTest(bad): + with self.assertRaises(RuntimeError): + solver.set_refactor_every_n(bad) + solver.set_refactor_every_n(3) # a sane value still works + self.assertEqual(solver.get_refactor_every_n(), 3) + + def test_line_search_params_are_validated(self): + from lightsim2grid.algorithm import NR_SparseLU + solver = NR_SparseLU() + for name, bad_values in [("set_ls_rho", (0.0, 1.0, 1.5, float("nan"))), + ("set_ls_c", (0.0, 1.0, float("inf"))), + ("set_ls_max_iter", (-1,)), + ("set_max_dVa", (0.0, -1.0, float("nan"))), + ("set_max_dVm", (0.0, float("inf")))]: + for bad in bad_values: + with self.subTest(f"{name}({bad})"): + with self.assertRaises(RuntimeError): + getattr(solver, name)(bad) + + def test_poisoned_algo_config_is_rejected(self): + # AlgoConfig is part of the serialized grid state, so this value can come + # straight from a pickle or a binary file. + cfg = self.grid.get_ac_algo_config() + int_params = list(cfg.int_params) + int_params[3] = 0 # refactor_every_n + cfg.int_params = int_params + with self.assertRaises(RuntimeError): + self.grid.set_ac_algo_config(cfg) + def test_pickle_roundtrip_of_valid_grid(self): # pickling goes through set_state on load, which runs check_grid(); a valid # grid must round-trip cleanly. diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index f3bc9f6f..929eb28c 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -9,6 +9,10 @@ #ifndef NR_ALGO_H #define NR_ALGO_H +#include // std::isfinite (policy parameter validation) +#include +#include + #include "BaseAlgo.hpp" #include "NRSystem.hpp" #include "ScalingPolicies.hpp" @@ -173,25 +177,76 @@ class NRAlgo final : public BaseAlgo ); } + // ----- policy parameter validation ---------------------------------------- + // These parameters reach the solver from two untrusted-ish directions: the + // python setters below (exposed by `lightsim2grid.algorithm`, ie the + // `newtonpf` entry point) and set_config(), which is fed by an AlgoConfig -- + // part of the serialized grid state, so it also arrives from a pickle or a + // binary file. They must be validated on the way in: `refactor_every_n_` in + // particular is used as `iter % refactor_every_n_` (see + // should_refactor_policy), where 0 is an integer division by zero -- a + // SIGFPE that kills the process instead of raising a catchable error. + static int _checked_refactor_every_n(int v) { + if (v < 1) { + std::ostringstream exc_; + exc_ << "NRAlgo: refactor_every_n must be >= 1, got " << v + << " (it is used as `iter % refactor_every_n`, so 0 would be a division by zero)."; + throw std::runtime_error(exc_.str()); + } + return v; + } + static real_type _checked_in_0_1(real_type v, const char * name) { + if (!std::isfinite(v) || (v <= 0.) || (v >= 1.)) { + std::ostringstream exc_; + exc_ << "NRAlgo: " << name << " must be a finite number in (0, 1), got " << v << "."; + throw std::runtime_error(exc_.str()); + } + return v; + } + static real_type _checked_positive(real_type v, const char * name) { + if (!std::isfinite(v) || (v <= 0.)) { + std::ostringstream exc_; + exc_ << "NRAlgo: " << name << " must be a finite, strictly positive number, got " << v << "."; + throw std::runtime_error(exc_.str()); + } + return v; + } + static real_type _checked_finite(real_type v, const char * name) { + if (!std::isfinite(v)) { + std::ostringstream exc_; + exc_ << "NRAlgo: " << name << " must be a finite number, got " << v << "."; + throw std::runtime_error(exc_.str()); + } + return v; + } + static int _checked_non_negative(int v, const char * name) { + if (v < 0) { + std::ostringstream exc_; + exc_ << "NRAlgo: " << name << " must be >= 0, got " << v << "."; + throw std::runtime_error(exc_.str()); + } + return v; + } + // MaxVoltageChange params real_type get_max_dVa() const { return max_dVa_; } - void set_max_dVa(real_type v) { max_dVa_ = v; } + void set_max_dVa(real_type v) { max_dVa_ = _checked_positive(v, "max_dVa"); } real_type get_max_dVm() const { return max_dVm_; } - void set_max_dVm(real_type v) { max_dVm_ = v; } + void set_max_dVm(real_type v) { max_dVm_ = _checked_positive(v, "max_dVm"); } // LineSearch (Armijo) params real_type get_ls_c() const { return ls_c_; } - void set_ls_c(real_type v) { ls_c_ = v; } + void set_ls_c(real_type v) { ls_c_ = _checked_in_0_1(v, "ls_c"); } real_type get_ls_rho() const { return ls_rho_; } - void set_ls_rho(real_type v){ ls_rho_ = v; } + void set_ls_rho(real_type v){ ls_rho_ = _checked_in_0_1(v, "ls_rho"); } int get_ls_max_iter()const { return ls_max_iter_; } - void set_ls_max_iter(int v) { ls_max_iter_ = v; } + void set_ls_max_iter(int v) { ls_max_iter_ = _checked_non_negative(v, "ls_max_iter"); } // Iwamoto params real_type get_iw_mu_min() const { return iw_mu_min_; } - void set_iw_mu_min(real_type v) { iw_mu_min_ = v; } + void set_iw_mu_min(real_type v) { iw_mu_min_ = _checked_finite(v, "iw_mu_min"); } real_type get_iw_mu_max() const { return iw_mu_max_; } - void set_iw_mu_max(real_type v) { iw_mu_max_ = v; } + void set_iw_mu_max(real_type v) { iw_mu_max_ = _checked_finite(v, "iw_mu_max"); } // ----- refactor policy ----------------------------------------------------- @@ -199,7 +254,7 @@ class NRAlgo final : public BaseAlgo void set_refactor_policy(RefactorPolicyType t) { refactor_policy_ = t; } int get_refactor_every_n() const { return refactor_every_n_; } - void set_refactor_every_n(int v) { refactor_every_n_ = v; } + void set_refactor_every_n(int v) { refactor_every_n_ = _checked_refactor_every_n(v); } // ----- AlgoConfig serialization ------------------------------------------- @@ -221,14 +276,17 @@ class NRAlgo final : public BaseAlgo void set_config(const AlgoConfig& cfg) override { if (cfg.int_params.size() < 4) throw std::runtime_error("NRAlgo::set_config: int_params must have at least 4 elements"); if (cfg.real_params.size() < 6) throw std::runtime_error("NRAlgo::set_config: real_params must have at least 6 elements"); - max_dVa_ = static_cast(cfg.real_params[0]); - max_dVm_ = static_cast(cfg.real_params[1]); - ls_c_ = static_cast(cfg.real_params[2]); - ls_rho_ = static_cast(cfg.real_params[3]); - iw_mu_min_ = static_cast(cfg.real_params[4]); - iw_mu_max_ = static_cast(cfg.real_params[5]); - ls_max_iter_ = cfg.int_params[2]; - refactor_every_n_= cfg.int_params[3]; + // NB an AlgoConfig is part of the serialized grid state, so these values + // can come straight from a pickle or a binary file: validate them exactly + // like the setters do (see the note on _checked_refactor_every_n). + max_dVa_ = _checked_positive(static_cast(cfg.real_params[0]), "max_dVa"); + max_dVm_ = _checked_positive(static_cast(cfg.real_params[1]), "max_dVm"); + ls_c_ = _checked_in_0_1(static_cast(cfg.real_params[2]), "ls_c"); + ls_rho_ = _checked_in_0_1(static_cast(cfg.real_params[3]), "ls_rho"); + iw_mu_min_ = _checked_finite(static_cast(cfg.real_params[4]), "iw_mu_min"); + iw_mu_max_ = _checked_finite(static_cast(cfg.real_params[5]), "iw_mu_max"); + ls_max_iter_ = _checked_non_negative(cfg.int_params[2], "ls_max_iter"); + refactor_every_n_= _checked_refactor_every_n(cfg.int_params[3]); refactor_policy_ = static_cast(cfg.int_params[1]); set_scaling_policy(static_cast(cfg.int_params[0])); } diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index 84fe3a3c..59e612e5 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -104,6 +104,17 @@ std::vector& gen_slack_weight(LSGrid::StateRes & st) { return std::get<7>(std::get(st)); } +// AC_ALGO_CONFIG_ID -> tuple (an AlgoConfig) +// int_params = {scaling_policy, refactor_policy, ls_max_iter, refactor_every_n} +// real_params = {max_dVa, max_dVm, ls_c, ls_rho, iw_mu_min, iw_mu_max} +std::vector& ac_algo_int_params(LSGrid::StateRes & st) +{ + return std::get<0>(std::get(st)); +} +std::vector& ac_algo_real_params(LSGrid::StateRes & st) +{ + return std::get<1>(std::get(st)); +} std::vector& gen_regulated_bus(LSGrid::StateRes & st) { return std::get<8>(std::get(st)); @@ -157,6 +168,7 @@ class NaNAlgo : public ls2g::BaseAlgo { } }; + } // namespace TEST_CASE("check_grid accepts a valid grid and its state round-trip", "[check_grid]") @@ -253,6 +265,61 @@ TEST_CASE("check_grid rejects an out-of-range pos_topo_vect entry", "[check_grid CHECK_THROWS_AS(grid.check_grid(), std::out_of_range); } +// --- solver policy parameters carried by the serialized state ---------------- +// AlgoConfig is part of StateRes, so these values arrive from a pickle / binary +// file. refactor_every_n == 0 used to reach `iter % refactor_every_n` and kill +// the process with SIGFPE (integer division by zero); it must be rejected on the +// way in instead. + +TEST_CASE("a state with refactor_every_n = 0 is rejected, not divided by", "[check_grid][algo_config]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(ac_algo_int_params(st).size() >= 4); + ac_algo_int_params(st)[3] = 0; // refactor_every_n + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("a state with a negative refactor_every_n is rejected", "[check_grid][algo_config]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + ac_algo_int_params(st)[3] = -3; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("a state with an out-of-range line-search parameter is rejected", "[check_grid][algo_config]") +{ + LSGrid grid = make_valid_grid(); + + SECTION("ls_rho outside (0, 1)") { + LSGrid::StateRes st = grid.get_state(); + REQUIRE(ac_algo_real_params(st).size() >= 6); + ac_algo_real_params(st)[3] = 1.5; // ls_rho + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); + } + SECTION("non-finite max_dVa") { + LSGrid::StateRes st = grid.get_state(); + ac_algo_real_params(st)[0] = std::numeric_limits::quiet_NaN(); + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); + } +} + +TEST_CASE("a valid algo config still round-trips", "[check_grid][algo_config]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + LSGrid restored; + CHECK_NOTHROW(restored.set_state(st)); + CHECK(restored.get_ac_algo_config().int_params[3] == grid.get_ac_algo_config().int_params[3]); +} + TEST_CASE("a solver returning a wrong-sized voltage vector is rejected, not indexed", "[check_grid][plugin]") { ls2g::AlgorithmRegistry::instance().register_solver( From 528719e5e8034eac91cb06df26ff195d57e70fb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 06:43:56 +0000 Subject: [PATCH 158/166] Serialize the algorithm by name so plugin grids round-trip A grid records which AC / DC algorithm it was using. That was stored as an AlgorithmType enum, and every external (plugin) solver collapses onto the catch-all AlgorithmType::Custom, so the plugin's identity was lost at save time. The consequences were worse than "the plugin is missing": * get_state() silently succeeded, producing a file that could NEVER be loaded again -- even with the plugin loaded, since the name was gone; * set_state() always threw "AlgorithmType::Custom is not a concrete solver; use the string-based change_algorithm(name) overload", a message aimed at a library developer, not at whoever saved the grid; * copy() threw the same way, so a grid using a plugin could not be copied at all. Store the registry *name* instead (StateRes now holds two std::string), and restore by name. AlgorithmSelector keeps the name it was selected with (get_name()), which the enum cannot express for plugins. copy() goes through the name too. This changes the serialized layout, so BINARY_FORMAT_VERSION is bumped 3 -> 4 and the reference fixture is regenerated (it now stores 'NR_SparseLU', making it loadable by builds without KLU as well). When the saved solver is not registered on load, the error now names it, distinguishes a plugin from a built-in needing an optional backend (KLU / NICSLU / CKTSO), says what to do about each, and lists the solvers that are available. The name read from the file is escaped with printable() (now exported from BinaryArchive.hpp) before being embedded in the message: a corrupted one holds arbitrary bytes, which made pybind11 raise UnicodeDecodeError instead of the intended RuntimeError -- caught by the existing corruption sweep. Add LSGrid.load_binary_without_algorithm(path) (set_state gains a `restore_algorithm` flag): loads the grid data and keeps the default solvers, so a grid saved with an unavailable solver is still usable. Every other check (format, corruption, check_grid) is applied identically. Document that the solver is persisted *by name only*: a name is not a strong identity, so two plugins sharing a name silently substitute for each other, and plugin authors should prefix their solver names. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- CHANGELOG.rst | 16 +++ docs/binary_serialization.rst | 16 +++ docs/solver_plugin.rst | 18 +++ ...format3.lsb => case14_sandbox_format4.lsb} | Bin 4755 -> 4781 bytes .../tests/test_binary_serialization.py | 2 +- lightsim2grid/tests/test_check_grid.py | 16 +++ src/bindings/python/binding_lsgrid.cpp | 16 +++ src/core/AlgorithmSelector.cpp | 2 + src/core/AlgorithmSelector.hpp | 7 + src/core/BinaryArchive.cpp | 11 +- src/core/BinaryArchive.hpp | 31 +++- src/core/LSGrid.cpp | 87 +++++++++--- src/core/LSGrid.hpp | 32 ++++- src/tests/test_check_grid.cpp | 132 ++++++++++++++++++ 14 files changed, 346 insertions(+), 40 deletions(-) rename lightsim2grid/tests/binary_format_fixture/{case14_sandbox_format3.lsb => case14_sandbox_format4.lsb} (97%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 56c18ebe..3f185486 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -124,6 +124,22 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an out-of-range bus id in a crafted binary file) now raises a clean exception instead of causing an out-of-bounds access during the next powerflow. +- [BREAKING] the binary format version is bumped to 4: the AC / DC algorithm is now stored + as its registry **name** instead of an ``AlgorithmType`` enum. Files written by an older + lightsim2grid are rejected with a clear error (they were already only readable by the + same format version). +- [FIXED] a grid using a solver from a plugin could be saved but never loaded back, and + could not even be copied: only the ``AlgorithmType`` was stored, and every external + solver collapses onto ``AlgorithmType::Custom``, which is not a concrete solver. The + solver name is now stored, so such a grid round-trips (and ``copy()`` works) as long as + the plugin is loaded. +- [ADDED] when a grid is loaded whose solver is not registered here (a plugin that has not + been loaded, or a built-in needing an optional KLU / NICSLU / CKTSO backend this build + lacks), the error now names the solver, says how to obtain it, and lists the solvers + that *are* available -- instead of an internal message about ``AlgorithmType::Custom``. +- [ADDED] ``LSGrid.load_binary_without_algorithm(path)``: loads the grid data without + re-selecting the solver it was saved with (keeping the default solvers), so a grid saved + with an unavailable solver can still be loaded. All other checks are unchanged. - [ADDED] a sanity check on the voltages returned by an **external** (plugin) solver before they are consumed: a wrong-sized result raises, and a non-finite one is reported as a non-converged solve instead of propagating ``NaN`` / ``Inf``. Built-in diff --git a/docs/binary_serialization.rst b/docs/binary_serialization.rst index e6d3603b..c4cdd993 100644 --- a/docs/binary_serialization.rst +++ b/docs/binary_serialization.rst @@ -70,6 +70,22 @@ Individual element containers work the same way: destroys a previously saved file. Pass ``atomic=False`` to write the destination directly instead (marginally faster -- it skips one temporary file and rename -- but without that protection). +.. note:: + **The solver is saved by name.** A grid records the *registry name* of the AC and DC + algorithm it was using (``'NR_SparseLU'``, or whatever a plugin registered itself as), + and ``load_binary`` re-selects the solver with that name. Two consequences: + + - if that solver is not registered when you load the file -- a plugin you have not + loaded in this process, or a built-in needing an optional backend (KLU / NICSLU / + CKTSO) this build lacks -- ``load_binary`` raises, naming the solver and how to get + it. Use ``LSGrid.load_binary_without_algorithm(path)`` to load the grid *data* and + keep the default solvers instead, then pick one with ``change_algorithm``; + - a name is **not** a strong identity. If two different plugins register the same + solver name, the one loaded when the file is read is the one you get -- it may be a + completely different algorithm from the one that was used when the file was written, + with no way for lightsim2grid to notice. Give your plugin solvers distinctive names + (a project prefix, for instance) if you exchange files between environments. + .. danger:: Not even pickle is a *portable* format, and this binary format is even less so. Two rules to follow: diff --git a/docs/solver_plugin.rst b/docs/solver_plugin.rst index 4b9b1f5f..630b5152 100644 --- a/docs/solver_plugin.rst +++ b/docs/solver_plugin.rst @@ -53,6 +53,24 @@ The lookup flow is: └─ AlgorithmRegistry::instance().make("MySolver") └─ factory() → unique_ptr +.. important:: + **Choose your solver name carefully: it is an identity, and it is persisted.** + When a grid is saved (pickle or :ref:`binary-serialization`) it records the + *name* of the solver it was using, and loading re-selects the solver + registered under that name. So: + + - a grid saved while using your plugin can only be loaded where that plugin is + loaded. Otherwise loading raises an error naming the missing solver; the + reader can fall back to ``LSGrid.load_binary_without_algorithm(path)`` to get + the grid data with the default solvers; + - the name is the *only* thing stored — there is no version, checksum or + identity of the plugin itself. If two plugins register the same name, a grid + saved with one will silently be loaded with the other, even though they may + be entirely different algorithms. lightsim2grid cannot detect this, so + **prefix your solver names** with something specific to your project + (``"acme_NR_fast"`` rather than ``"NR_fast"``) if your files travel between + environments. + The ``AlgorithmRegistry`` C++ API (defined in ``AlgorithmRegistry.hpp``, installed to ``include/lightsim2grid/``): .. code-block:: cpp diff --git a/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format3.lsb b/lightsim2grid/tests/binary_format_fixture/case14_sandbox_format4.lsb similarity index 97% rename from lightsim2grid/tests/binary_format_fixture/case14_sandbox_format3.lsb rename to lightsim2grid/tests/binary_format_fixture/case14_sandbox_format4.lsb index 6379853c86e2aeab024eedf72abbc95e96c3387b..8375fb90365c2d99567304d47624fcaf00a7a352 100644 GIT binary patch delta 53 zcmbQNx>l9dC)mk|Wg{z}kP<%w1A|{saAsb5d~iWxQE{qIC^t~Z#Tm|>EG<+40C1-c Ag#Z8m delta 27 hcmZ3hI$4#~C)mk|c_S;I5C=O00|O@zPc{{*002=p1m*w$ diff --git a/lightsim2grid/tests/test_binary_serialization.py b/lightsim2grid/tests/test_binary_serialization.py index 451bea00..d3bdc85e 100644 --- a/lightsim2grid/tests/test_binary_serialization.py +++ b/lightsim2grid/tests/test_binary_serialization.py @@ -580,7 +580,7 @@ def test_converter_station_type(self): # bump) with: python -m lightsim2grid.tests.test_binary_serialization regen FIXTURE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "binary_format_fixture", - "case14_sandbox_format3.lsb") + "case14_sandbox_format4.lsb") FIXTURE_ENV_NAME = "l2rpn_case14_sandbox" FIXTURE_N_SUB = 14 FIXTURE_N_LOAD = 11 diff --git a/lightsim2grid/tests/test_check_grid.py b/lightsim2grid/tests/test_check_grid.py index 8c5baa35..10c8cd6e 100644 --- a/lightsim2grid/tests/test_check_grid.py +++ b/lightsim2grid/tests/test_check_grid.py @@ -154,6 +154,22 @@ def test_poisoned_algo_config_is_rejected(self): with self.assertRaises(RuntimeError): self.grid.set_ac_algo_config(cfg) + def test_load_binary_without_algorithm(self): + # the escape hatch for a grid saved with a solver that is not available + # here (typically a plugin that has not been loaded): the grid data loads, + # only the solver selection is skipped. + import tempfile, os + from lightsim2grid.lightsim2grid_cpp import LSGrid + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "grid.lsb") + self.grid.save_binary(path) + loaded = LSGrid.load_binary_without_algorithm(path) + self.assertIsNone(loaded.check_grid()) + self.assertEqual(len(loaded.get_loads()), self.n_load) + # and it is usable: a powerflow runs with the default solver + V = loaded.ac_pf(np.ones(loaded.total_bus(), dtype=complex), 20, 1e-8) + self.assertGreater(V.shape[0], 0) + def test_pickle_roundtrip_of_valid_grid(self): # pickling goes through set_state on load, which runs check_grid(); a valid # grid must round-trip cleanly. diff --git a/src/bindings/python/binding_lsgrid.cpp b/src/bindings/python/binding_lsgrid.cpp index 0549fe1c..c78e068f 100644 --- a/src/bindings/python/binding_lsgrid.cpp +++ b/src/bindings/python/binding_lsgrid.cpp @@ -66,6 +66,22 @@ views (eg `LightsimResultNetwork`), never by any C++ powerflow logic. .def_property_readonly("timer_last_dc_pf", &LSGrid::timer_last_dc_pf, "TODO"); add_pickle(lsgrid_cls, "LSGrid"); add_binary_serialization(lsgrid_cls); + // Companion to load_binary(): loads the grid *data* without re-selecting the + // solver it was saved with. Lets a grid saved with a solver that is not + // available here (a plugin that has not been loaded, or a built-in needing an + // optional backend this build lacks) still be loaded -- the grid keeps the + // default solvers, and you pick one yourself with change_algorithm(). + lsgrid_cls.def_static("load_binary_without_algorithm", [](const std::string& path) { + return ls2g::load_binary_generic_with( + path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, /*restore_algorithm=*/false); + }, py::arg("path"), + "Load a grid saved with save_binary(), WITHOUT restoring the AC / DC solver it " + "was saved with (nor that solver's configuration): the grid keeps the default " + "solvers and you select one yourself with change_algorithm(). Use this when " + "load_binary() reports that the saved solver is unavailable here -- typically a " + "solver plugin that has not been loaded in this process. Every other check " + "(binary format, corruption, grid consistency) is applied exactly as in " + "load_binary()."); lsgrid_cls // whole-grid consistency validation .def("check_grid", &LSGrid::check_grid, DocLSGrid::check_grid.c_str()) diff --git a/src/core/AlgorithmSelector.cpp b/src/core/AlgorithmSelector.cpp index 8bfc1e4b..d5ba3343 100644 --- a/src/core/AlgorithmSelector.cpp +++ b/src/core/AlgorithmSelector.cpp @@ -34,6 +34,7 @@ FDPF_BX_SparseLU& AlgorithmSelector::get_fdpf_bx_lu() { AlgorithmSelector::AlgorithmSelector() : _algo_type(AlgorithmType::NR_SparseLU), + _algo_name("NR_SparseLU"), _algo_type_used_for_nr(AlgorithmType::NR_SparseLU), _gridmodel_ptr(nullptr) { @@ -100,6 +101,7 @@ void AlgorithmSelector::change_algorithm(const std::string& name) _algo = std::move(new_algo); _algo_type = type; + _algo_name = name; // keeps the plugin name, which `type` (Custom) loses _algo_type_used_for_nr = type; if (_gridmodel_ptr) _algo->set_lsgrid(_gridmodel_ptr); diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 9f22696d..43dff999 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -113,6 +113,12 @@ class LS2G_API AlgorithmSelector final AlgorithmType get_type() const { return _algo_type; } + // Registry name of the currently selected algorithm. Unlike get_type(), + // this stays meaningful for external (plugin) solvers, whose type is the + // catch-all AlgorithmType::Custom: it is what gets serialized, so a grid + // using a plugin can be restored (see LSGrid::get_state/set_state). + const std::string& get_name() const { return _algo_name; } + // Polymorphic (instance-level) capability queries: unlike the // type-keyed overloads above (needed by LSGrid::change_algorithm to // route BEFORE a solver is constructed), these ask the CURRENTLY @@ -383,6 +389,7 @@ class LS2G_API AlgorithmSelector final std::unique_ptr _algo; AlgorithmType _algo_type; + std::string _algo_name; // registry name; survives for plugin solvers AlgorithmType _algo_type_used_for_nr; const LSGrid* _gridmodel_ptr; }; diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index c830643b..229fb280 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -45,11 +45,10 @@ std::string supported_formats_str() return oss.str(); } -// Strings read from a (possibly corrupted) file must be escaped before being -// embedded in an exception message: pybind11 converts what() to a python str -// as UTF-8, and raw garbage bytes would turn the intended RuntimeError into a -// UnicodeDecodeError (found by the corruption-sweep test). Also truncated, -// so a corrupted length cannot produce a message megabytes long. +} // anonymous namespace + +// (declared in BinaryArchive.hpp -- also used by LSGrid::set_state, which +// embeds the solver name read from the file in its error message) std::string printable(const std::string & s) { const std::size_t max_len = 64; @@ -68,8 +67,6 @@ std::string printable(const std::string & s) return oss.str(); } -} // anonymous namespace - BinaryArchive::BinaryArchive(const std::string & path, Mode mode, bool atomic_write): mode_(mode), path_(path), diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 5488057e..408c370e 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -60,6 +60,13 @@ namespace ls2g { +// Escape (and truncate to 64 chars) a string that came from a possibly-corrupted +// file, before embedding it in an exception message: pybind11 converts what() to +// a python str as UTF-8, so raw garbage bytes would turn the intended +// RuntimeError into a UnicodeDecodeError. Truncation keeps a corrupted length +// from producing a message megabytes long. +LS2G_API std::string printable(const std::string & s); + // Version of the binary *format* itself, decoupled from the lightsim2grid // package version: all lightsim2grid releases sharing the same format number // can read each other's binary files (most releases do not touch the @@ -73,7 +80,10 @@ namespace ls2g { // of being silently mis-read. If a future version wants to keep reading an // older format, add that format number to SUPPORTED_BINARY_FORMATS (in // BinaryArchive.cpp) together with the required migration code. -constexpr std::uint32_t BINARY_FORMAT_VERSION = 3; +// v4: the AC / DC algorithm is stored as its registry *name* (std::string) +// instead of an AlgorithmType enum, so a grid using an external (plugin) +// solver can be saved and restored -- see LSGrid::StateRes. +constexpr std::uint32_t BINARY_FORMAT_VERSION = 4; class LS2G_API BinaryArchive { @@ -389,9 +399,14 @@ void save_binary_generic(const T & obj, const std::string & path, ar.commit(); } -template -T load_binary_generic(const std::string & path, - const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { +// Reads a binary file and forwards `extra` to set_state(). Used by LSGrid to +// offer a load that does NOT restore the solver the grid was saved with (see +// LSGrid::set_state's `restore_algorithm`); with no extra argument this is the +// plain load_binary_generic below. +template +T load_binary_generic_with(const std::string & path, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor, + ExtraArgs&&... extra) { BinaryArchive ar(path, BinaryArchive::Mode::Read); ar.check_header(T::binary_type_tag(), v_major, v_medium, v_minor); typename T::StateRes state{}; @@ -399,10 +414,16 @@ T load_binary_generic(const std::string & path, ar.check_fully_consumed(); archive_detail::BinaryLoadFixup::apply(state); T res{}; - res.set_state(state); + res.set_state(state, std::forward(extra)...); return res; } +template +T load_binary_generic(const std::string & path, + const std::string & v_major, const std::string & v_medium, const std::string & v_minor) { + return load_binary_generic_with(path, v_major, v_medium, v_minor); +} + } // namespace ls2g #endif // BINARYARCHIVE_H diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 926fb8b0..fa10d992 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -63,11 +63,13 @@ LSGrid::LSGrid(const LSGrid & other) // static var compensators svcs_ = other.svcs_; - // assign the right solver + // assign the right solver. By *name*, not by AlgorithmType: an external + // (plugin) solver has type AlgorithmType::Custom, which the type-based + // overload rejects -- copying a grid using a plugin used to throw. reset(true, true, true); - _algo.change_algorithm(other.get_algo_type()); + _algo.change_algorithm(other._algo.get_name()); _algo.set_config(other.get_algo().get_config()); - _dc_algo.change_algorithm(other.get_dc_algo_type()); + _dc_algo.change_algorithm(other._dc_algo.get_name()); _dc_algo.set_config(other.get_dc_algo().get_config()); } @@ -121,8 +123,8 @@ LSGrid::StateRes LSGrid::get_state() const res_storage, res_hvdc_line, res_svc, - get_algo_type(), - get_dc_algo_type(), + _algo.get_name(), + _dc_algo.get_name(), res_ac_algo_cfg, res_dc_algo_cfg, init_kwargs_keys, @@ -132,7 +134,7 @@ LSGrid::StateRes LSGrid::get_state() const return res; }; -void LSGrid::set_state(LSGrid::StateRes & my_state) +void LSGrid::set_state(LSGrid::StateRes & my_state, bool restore_algorithm) { // after loading back, the instance need to be reset anyway // TODO see if it's worth the trouble NOT to do it @@ -225,21 +227,24 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) // handle the solver reset(true, true, true); - _algo.change_algorithm(std::get(my_state)); - _dc_algo.change_algorithm(std::get(my_state)); - - // algo configs -- must be restored *after* change_algorithm() above, - // since set_config() operates on the currently-selected concrete solver - // (same order as the copy constructor) - AlgoConfig ac_algo_cfg; - ac_algo_cfg.int_params = std::get<0>(state_ac_algo_cfg); - ac_algo_cfg.real_params = std::get<1>(state_ac_algo_cfg); - set_ac_algo_config(ac_algo_cfg); - - AlgoConfig dc_algo_cfg; - dc_algo_cfg.int_params = std::get<0>(state_dc_algo_cfg); - dc_algo_cfg.real_params = std::get<1>(state_dc_algo_cfg); - set_dc_algo_config(dc_algo_cfg); + if (restore_algorithm) { + _restore_algorithm(_algo, std::get(my_state), "AC"); + _restore_algorithm(_dc_algo, std::get(my_state), "DC"); + + // algo configs -- must be restored *after* change_algorithm() above, + // since set_config() operates on the currently-selected concrete solver + // (same order as the copy constructor). They describe the tuning of the + // solver we just re-selected, so they are skipped together with it. + AlgoConfig ac_algo_cfg; + ac_algo_cfg.int_params = std::get<0>(state_ac_algo_cfg); + ac_algo_cfg.real_params = std::get<1>(state_ac_algo_cfg); + set_ac_algo_config(ac_algo_cfg); + + AlgoConfig dc_algo_cfg; + dc_algo_cfg.int_params = std::get<0>(state_dc_algo_cfg); + dc_algo_cfg.real_params = std::get<1>(state_dc_algo_cfg); + set_dc_algo_config(dc_algo_cfg); + } // relevant kwargs the grid was built with (eg by init_from_pypowsybl) init_kwargs_.clear(); @@ -259,6 +264,46 @@ void LSGrid::set_state(LSGrid::StateRes & my_state) check_grid(); }; +void LSGrid::_restore_algorithm(AlgorithmSelector & algo_selector, + const std::string & name, + const char * ac_or_dc) +{ + if (AlgorithmRegistry::instance().is_registered(name)) { + algo_selector.change_algorithm(name); + return; + } + // The name was resolvable when the grid was saved but is not now. Either the + // solver comes from a plugin that has not been loaded in this process, or it + // needs an optional linear-algebra backend (KLU / NICSLU / CKTSO) this build + // was not compiled with. Say so, and say what to do about it -- the grid data + // itself is perfectly fine, only the solver choice cannot be honoured. + // `name` comes straight from the file: escape it (a corrupted one can hold + // arbitrary bytes, which would make pybind11 raise UnicodeDecodeError while + // converting what() instead of the RuntimeError we mean to report). + std::ostringstream exc_; + exc_ << "LSGrid::set_state: this grid was saved using the " << ac_or_dc + << " solver '" << printable(name) << "', which is not available here. "; + if (name.rfind("NR_", 0) == 0 || name.rfind("NRSing_", 0) == 0 || + name.rfind("DC_", 0) == 0 || name.rfind("FDPF_", 0) == 0) { + exc_ << "It looks like a built-in solver relying on an optional linear-algebra " + << "backend (KLU / NICSLU / CKTSO) that this build of lightsim2grid does not " + << "include: reinstall lightsim2grid with that support, or re-save the grid " + << "after selecting a solver available everywhere (eg 'NR_SparseLU'). "; + } else { + exc_ << "It is most likely provided by a solver plugin: load it first with " + << "lightsim2grid.load_algorithm_plugin(), then load this " + << "grid again. "; + } + exc_ << "Solvers currently available: "; + const std::vector available = AlgorithmRegistry::instance().available_algorithm_names(); + for (std::size_t i = 0; i < available.size(); ++i) { + if (i) exc_ << ", "; + exc_ << "'" << available[i] << "'"; + } + exc_ << "."; + throw std::runtime_error(exc_.str()); +} + void LSGrid::check_grid() const { const int nb_bus = static_cast(substations_.nb_bus()); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 3c2a96c6..33bd0a63 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -84,9 +84,13 @@ class LS2G_API LSGrid final HvdcLineContainer::StateRes, // static var compensators (appended; old pickles are version-gated) SvcContainer::StateRes, - // algo types - AlgorithmType, // ac_algo - AlgorithmType, // dc_algo + // algo *registry names* (not AlgorithmType): the enum collapses + // every external/plugin solver onto AlgorithmType::Custom, which + // loses the information needed to restore it (and made loading + // such a grid fail unconditionally). The name round-trips, and is + // also immune to a renumbering of the enum. + std::string, // ac_algo name + std::string, // dc_algo name // algo config (scaling/refactor/line-search params, appended; // old pickles are version-gated): int_params, real_params std::tuple, std::vector >, // ac_algo_config @@ -121,8 +125,8 @@ class LS2G_API LSGrid final static const std::size_t STORAGE_ID = 14; static const std::size_t HVDC_ID = 15; static const std::size_t SVC_ID = 16; - static const std::size_t AC_ALGO_TYPE_ID = 17; - static const std::size_t DC_ALGO_TYPE_ID = 18; + static const std::size_t AC_ALGO_NAME_ID = 17; + static const std::size_t DC_ALGO_NAME_ID = 18; static const std::size_t AC_ALGO_CONFIG_ID = 19; static const std::size_t DC_ALGO_CONFIG_ID = 20; static const std::size_t INIT_KWARGS_KEYS_ID = 21; @@ -511,7 +515,13 @@ class LS2G_API LSGrid final //pickle LSGrid::StateRes get_state() const ; - void set_state(LSGrid::StateRes & my_state) ; + // `restore_algorithm == true` (the default) also re-selects the AC / DC + // solver the grid was saved with, and re-applies its configuration. It + // throws if that solver is not registered here (a plugin that has not + // been loaded, or a built-in needing an optional backend this build + // lacks). Pass false to load the grid *data* only and keep this object's + // current (default) solvers -- see load_binary_without_algorithm(). + void set_state(LSGrid::StateRes & my_state, bool restore_algorithm = true) ; // Whole-grid consistency check. Verifies that every index the grid carries // (element bus ids, substation ids, position in the topology vector, @@ -527,6 +537,16 @@ class LS2G_API LSGrid final // hot path. void check_grid() const; + private: + // Re-select, by registry name, the algorithm a grid was saved with. + // Throws a message naming the missing solver (and how to get it) when it + // is not registered here -- typically a plugin that has not been loaded, + // or a built-in needing an optional backend this build lacks. + static void _restore_algorithm(AlgorithmSelector & algo_selector, + const std::string & name, + const char * ac_or_dc); + public: + // fast binary serialization (additive alternative to pickle, see // BinaryArchive.hpp -- readable by any lightsim2grid version sharing // the same BINARY_FORMAT_VERSION) diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index 59e612e5..a70c92c0 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -120,6 +120,31 @@ std::vector& gen_regulated_bus(LSGrid::StateRes & st) return std::get<8>(std::get(st)); } +// A well-behaved AC solver (returns the input voltages, claims convergence). +// Registered under a non-built-in name it stands in for a plugin solver, whose +// AlgorithmType is the catch-all AlgorithmType::Custom. +class PluginLikeAlgo : public ls2g::BaseAlgo { +public: + PluginLikeAlgo() : ls2g::BaseAlgo(/*is_ac=*/true) {} + bool compute_pf(const ls2g::EigenRefConstCplxSpMat & /*Ybus*/, + const Eigen::Ref & V, + const Eigen::Ref & /*Sbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/, + int /*max_iter*/, real_type /*tol*/) override + { + V_ = V; + Va_ = V.array().arg(); + Vm_ = V.array().abs(); + n_ = static_cast(V.size()); + nr_iter_ = 1; + err_ = ls2g::ErrorType::NoError; + return true; + } +}; + // A BaseAlgo that claims convergence but returns V/Va/Vm one entry too long. class WrongSizeAlgo : public ls2g::BaseAlgo { public: @@ -349,3 +374,110 @@ TEST_CASE("a solver returning non-finite voltages is reported as non-converged", CHECK(res.size() == 0); // diverged => empty result CHECK_FALSE(grid.get_algo().converged()); // marked non-converged, no NaN leaked } + +// --- a grid using an external (plugin) solver survives serialization --------- +// The algorithm is stored by registry name, so a plugin solver round-trips (and +// a grid using one can be copied). Storing AlgorithmType instead collapsed every +// plugin onto AlgorithmType::Custom, which made both operations throw. + +TEST_CASE("a grid using a plugin solver round-trips through get_state/set_state", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__roundtrip_plugin_algo__", + [] { return std::unique_ptr(new PluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__roundtrip_plugin_algo__"); + REQUIRE(grid.get_algo_type() == ls2g::AlgorithmType::Custom); + + LSGrid::StateRes st = grid.get_state(); + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + // the *same* plugin solver is selected again, not a silent fallback + CHECK(restored.get_algo_type() == ls2g::AlgorithmType::Custom); + CHECK(restored.get_algo().get_name() == "__roundtrip_plugin_algo__"); +} + +TEST_CASE("a grid using a plugin solver can be copied", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__copy_plugin_algo__", + [] { return std::unique_ptr(new PluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__copy_plugin_algo__"); + + LSGrid copied = grid.copy(); + CHECK(copied.get_algo().get_name() == "__copy_plugin_algo__"); +} + +TEST_CASE("loading a grid whose solver is not registered names it and how to get it", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__vanishing_plugin_algo__", + [] { return std::unique_ptr(new PluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__vanishing_plugin_algo__"); + LSGrid::StateRes st = grid.get_state(); + + // simulate loading in a process where that plugin was never loaded + std::get(st) = "__a_plugin_nobody_loaded__"; + + LSGrid restored; + REQUIRE_THROWS_AS(restored.set_state(st), std::runtime_error); + try { + LSGrid again; + again.set_state(st); + } catch (const std::runtime_error & e) { + const std::string msg = e.what(); + CHECK(msg.find("__a_plugin_nobody_loaded__") != std::string::npos); // names the solver + CHECK(msg.find("load_algorithm_plugin") != std::string::npos); // says what to do + } +} + +TEST_CASE("a grid whose solver is unavailable still loads without the algorithm", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__escape_hatch_plugin_algo__", + [] { return std::unique_ptr(new PluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__escape_hatch_plugin_algo__"); + LSGrid::StateRes st = grid.get_state(); + std::get(st) = "__not_loaded_here__"; + + // strict load refuses... + LSGrid strict; + CHECK_THROWS_AS(strict.set_state(st), std::runtime_error); + + // ... but the grid data itself loads fine when the solver is not restored, + // keeping this object's default solver. + LSGrid lenient; + REQUIRE_NOTHROW(lenient.set_state(st, /*restore_algorithm=*/false)); + CHECK(lenient.get_algo().get_name() == "NR_SparseLU"); // the default + CHECK(lenient.get_loads().nb() == grid.get_loads().nb()); // data is there + CHECK_NOTHROW(lenient.check_grid()); + // and it can still run a powerflow with that default solver + const CplxVect V = lenient.ac_pf(flat_start(lenient), 20, 1e-8); + CHECK(V.size() > 0); +} + +TEST_CASE("a corrupted solver name is escaped in the error message", "[check_grid][plugin]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + // raw bytes, as a corrupted file would hold: they must not reach what() as-is + // (pybind11 would raise UnicodeDecodeError instead of our RuntimeError). + std::get(st) = std::string("\xff\xfe bad", 7); + + LSGrid restored; + try { + restored.set_state(st); + FAIL("expected set_state to throw"); + } catch (const std::runtime_error & e) { + const std::string msg = e.what(); + CHECK(msg.find("\xff") == std::string::npos); // escaped, not raw + CHECK(msg.find("\\xff") != std::string::npos); + } +} From bf665525f84f66ad84e5521bb51746333bc477b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:27:59 +0000 Subject: [PATCH 159/166] Restrict solver names to a safe ASCII whitelist A solver name is an identity, not a label: it is written into every serialized grid and is what re-selects the solver on load. Restrict it to first character : A-Z a-z _ other characters : A-Z a-z 0-9 _ . length : 1 to 64 characters (ls2g::is_valid_solver_name, in the installed AlgorithmRegistry.hpp). Why each part: * no non-ASCII -- otherwise a plugin can register a name visually identical to a built-in one ("NR_SparseLU" with a Cyrillic 'a') and masquerade as it in every error message, doc and available_algorithm_names() listing while being a different registry key; * no control characters -- names are printed into error messages and logs, where a newline allows log injection and an ESC byte terminal-escape injection; * bounded length -- bounds what goes into a file, into an error message and into the diagnostic buffer a plugin is handed; * '.' allowed (not first) for namespacing ("acme.NR_fast"), the recommended way to avoid the name collisions documented last commit; digits allowed (not first) so "solver_v2" works without a name looking like a number. The set is deliberately minimal -- a whitelist can be widened later without breaking anyone, but not narrowed. Hyphen is left out: '_' covers the need. Enforced where names enter: AlgorithmRegistry::register_solver and register_all (so a bad plugin name fails at load_algorithm_plugin() time, and register_all still rolls the whole batch back), and LSGrid::set_state, where a name that cannot even be a solver name means the state is corrupted and is reported as such rather than sending the reader after a plugin that never existed. Registry messages now escape the offending name with printable(), which moves from BinaryArchive to Utils so both can use it. Every existing name complies: the 25 built-ins, the 5 names the shipped example plugins register, and every name used in the tests. One test is adapted: test_plugin_registration's overflow case registered a 1000-character name, which is now refused -- it exercises the rejection path instead, and asserts the bounded, NUL-terminated diagnostic directly (the message no longer necessarily fills the buffer, since printable() truncates the name). Verified end to end with the shipped example plugin: it builds, loads, registers 'DummyExternal', and a grid using it saves, reloads (with and without the algorithm), pickles and copies. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- CHANGELOG.rst | 8 +++ docs/solver_plugin.rst | 21 ++++++- lightsim2grid/tests/test_check_grid.py | 13 ++++ src/core/AlgorithmRegistry.cpp | 22 +++++-- src/core/AlgorithmRegistry.hpp | 60 ++++++++++++++++++ src/core/BinaryArchive.cpp | 20 ------ src/core/BinaryArchive.hpp | 8 +-- src/core/LSGrid.cpp | 10 +++ src/core/Utils.cpp | 21 +++++++ src/core/Utils.hpp | 9 +++ src/tests/test_plugin_registration.cpp | 86 +++++++++++++++++++++++--- 11 files changed, 237 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3f185486..db396773 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -124,6 +124,14 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an out-of-range bus id in a crafted binary file) now raises a clean exception instead of causing an out-of-bounds access during the next powerflow. +- [BREAKING] solver names are now restricted to ``[A-Za-z_][A-Za-z0-9_.]{0,63}`` (start with + an ASCII letter or ``_``; then ASCII letters, digits, ``_`` or ``.``; at most 64 + characters). Registering any other name is refused. A solver name is written into every + serialized grid and is what re-selects the solver on load, so it has to be an identity + that cannot be spoofed (a non-ASCII homoglyph of a built-in name), cannot inject content + into an error message or a log (control characters), and is bounded in length. Every + built-in and every name used by the shipped example plugins already complies; a plugin + using an exotic name must rename its solver. - [BREAKING] the binary format version is bumped to 4: the AC / DC algorithm is now stored as its registry **name** instead of an ``AlgorithmType`` enum. Files written by an older lightsim2grid are rejected with a clear error (they were already only readable by the diff --git a/docs/solver_plugin.rst b/docs/solver_plugin.rst index 630b5152..3b036cb6 100644 --- a/docs/solver_plugin.rst +++ b/docs/solver_plugin.rst @@ -57,7 +57,26 @@ The lookup flow is: **Choose your solver name carefully: it is an identity, and it is persisted.** When a grid is saved (pickle or :ref:`binary-serialization`) it records the *name* of the solver it was using, and loading re-selects the solver - registered under that name. So: + registered under that name. + + Because of that, names are restricted to a small ASCII subset — registering + anything else is refused, with an error explaining the rule:: + + first character : A-Z a-z _ + other characters : A-Z a-z 0-9 _ . + length : 1 to 64 characters + + (``lightsim2grid.lightsim2grid_cpp``'s C++ header exposes this as + ``ls2g::is_valid_solver_name``.) Non-ASCII characters are excluded so that no + plugin can register a name that *looks* identical to another solver's — say + ``NR_SparseLU`` with a Cyrillic ``а`` — and masquerade as it in error + messages, documentation and ``available_algorithm_names()`` listings while + being a different registry key. Control characters are excluded because names + are printed into error messages and logs, where a newline or an ESC byte + would let a name inject content. The length bound keeps a name from + overflowing the diagnostic buffer a plugin is given. + + Beyond the character set: - a grid saved while using your plugin can only be loaded where that plugin is loaded. Otherwise loading raises an error naming the missing solver; the diff --git a/lightsim2grid/tests/test_check_grid.py b/lightsim2grid/tests/test_check_grid.py index 10c8cd6e..fb8ffa23 100644 --- a/lightsim2grid/tests/test_check_grid.py +++ b/lightsim2grid/tests/test_check_grid.py @@ -154,6 +154,19 @@ def test_poisoned_algo_config_is_rejected(self): with self.assertRaises(RuntimeError): self.grid.set_ac_algo_config(cfg) + def test_every_registered_solver_name_is_valid(self): + # A solver name is persisted in every saved grid and re-selects the solver + # on load, so it is restricted to [A-Za-z_][A-Za-z0-9_.]{0,63} (rejecting + # non-ASCII homoglyphs of a built-in name, control characters, and + # unbounded lengths). Guards against a built-in ever gaining a bad name. + import re + pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]{0,63}$") + names = self.grid.available_algorithm_names() + self.assertGreater(len(names), 0) + for name in names: + with self.subTest(name): + self.assertRegex(name, pattern) + def test_load_binary_without_algorithm(self): # the escape hatch for a grid saved with a solver that is not available # here (typically a plugin that has not been loaded): the grid data loads, diff --git a/src/core/AlgorithmRegistry.cpp b/src/core/AlgorithmRegistry.cpp index 68bd9ae6..10a22bfa 100644 --- a/src/core/AlgorithmRegistry.cpp +++ b/src/core/AlgorithmRegistry.cpp @@ -26,8 +26,15 @@ void AlgorithmRegistry::register_solver(const std::string& name, Factory f, Ls2g "with [" + caller_tag.describe() + "]. Recompile the plugin with matching compiler/-march " "flags -- see docs/solver_plugin.rst, section \"Matching build flags\"."); } + if (!is_valid_solver_name(name)) { + throw std::invalid_argument( + "AlgorithmRegistry: refusing to register the solver name '" + printable(name) + "': " + + solver_name_rule() + ". This name is written into every grid saved while the solver is " + "selected, and is what picks it again on load, so it is restricted to a character set " + "that cannot be confused with another solver's nor corrupt an error message."); + } if (_factories.count(name)) { - throw std::invalid_argument("AlgorithmRegistry: a solver named '" + name + "' is already registered."); + throw std::invalid_argument("AlgorithmRegistry: a solver named '" + printable(name) + "' is already registered."); } _factories[name] = std::move(f); } @@ -43,12 +50,19 @@ void AlgorithmRegistry::register_all(FactoryMap batch, Ls2gAbiTag caller_tag) { "with [" + caller_tag.describe() + "]. Recompile the plugin with matching compiler/-march " "flags -- see docs/solver_plugin.rst, section \"Matching build flags\"."); } - // Reject the whole batch if any name is already taken, *before* touching - // the live registry, so a rejected plugin never half-registers. + // Reject the whole batch if any name is invalid or already taken, *before* + // touching the live registry, so a rejected plugin never half-registers. for (const auto& kv : batch) { + if (!is_valid_solver_name(kv.first)) { + throw std::invalid_argument( + "AlgorithmRegistry: refusing to register the solver name '" + printable(kv.first) + "': " + + solver_name_rule() + ". This name is written into every grid saved while the solver is " + "selected, and is what picks it again on load, so it is restricted to a character set " + "that cannot be confused with another solver's nor corrupt an error message."); + } if (_factories.count(kv.first)) { throw std::invalid_argument( - "AlgorithmRegistry: a solver named '" + kv.first + "' is already registered."); + "AlgorithmRegistry: a solver named '" + printable(kv.first) + "' is already registered."); } } // Strong exception guarantee: assemble the merged table off to the side, diff --git a/src/core/AlgorithmRegistry.hpp b/src/core/AlgorithmRegistry.hpp index 6092d45e..8528d54f 100644 --- a/src/core/AlgorithmRegistry.hpp +++ b/src/core/AlgorithmRegistry.hpp @@ -23,6 +23,66 @@ namespace ls2g { +// Longest accepted solver name (see is_valid_solver_name). Matches the length at +// which printable() truncates, so a name always fits whole in an error message. +constexpr std::size_t SOLVER_NAME_MAX_LEN = 64; + +/** + * Is `name` an acceptable solver name? + * + * first character : A-Z a-z _ + * other characters : A-Z a-z 0-9 _ . + * length : 1 to SOLVER_NAME_MAX_LEN characters + * + * A solver name is an identity, not just a label: it is written into every + * serialized grid (see LSGrid::StateRes) and is what selects the solver again on + * load. Restricting it to this ASCII subset is what makes that identity + * trustworthy and safe to display: + * + * - **no non-ASCII**: otherwise a plugin could register a name that is visually + * identical to a built-in one (say "NR_SparseLU" with a Cyrillic 'a') and + * masquerade as it in every error message, doc and available_algorithm_names() + * listing, while being a different registry key; + * - **no control characters**: names end up in exception messages and logs, so a + * newline would allow log injection and an ESC byte terminal-escape injection; + * - **bounded length**: bounds what goes into a file, into an error message and + * into a plugin's diagnostic buffer; + * - **'.' allowed (but not first)**: lets plugin authors namespace their solvers + * ("acme.NR_fast"), which is the recommended way to avoid name collisions -- + * see docs/solver_plugin.rst; + * - **digits allowed (but not first)**: "solver_v2" is natural, while a leading + * digit would let a name look like a number. + * + * The set is deliberately minimal: it can be widened later without breaking + * anyone, whereas narrowing it could not. + */ +inline bool is_valid_solver_name(const std::string& name) noexcept { + if (name.empty() || name.size() > SOLVER_NAME_MAX_LEN) return false; + // NB explicit ranges, not std::isalpha & co: those are locale-dependent and + // are UB for negative char values. + const unsigned char first = static_cast(name[0]); + const bool first_ok = (first >= 'A' && first <= 'Z') || + (first >= 'a' && first <= 'z') || + (first == '_'); + if (!first_ok) return false; + for (std::size_t i = 1; i < name.size(); ++i) { + const unsigned char c = static_cast(name[i]); + const bool ok = (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + (c == '_') || (c == '.'); + if (!ok) return false; + } + return true; +} + +// Human-readable statement of the rule above, for error messages. +inline std::string solver_name_rule() { + return "a solver name must be 1 to " + std::to_string(SOLVER_NAME_MAX_LEN) + + " characters, start with an ASCII letter or '_', and contain only ASCII " + "letters, digits, '_' or '.'"; +} + class LS2G_API AlgorithmRegistry { public: using Factory = std::function()>; diff --git a/src/core/BinaryArchive.cpp b/src/core/BinaryArchive.cpp index 229fb280..6ac49987 100644 --- a/src/core/BinaryArchive.cpp +++ b/src/core/BinaryArchive.cpp @@ -47,26 +47,6 @@ std::string supported_formats_str() } // anonymous namespace -// (declared in BinaryArchive.hpp -- also used by LSGrid::set_state, which -// embeds the solver name read from the file in its error message) -std::string printable(const std::string & s) -{ - const std::size_t max_len = 64; - std::ostringstream oss; - for (std::size_t i = 0; i < s.size() && i < max_len; ++i) { - const unsigned char c = static_cast(s[i]); - if (c >= 0x20 && c < 0x7f) { - oss << static_cast(c); - } else { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\x%02x", c); - oss << buf; - } - } - if (s.size() > max_len) oss << "... (" << s.size() << " chars)"; - return oss.str(); -} - BinaryArchive::BinaryArchive(const std::string & path, Mode mode, bool atomic_write): mode_(mode), path_(path), diff --git a/src/core/BinaryArchive.hpp b/src/core/BinaryArchive.hpp index 408c370e..4385f2ed 100644 --- a/src/core/BinaryArchive.hpp +++ b/src/core/BinaryArchive.hpp @@ -60,12 +60,8 @@ namespace ls2g { -// Escape (and truncate to 64 chars) a string that came from a possibly-corrupted -// file, before embedding it in an exception message: pybind11 converts what() to -// a python str as UTF-8, so raw garbage bytes would turn the intended -// RuntimeError into a UnicodeDecodeError. Truncation keeps a corrupted length -// from producing a message megabytes long. -LS2G_API std::string printable(const std::string & s); +// NB `printable()` (used to escape strings read from a corrupted file before +// embedding them in an error message) now lives in Utils.hpp, included above. // Version of the binary *format* itself, decoupled from the lightsim2grid // package version: all lightsim2grid releases sharing the same format number diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index fa10d992..81436c48 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -268,6 +268,16 @@ void LSGrid::_restore_algorithm(AlgorithmSelector & algo_selector, const std::string & name, const char * ac_or_dc) { + // A name that cannot even be a solver name never was one: the state is + // corrupted (or was not produced by lightsim2grid). Say so, rather than + // sending the reader looking for a plugin that never existed. + if (!is_valid_solver_name(name)) { + std::ostringstream exc_; + exc_ << "LSGrid::set_state: the " << ac_or_dc << " solver name read from this state, '" + << printable(name) << "', is not a valid solver name (" << solver_name_rule() + << "). This state is corrupted."; + throw std::runtime_error(exc_.str()); + } if (AlgorithmRegistry::instance().is_registered(name)) { algo_selector.change_algorithm(name); return; diff --git a/src/core/Utils.cpp b/src/core/Utils.cpp index 9e4d5983..43b39ea6 100644 --- a/src/core/Utils.cpp +++ b/src/core/Utils.cpp @@ -8,8 +8,29 @@ #include "Utils.hpp" +#include // std::snprintf +#include + namespace ls2g { +std::string printable(const std::string & s) +{ + const std::size_t max_len = 64; + std::ostringstream oss; + for (std::size_t i = 0; i < s.size() && i < max_len; ++i) { + const unsigned char c = static_cast(s[i]); + if (c >= 0x20 && c < 0x7f) { + oss << static_cast(c); + } else { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\x%02x", c); + oss << buf; + } + } + if (s.size() > max_len) oss << "... (" << s.size() << " chars)"; + return oss.str(); +} + std::ostream& operator<<(std::ostream& out, const ErrorType & error_type){ switch (error_type) { diff --git a/src/core/Utils.hpp b/src/core/Utils.hpp index 9a567bae..5670337c 100644 --- a/src/core/Utils.hpp +++ b/src/core/Utils.hpp @@ -63,6 +63,15 @@ enum class ErrorType {NoError, LicenseError}; std::ostream& operator<<(std::ostream& out, const ErrorType & error_type); +// Escape (and truncate to 64 chars) a string of untrusted origin -- read from a +// possibly-corrupted file, or supplied by a plugin -- before embedding it in an +// exception message. pybind11 converts what() to a python str as UTF-8, so raw +// garbage bytes would turn the intended RuntimeError into a UnicodeDecodeError; +// control characters would also let a name inject newlines or terminal escape +// sequences into logs. Truncation keeps a corrupted length from producing a +// message megabytes long. +LS2G_API std::string printable(const std::string & s); + struct Coeff{ Eigen::Index row_id; diff --git a/src/tests/test_plugin_registration.cpp b/src/tests/test_plugin_registration.cpp index b7bb7eeb..bb29bf32 100644 --- a/src/tests/test_plugin_registration.cpp +++ b/src/tests/test_plugin_registration.cpp @@ -137,14 +137,15 @@ TEST_CASE("plugin entry helper: a short error buffer is never overflowed", "[plu REQUIRE(std::strlen(errbuf) <= N - 1); // never wrote past the buffer } -TEST_CASE("plugin entry helper: a solver name longer than the buffer cannot overflow it", "[plugin]") +TEST_CASE("plugin entry helper: a pathologically long solver name cannot overflow the buffer", "[plugin]") { - // The scenario from the review: a plugin registers a name so long that the - // resulting error message far exceeds the host's fixed 512-byte diagnostic - // buffer. The bounded write must truncate it, never overflow. Under valgrind + // A plugin supplies a name far longer than both the solver-name limit and the + // host's fixed 512-byte diagnostic buffer. It is rejected (see + // is_valid_solver_name), and the rejection has to come back as a return code + // plus a message that fits the buffer -- never an overflow, and never an + // exception escaping the extern "C" entry point. Under valgrind // (cpp_unit_tests workflow) this also proves there is no out-of-bounds write. - g_long_solver_name = std::string(1000, 'x'); // >> 512 - ls2g::AlgorithmRegistry::instance().register_solver(g_long_solver_name, null_factory()); + g_long_solver_name = std::string(1000, 'x'); // >> 512, and >> SOLVER_NAME_MAX_LEN constexpr std::size_t host_buf_size = 512; // matches load_algorithm_plugin_impl char errbuf[host_buf_size]; @@ -152,14 +153,19 @@ TEST_CASE("plugin entry helper: a solver name longer than the buffer cannot over int rc = ls2g::detail::ls2g_run_plugin_entry( &ls2g::AlgorithmRegistry::instance(), errbuf, host_buf_size, - // staging the (already-registered) long name triggers the long - // "... is already registered" message, which contains the 1000-char name +[](ls2g::PluginRegistrar& r) { r.add(g_long_solver_name, null_factory()); }, ls2g::ls2g_current_abi_tag()); REQUIRE(rc != 0); - REQUIRE(errbuf[host_buf_size - 1] == '\0'); // terminated within bounds - REQUIRE(std::strlen(errbuf) <= host_buf_size - 1); // never wrote past the buffer + // The buffer holds a proper C string: a NUL somewhere within bounds. (Unlike + // the short-buffer case above we do not require the NUL to land on the very + // last byte: printable() truncates the offending name, so the message may now + // be shorter than the buffer -- what matters is that it is terminated and + // that nothing was written past the end.) + REQUIRE(std::memchr(errbuf, '\0', host_buf_size) != nullptr); + REQUIRE(std::strlen(errbuf) < host_buf_size); + REQUIRE(std::strlen(errbuf) > 0); // a diagnostic was written + REQUIRE_FALSE(ls2g::AlgorithmRegistry::instance().is_registered(g_long_solver_name)); } TEST_CASE("plugin entry helper: a null / zero-length buffer is handled safely", "[plugin]") @@ -193,3 +199,63 @@ TEST_CASE("plugin entry helper: a null registry pointer is reported, not derefer REQUIRE(rc == 2); REQUIRE(std::strlen(errbuf) > 0); } + +// --- solver-name whitelist --------------------------------------------------- +// A solver name is an identity: it is written into every serialized grid and is +// what re-selects the solver on load. It is restricted to +// [A-Za-z_][A-Za-z0-9_.]{0,63} -- see is_valid_solver_name. + +TEST_CASE("is_valid_solver_name accepts the names actually in use", "[plugin][solver_name]") +{ + // every built-in, and the names the shipped example plugins register + for (const char * name : {"NR_SparseLU", "NRSing_SparseLU", "DC_SparseLU", + "GaussSeidel", "GaussSeidelSynch", "FDPF_XB_SparseLU", + "NR_KLU", "NRRefactorRetry_CKTSO", + "DummyExternal", "NR_LM_SparseLU", "NRDistSlack_KLU"}) { + INFO(name); + CHECK(ls2g::is_valid_solver_name(name)); + } + // and the shapes we explicitly want to allow + CHECK(ls2g::is_valid_solver_name("_leading_underscore")); + CHECK(ls2g::is_valid_solver_name("acme.NR_fast")); // namespaced (recommended) + CHECK(ls2g::is_valid_solver_name("solver_v2")); // digits, not first + CHECK(ls2g::is_valid_solver_name("A")); // shortest + CHECK(ls2g::is_valid_solver_name(std::string(ls2g::SOLVER_NAME_MAX_LEN, 'a'))); +} + +TEST_CASE("is_valid_solver_name rejects names that could confuse or corrupt", "[plugin][solver_name]") +{ + CHECK_FALSE(ls2g::is_valid_solver_name("")); // empty + CHECK_FALSE(ls2g::is_valid_solver_name(std::string(ls2g::SOLVER_NAME_MAX_LEN + 1, 'a'))); // too long + CHECK_FALSE(ls2g::is_valid_solver_name("2fast")); // leading digit + CHECK_FALSE(ls2g::is_valid_solver_name(".hidden")); // leading dot + CHECK_FALSE(ls2g::is_valid_solver_name("NR-SparseLU")); // hyphen not allowed + CHECK_FALSE(ls2g::is_valid_solver_name("NR SparseLU")); // space + CHECK_FALSE(ls2g::is_valid_solver_name("../../etc/passwd")); // path-like + CHECK_FALSE(ls2g::is_valid_solver_name("NR_SparseLU\nWARNING: x"));// log injection + CHECK_FALSE(ls2g::is_valid_solver_name("NR\x1b[31m_SparseLU")); // terminal escape + CHECK_FALSE(ls2g::is_valid_solver_name(std::string("NR\0LU", 5))); // embedded NUL + // a homoglyph: "NR_SparseLU" with a Cyrillic 'а' (U+0430) in place of the 'a' + CHECK_FALSE(ls2g::is_valid_solver_name("NR_Sp\xd0\xb0rseLU")); +} + +TEST_CASE("registering an invalid solver name is refused", "[plugin][solver_name]") +{ + CHECK_THROWS_AS( + ls2g::AlgorithmRegistry::instance().register_solver("bad name!", null_factory()), + std::invalid_argument); + CHECK_FALSE(ls2g::AlgorithmRegistry::instance().is_registered("bad name!")); +} + +TEST_CASE("a batch containing an invalid name is rejected whole", "[plugin][solver_name]") +{ + ls2g::AlgorithmRegistry::FactoryMap batch; + batch.emplace("__valid_name_in_bad_batch__", null_factory()); + batch.emplace("also bad!", null_factory()); + + CHECK_THROWS_AS(ls2g::AlgorithmRegistry::instance().register_all(std::move(batch)), + std::invalid_argument); + // atomic: the valid name from the rejected batch must not survive either + CHECK_FALSE(ls2g::AlgorithmRegistry::instance().is_registered("__valid_name_in_bad_batch__")); + CHECK_FALSE(ls2g::AlgorithmRegistry::instance().is_registered("also bad!")); +} From 02481259c1d4ce216fb78b2946405ec835b222da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 07:31:31 +0000 Subject: [PATCH 160/166] Strengthen the plugin-solver copy test, and cover DC plugins The copy test only asserted the copied grid's solver *name*. That is a stored string, so it would still have passed if copy() had carried the name across without building the solver. Assert what actually matters: * the copy reports the plugin solver (name + AlgorithmType::Custom); * it holds a live instance of it -- proven by running a powerflow on the copy, which only converges if the registry factory was actually called; * the copy is independent: re-selecting on the copy leaves the original on the plugin solver. Also cover the DC side, which was untested: LSGrid::change_algorithm routes on BaseAlgo::IS_AC, so a DC plugin lands in the other selector and needs copy() and get_state/set_state to carry *that* one by name. Adds DcPluginLikeAlgo (IS_AC == false, implements compute_pf_dc) and tests copy + state round-trip for it, including a DC powerflow to prove the restored instance is live. Checked these are not vacuous: reverting copy() to the previous enum-based version makes both copy tests fail, and they pass again on the fix. Signed-off-by: Claude Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YReCvKFeTQiLQeacZwP32G --- src/tests/test_check_grid.cpp | 78 ++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index a70c92c0..961314b5 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -145,6 +145,29 @@ class PluginLikeAlgo : public ls2g::BaseAlgo { } }; +// The DC counterpart of PluginLikeAlgo: IS_AC == false, so LSGrid routes it to +// the DC slot (see LSGrid::change_algorithm, which dispatches on IS_AC). +class DcPluginLikeAlgo : public ls2g::BaseAlgo { +public: + DcPluginLikeAlgo() : ls2g::BaseAlgo(/*is_ac=*/false) {} + bool compute_pf_dc(const ls2g::EigenRefConstRealSpMat & /*Bbus*/, + const Eigen::Ref & V, + const Eigen::Ref & /*Pbus*/, + const Eigen::Ref & /*slack_ids*/, + const Eigen::Ref & /*slack_weights*/, + const Eigen::Ref & /*pv*/, + const Eigen::Ref & /*pq*/) override + { + V_ = V; + Va_ = V.array().arg(); + Vm_ = V.array().abs(); + n_ = static_cast(V.size()); + nr_iter_ = 1; + err_ = ls2g::ErrorType::NoError; + return true; + } +}; + // A BaseAlgo that claims convergence but returns V/Va/Vm one entry too long. class WrongSizeAlgo : public ls2g::BaseAlgo { public: @@ -398,7 +421,7 @@ TEST_CASE("a grid using a plugin solver round-trips through get_state/set_state" CHECK(restored.get_algo().get_name() == "__roundtrip_plugin_algo__"); } -TEST_CASE("a grid using a plugin solver can be copied", "[check_grid][plugin]") +TEST_CASE("a grid using a plugin solver can be copied, and the copy uses that solver", "[check_grid][plugin]") { ls2g::AlgorithmRegistry::instance().register_solver( "__copy_plugin_algo__", @@ -408,7 +431,60 @@ TEST_CASE("a grid using a plugin solver can be copied", "[check_grid][plugin]") grid.change_algorithm("__copy_plugin_algo__"); LSGrid copied = grid.copy(); + + // the copy is on the same solver... CHECK(copied.get_algo().get_name() == "__copy_plugin_algo__"); + CHECK(copied.get_algo_type() == ls2g::AlgorithmType::Custom); + // ... and it is a real, working instance of it, not just the name carried + // over: the factory has to have been called for this powerflow to run. + // PluginLikeAlgo returns the input voltages, so convergence means it ran. + const CplxVect V = copied.ac_pf(flat_start(copied), 20, 1e-8); + REQUIRE(V.size() > 0); + CHECK(copied.get_algo().converged()); + + // the copy is independent: re-selecting on the copy leaves the original be + copied.change_algorithm("NR_SparseLU"); + CHECK(copied.get_algo().get_name() == "NR_SparseLU"); + CHECK(grid.get_algo().get_name() == "__copy_plugin_algo__"); + CHECK(grid.get_algo_type() == ls2g::AlgorithmType::Custom); +} + +TEST_CASE("a grid using a DC plugin solver can be copied, and the copy uses that solver", "[check_grid][plugin]") +{ + // DC plugins land in the other selector (LSGrid routes on BaseAlgo::IS_AC), + // so copy() has to carry that one across by name too. + ls2g::AlgorithmRegistry::instance().register_solver( + "__copy_dc_plugin_algo__", + [] { return std::unique_ptr(new DcPluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__copy_dc_plugin_algo__"); + REQUIRE(grid.get_dc_algo().get_name() == "__copy_dc_plugin_algo__"); + REQUIRE(grid.get_algo().get_name() == "NR_SparseLU"); // AC slot untouched + + LSGrid copied = grid.copy(); + CHECK(copied.get_dc_algo().get_name() == "__copy_dc_plugin_algo__"); + CHECK(copied.get_dc_algo_type() == ls2g::AlgorithmType::Custom); + // again: a live instance, proven by running a DC powerflow with it + const CplxVect V = copied.dc_pf(flat_start(copied), 1, 1e-8); + REQUIRE(V.size() > 0); + CHECK(copied.get_dc_algo().converged()); +} + +TEST_CASE("a grid using a DC plugin solver round-trips through get_state/set_state", "[check_grid][plugin]") +{ + ls2g::AlgorithmRegistry::instance().register_solver( + "__roundtrip_dc_plugin_algo__", + [] { return std::unique_ptr(new DcPluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + grid.change_algorithm("__roundtrip_dc_plugin_algo__"); + + LSGrid::StateRes st = grid.get_state(); + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + CHECK(restored.get_dc_algo().get_name() == "__roundtrip_dc_plugin_algo__"); + CHECK(restored.get_dc_algo_type() == ls2g::AlgorithmType::Custom); } TEST_CASE("loading a grid whose solver is not registered names it and how to get it", "[check_grid][plugin]") From 5b8bfd3d4c06cb7113a249ed4223c6ffcbeed73f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 08:04:54 +0000 Subject: [PATCH 161/166] Fix memory-safety holes on the untrusted-input paths Audit of every entry point that consumes data from outside the C++ core (python bindings, pickle, the binary format, the newtonpf/solver entry point), in the spirit of the refactor-every-n SIGFPE fix. Seven issues, all confirmed with a proof of concept before and after (SIGSEGV / "malloc(): unsorted double linked list corrupted" turning into a clean exception). Release wheels are -O3 -DNDEBUG, so neither Eigen's nor the standard library's own bounds checks are there to catch any of these. SubstationContainer::set_state did no validation whatsoever, and it restores the root of the grid's index space: nb_bus() is the bound check_grid() validates every element bus id against, bus_status_ is the vector those same ids then index, and n_sub_ is both the substation-id bound and a modulo divisor. A file declaring 4000 buses with a 1-entry bus_status passed check_grid() and corrupted the heap on the next init_bus_status(). n_sub_ == 0 reached `bus_id % n_sub_` (SIGFPE), and n_sub_ * nmax_busbar_per_sub_ could overflow the int holding it. set_ls_to_orig_internal sizes the reverse mapping from lpNorm() -- the maximum *absolute* value -- then indexes it with the values themselves, so an entry of -5 sized the vector from 5 and wrote at index -5. Reachable from the _ls_to_orig property, a pickle and a binary file alike. _init_kwargs is serialized as two parallel vectors with independently stored lengths; set_state walked the keys and indexed the values with the same counter, over-reading a vector. SubstationInfo read sub_names_[id] bounded only against the substation count, but the names are optional and the pandapower / matpower / powermodels loaders never set them: iterating get_substations() on any such grid segfaulted, with no crafted input at all. update_topo and update_slack_weights never checked the length of the arrays handed to them, though both index them (by topology position and by generator id) with unchecked Eigen operator(). Also: an out-of-range policy enum in an AlgoConfig is now rejected rather than silently falling into a default: branch and round-tripping back out, set_config validates before mutating anything, and set_orig_to_ls now actually builds the inverse mapping it claims to. check_grid() covers the substation container and the bus-id mapping vectors, which it previously ignored; docs/security.rst says so. Signed-off-by: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015CfJVFAd2ifp4qyZxp5Ec5 --- CHANGELOG.rst | 49 +++ docs/security.rst | 13 +- lightsim2grid/tests/test_check_grid.py | 160 +++++++++ src/core/LSGrid.cpp | 172 +++++++++- src/core/LSGrid.hpp | 8 +- src/core/SubstationContainer.cpp | 154 ++++++++- src/core/SubstationContainer.hpp | 20 +- .../element_container/GeneratorContainer.cpp | 10 + src/core/powerflow_algorithm/NRAlgo.hpp | 40 ++- src/tests/test_check_grid.cpp | 306 ++++++++++++++++++ 10 files changed, 902 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db396773..49de2ef0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -124,6 +124,55 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an out-of-range bus id in a crafted binary file) now raises a clean exception instead of causing an out-of-bounds access during the next powerflow. +- [FIXED] ``SubstationContainer::set_state`` performed **no** validation at all, and it + restores the root of the grid's index space: ``nb_bus()`` (the bound ``check_grid()`` + validates every element bus id against), ``bus_status_`` (the vector those same ids are + used to index) and ``n_sub_`` / ``nmax_busbar_per_sub_`` were all read from the file + independently of one another. A pickle or a binary file declaring, say, 4000 buses but a + 1-entry ``bus_status`` passed ``check_grid()`` and then corrupted the heap on the next + ``init_bus_status()`` (``disconnect_all_buses()`` writes ``nb_bus()`` entries into it). + All of these are now cross-checked on load, and re-checked by ``check_grid()``. +- [FIXED] ``n_sub_ == 0`` restored from a state reached ``sub_id_of_bus()``, which does + ``gridmodel_bus_id % n_sub_`` -- an integer division by zero (SIGFPE that kills the + process), the same class of bug as ``refactor_every_n == 0``. A grid with buses must now + declare a strictly positive substation count, and ``n_sub_ * nmax_busbar_per_sub_`` is + rejected if it overflows the ``int`` it is stored in. +- [FIXED] ``LSGrid::set_ls_to_orig`` accepted any value: ``set_ls_to_orig_internal`` sizes + the reverse mapping from ``lpNorm()`` (the maximum **absolute** value) and then + indexes it with the values themselves, so an entry of ``-5`` sized the vector from 5 and + wrote at index ``-5`` -- an out-of-bounds heap write, reachable from the ``_ls_to_orig`` + python property as well as from a pickle / binary file. Entries must now be ``-1`` or a + sane non-negative original-grid bus id. ``_orig_to_ls`` is range-checked the same way. +- [FIXED] ``_init_kwargs`` is serialized as two parallel vectors whose lengths are stored + independently; ``set_state`` walked the keys and indexed the values with the same + counter, so a file declaring more keys than values read past the end of the values + vector -- constructing ``std::string`` objects from arbitrary heap contents. The two + lengths must now match. +- [FIXED] iterating ``gridmodel.get_substations()`` crashed on any grid whose substation + names were never set (``set_substation_names`` is optional and the pandapower / matpower + / powermodels loaders never call it): ``SubstationInfo`` read ``sub_names_[id]`` + unconditionally, bounded only against the *substation count*. This needed no crafted + input at all. +- [FIXED] ``update_topo`` did not check the length of the ``has_changed`` / ``new_values`` + arrays it is given. They are indexed **by position in the topology vector** with an + unchecked Eigen ``operator()``: the positions are validated (``check_grid()`` proves they + form a permutation of ``[0, dim_topo)``), but a caller-supplied array shorter than + ``dim_topo`` was simply read past its end. Both must now have exactly ``dim_topo`` + entries. +- [FIXED] ``update_slack_weights`` did not check that its ``could_be_slack`` array had one + entry per generator, although it indexes it by generator id. +- [FIXED] ``check_grid()`` now also validates the substation container's own internal + consistency and the bus-id mapping vectors (``_ls_to_orig`` / ``_orig_to_ls`` / + ``_bus_fusion_rep``), which it previously ignored entirely. +- [FIXED] an ``AlgoConfig`` (part of the serialized grid state) carrying an out-of-range + ``RefactorPolicyType`` / ``ScalingPolicyType`` is now rejected instead of being cast to + the enum, silently falling into a ``default:`` branch and round-tripping back out of + ``get_config()``. ``set_config`` also validates both policies *before* touching any + member, so a rejected config no longer leaves a half-applied one behind. +- [FIXED] ``LSGrid::set_orig_to_ls`` did not actually build the inverse of the mapping it + was given: it walked only the first *n* entries (*n* = the number of non-``-1`` ones, + which are not necessarily at the front) and stored the lightsim bus id where the + original one belonged. ``_ls_to_orig`` and ``_orig_to_ls`` are now true inverses. - [BREAKING] solver names are now restricted to ``[A-Za-z_][A-Za-z0-9_.]{0,63}`` (start with an ASCII letter or ``_``; then ASCII letters, digits, ``_`` or ``.``; at most 64 characters). Registering any other name is refused. A solver name is written into every diff --git a/docs/security.rst b/docs/security.rst index b2f02fbf..09b923aa 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -83,9 +83,16 @@ the file, and it is validated at two levels: is internally consistent and safe to run a powerflow on. It checks that every index the grid carries is in range — the bus id of every element, the substation id and the position in the topology vector (both optional), and the generator -slack and remote-regulated bus references. It raises ``IndexError`` (an -out-of-range index) or ``RuntimeError`` (a structural inconsistency), and returns -``None`` for a consistent grid. +slack and remote-regulated bus references. It also checks the *shape* of the grid +itself: that the substation container's own arrays agree with each other (the bus +count, the per-bus status vector and ``n_sub × nmax_busbar_per_sub`` all describe +the same set of buses), and that the bus-id mapping vectors carried alongside the +grid (``_ls_to_orig`` / ``_orig_to_ls`` / ``_bus_fusion_rep``) are in range. That +part matters as much as the per-element checks: the bus count is the *bound* the +element checks are expressed against, so validating elements against a grid whose +own arrays disagree would prove nothing. It raises ``IndexError`` (an out-of-range +index) or ``RuntimeError`` (a structural inconsistency), and returns ``None`` for +a consistent grid. You normally do not need to call it yourself: diff --git a/lightsim2grid/tests/test_check_grid.py b/lightsim2grid/tests/test_check_grid.py index fb8ffa23..25320337 100644 --- a/lightsim2grid/tests/test_check_grid.py +++ b/lightsim2grid/tests/test_check_grid.py @@ -191,5 +191,165 @@ def test_pickle_roundtrip_of_valid_grid(self): self.assertIsNone(restored.check_grid()) +class TestSubstationAndMappingConsistency(unittest.TestCase): + """ + The substation container and the bus-id mapping vectors. + + ``SubstationContainer`` is the root of the grid's index space: ``nb_bus()`` + is the bound ``check_grid()`` validates every element's bus id against, and + ``bus_status_`` is the vector those same ids are used to index. They used to + be restored from a pickle / binary file with no cross-check at all, so a file + whose two lengths disagreed passed ``check_grid()`` and then corrupted the + heap on the next ``init_bus_status()``. Same idea for ``_ls_to_orig``, whose + *values* are used as indices into the reverse mapping it allocates. + + These are built by hand (no pandapower) so the checks are exercised directly. + """ + + def _grid(self, n_sub=3, n_busbar=2): + from lightsim2grid.lightsim2grid_cpp import LSGrid + grid = LSGrid() + grid.init_bus(n_sub, n_busbar, + np.array([138.0] * (n_sub * n_busbar)), 0, 0) + grid.init_loads(np.array([1.0]), np.array([0.5]), + np.array([0], dtype=np.int32)) + grid.init_generators(np.array([1.0]), np.array([1.0]), + np.array([-10.0]), np.array([10.0]), + np.array([1], dtype=np.int32)) + return grid + + def test_hand_built_grid_is_consistent(self): + # the new substation / mapping checks must not reject a legitimate grid + # (sub_vn_kv and the substation names are optional and normally empty). + self.assertIsNone(self._grid().check_grid()) + + def test_substation_info_without_names(self): + # `sub_names_` is optional -- the pandapower / matpower / powermodels + # loaders never set it -- and SubstationInfo used to read sub_names_[id] + # unconditionally, ie read a std::string out of bounds. Iterating the + # substations of such a grid used to segfault. + grid = self._grid() + self.assertEqual(list(grid.get_substation_names()), []) + for sub in grid.get_substations(): + self.assertEqual(sub.name, "") + self.assertEqual(sub.vn_kv, 138.0) + + def test_negative_ls_to_orig_is_rejected(self): + # the reverse mapping is sized from max(abs(values)) and then indexed with + # the values themselves: -5 sizes it from 5 and writes at index -5. + grid = self._grid() + bad = np.full(grid.total_bus(), -1, dtype=np.int32) + bad[0] = -5 + with self.assertRaises(IndexError): + grid._ls_to_orig = bad + + def test_absurd_ls_to_orig_is_rejected(self): + grid = self._grid() + bad = np.full(grid.total_bus(), -1, dtype=np.int32) + bad[0] = np.iinfo(np.int32).max + with self.assertRaises(IndexError): + grid._ls_to_orig = bad + + def test_valid_ls_to_orig_is_accepted(self): + grid = self._grid() + ok = np.arange(grid.total_bus(), dtype=np.int32) + grid._ls_to_orig = ok + np.testing.assert_array_equal(np.asarray(grid._ls_to_orig), ok) + self.assertIsNone(grid.check_grid()) + + def test_orig_to_ls_is_the_inverse_of_ls_to_orig(self): + # orig bus 0 has no lightsim counterpart; orig 1..6 map to ls 0..5. + grid = self._grid() + orig_to_ls = np.array([-1, 0, 1, 2, 3, 4, 5], dtype=np.int32) + grid._orig_to_ls = orig_to_ls + # the inverse: lightsim bus i corresponds to original bus i + 1 + np.testing.assert_array_equal(np.asarray(grid._ls_to_orig), + np.arange(1, 7, dtype=np.int32)) + + def test_out_of_range_orig_to_ls_is_rejected(self): + grid = self._grid() + bad = np.arange(grid.total_bus(), dtype=np.int32) + bad[0] = BIG # used to index a total_bus()-long _ls_to_orig + with self.assertRaises(IndexError): + grid._orig_to_ls = bad + + def test_update_topo_rejects_short_arrays(self): + # each container indexes has_changed / new_values by position in the + # topology vector with an unchecked Eigen operator(): a short array was + # simply read past its end. + grid = self._grid() + grid.set_load_pos_topo_vect(np.array([0], dtype=np.int32)) + grid.set_gen_pos_topo_vect(np.array([1], dtype=np.int32)) + with self.assertRaises(RuntimeError): + grid.update_topo(np.zeros(0, dtype=bool), np.zeros(0, dtype=np.int32)) + # correctly-sized (dim_topo == 2 here: one load + one gen), no-op call + grid.update_topo(np.zeros(2, dtype=bool), np.ones(2, dtype=np.int32)) + + def test_update_slack_weights_rejects_short_array(self): + grid = self._grid() # exactly one generator + with self.assertRaises(RuntimeError): + grid.update_slack_weights(np.zeros(0, dtype=bool)) + grid.update_slack_weights(np.ones(1, dtype=bool)) + + def test_poisoned_substation_state_is_rejected(self): + # go through the real pickle path: tamper with the substation block of a + # grid's state, then load it. Every case must raise, never corrupt memory. + import copyreg + import io + import pickle + from lightsim2grid.lightsim2grid_cpp import LSGrid + + SUBSTATION_ID = 7 + # SubstationContainer::StateRes = (n_sub, nmax_busbar, sub_vn_kv, + # bus_status, bus_vn_kv, sub_names, + # bus_vmin_kv, bus_vmax_kv) + def dump_with(grid, mutate): + outer = grid.__getstate__() + inner = list(outer[3]) + sub = list(inner[SUBSTATION_ID]) + mutate(sub) + inner[SUBSTATION_ID] = tuple(sub) + state = (outer[0], outer[1], outer[2], tuple(inner)) + + class _P(pickle.Pickler): + def reducer_override(self, obj): + if isinstance(obj, LSGrid): + return (copyreg.__newobj__, (LSGrid,), state) + return NotImplemented + + buf = io.BytesIO() + _P(buf, protocol=4).dump(grid) + return buf.getvalue() + + def set_bus_status_short(sub): + sub[3] = [True] + + def set_nsub_zero(sub): + sub[0] = 0 + + def set_nsub_overflow(sub): + sub[0] = 100000 + sub[1] = 100000 + + def set_bus_vn_kv_short(sub): + sub[4] = [138.0] + + for name, mutate in [("bus_status too short", set_bus_status_short), + ("n_sub == 0", set_nsub_zero), + ("n_sub * nmax overflows", set_nsub_overflow), + ("bus_vn_kv too short", set_bus_vn_kv_short)]: + with self.subTest(name): + blob = dump_with(self._grid(), mutate) + with self.assertRaises(RuntimeError): + pickle.loads(blob) + + def test_pickle_roundtrip_of_hand_built_grid(self): + import pickle + grid = self._grid() + restored = pickle.loads(pickle.dumps(grid)) + self.assertIsNone(restored.check_grid()) + self.assertEqual(restored.total_bus(), grid.total_bus()) + + if __name__ == "__main__": unittest.main() diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 81436c48..26401e4a 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -246,7 +246,19 @@ void LSGrid::set_state(LSGrid::StateRes & my_state, bool restore_algorithm) set_dc_algo_config(dc_algo_cfg); } - // relevant kwargs the grid was built with (eg by init_from_pypowsybl) + // relevant kwargs the grid was built with (eg by init_from_pypowsybl). + // The map is serialized as two parallel vectors, whose lengths are stored + // independently (both in a pickle and in the binary format): a file declaring + // more keys than values makes the loop below read past the end of + // init_kwargs_values -- an out-of-bounds read over std::string objects, ie + // constructing a std::string from whatever the heap holds there. Check first. + if (init_kwargs_keys.size() != init_kwargs_values.size()) { + std::ostringstream exc_; + exc_ << "LSGrid::set_state: the serialized `_init_kwargs` is inconsistent: " + << init_kwargs_keys.size() << " keys but " << init_kwargs_values.size() + << " values. They are two parallel vectors and must have the same length."; + throw std::runtime_error(exc_.str()); + } init_kwargs_.clear(); for (std::size_t i = 0; i < init_kwargs_keys.size(); ++i) { init_kwargs_[init_kwargs_keys[i]] = init_kwargs_values[i]; @@ -316,6 +328,12 @@ void LSGrid::_restore_algorithm(AlgorithmSelector & algo_selector, void LSGrid::check_grid() const { + // The substation container FIRST: it defines nb_bus / nb_sub, the bounds every + // per-element check below is expressed against, and it carries the vector + // (bus_status_) those very ids are used to index. Validating elements against a + // self-inconsistent substation container would prove nothing. + substations_.check_valid(); + const int nb_bus = static_cast(substations_.nb_bus()); const int nb_sub = substations_.nb_sub(); @@ -362,6 +380,65 @@ void LSGrid::check_grid() const seen[pos] = 1; } } + + // Bus-id mapping vectors carried alongside the grid. They are never read by the + // C++ powerflow itself (only by downstream python result views), but they are + // part of the serialized state, they are sized against the grid, and + // set_ls_to_orig_internal() indexes _orig_to_ls with the *values* of + // _ls_to_orig -- so an inconsistent pair is exactly the kind of thing this + // function exists to reject rather than discover later. + if(_ls_to_orig.size() != 0) + { + if(static_cast(_ls_to_orig.size()) != substations_.nb_bus()) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: _ls_to_orig has " << _ls_to_orig.size() + << " entries while the grid has " << substations_.nb_bus() + << " buses (it is indexed by lightsim bus id)."; + throw std::runtime_error(exc_.str()); + } + check_ls_to_orig_values(_ls_to_orig); + } + if(_orig_to_ls.size() != 0) + { + const int nb_bus_ls = static_cast(substations_.nb_bus()); + for(Eigen::Index i = 0; i < _orig_to_ls.size(); ++i) + { + const int ls_id = _orig_to_ls(i); + if(ls_id == GenericContainer::_deactivated_bus_id) continue; + if((ls_id < 0) || (ls_id >= nb_bus_ls)) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: _orig_to_ls[" << i << "] = " << ls_id + << " is out of range: it must be -1 or a lightsim bus id in [0, " + << nb_bus_ls << ")."; + throw std::out_of_range(exc_.str()); + } + } + } + if(_bus_fusion_rep.size() != 0) + { + const int nb_bus_ls = static_cast(substations_.nb_bus()); + if(static_cast(_bus_fusion_rep.size()) != substations_.nb_bus()) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: _bus_fusion_rep has " << _bus_fusion_rep.size() + << " entries while the grid has " << substations_.nb_bus() << " buses."; + throw std::runtime_error(exc_.str()); + } + for(Eigen::Index i = 0; i < _bus_fusion_rep.size(); ++i) + { + const int rep = _bus_fusion_rep(i); + if((rep < 0) || (rep >= nb_bus_ls)) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: _bus_fusion_rep[" << i << "] = " << rep + << " is out of range [0, " << nb_bus_ls << "): every bus must be merged into " + << "an existing bus (identity for a bus involved in no fusion)."; + throw std::out_of_range(exc_.str()); + } + } + } } void LSGrid::save_binary(const std::string & path, bool atomic) const { @@ -390,31 +467,83 @@ void LSGrid::set_ls_to_orig(const Eigen::Ref & ls_to_orig){ if(static_cast(ls_to_orig.size()) != substations_.nb_bus()) throw std::runtime_error("Impossible to set the converter ls_to_orig: the provided vector has not the same size as the number of bus on the grid."); + check_ls_to_orig_values(ls_to_orig); set_ls_to_orig_internal(ls_to_orig); } +// Every entry of _ls_to_orig is either -1 ("this lightsim bus has no counterpart in +// the original grid") or an original-grid bus id, which set_ls_to_orig_internal uses +// *as an index* into the _orig_to_ls it allocates. That allocation is sized from +// `lpNorm()`, ie the max ABSOLUTE value, so a negative entry other than -1 +// (say -5) sizes the vector from |−5| and then writes at index −5: an out-of-bounds +// heap write. A huge positive entry is the other end of the same problem (`size + 1` +// overflows int for INT_MAX, and even short of that it asks for a multi-gigabyte +// allocation for a handful of buses). Both are rejected here, before any allocation. +// This runs on the python property setter AND on set_state(), ie on every pickle and +// every binary file. +void LSGrid::check_ls_to_orig_values(const Eigen::Ref & ls_to_orig) +{ + // an original grid can never have more buses than the biggest vector we could + // possibly want to allocate for it; keep the bound generous but finite. + constexpr int max_orig_bus_id = 100000000; // 100M buses, ~400 MB for _orig_to_ls + for(Eigen::Index i = 0; i < ls_to_orig.size(); ++i){ + const int el = ls_to_orig(i); + if(el == GenericContainer::_deactivated_bus_id) continue; // -1: no counterpart, legal + if(el < 0){ + std::ostringstream exc_; + exc_ << "LSGrid::set_ls_to_orig: ls_to_orig[" << i << "] = " << el + << " is negative. Entries must be either -1 (this bus has no counterpart in the " + << "original grid) or a valid original-grid bus id >= 0 (it is used as an index " + << "into the reverse mapping)."; + throw std::out_of_range(exc_.str()); + } + if(el > max_orig_bus_id){ + std::ostringstream exc_; + exc_ << "LSGrid::set_ls_to_orig: ls_to_orig[" << i << "] = " << el + << " exceeds the maximum supported original-grid bus id (" << max_orig_bus_id + << "). The reverse mapping is sized from the largest id, so this would ask for " + << "an unreasonable allocation."; + throw std::out_of_range(exc_.str()); + } + } +} + void LSGrid::set_orig_to_ls(const Eigen::Ref & orig_to_ls){ if(orig_to_ls.size() == 0){ _ls_to_orig = IntVect(); _orig_to_ls = IntVect(); return; } - _orig_to_ls = orig_to_ls; size_t nb_bus_ls = 0; for(const auto el : orig_to_ls){ if (el != -1) nb_bus_ls += 1; } - if(nb_bus_ls != substations_.nb_bus()) + if(nb_bus_ls != substations_.nb_bus()) throw std::runtime_error("Impossible to set the converter orig_to_ls: the number of 'non -1' component in the provided vector does not match the number of buses on the grid."); - _ls_to_orig = IntVect::Constant(nb_bus_ls, -1); - size_t ls2or_ind = 0; - for(size_t or2ls_ind = 0; or2ls_ind < nb_bus_ls; ++or2ls_ind){ - const auto my_ind = _orig_to_ls[or2ls_ind]; - if(my_ind >= 0){ - _ls_to_orig[ls2or_ind] = my_ind; - ls2or_ind++; + // orig_to_ls[orig_bus_id] is a LIGHTSIM bus id, used below as an index into + // _ls_to_orig (which has one slot per lightsim bus): validate the range before + // writing anything, an unchecked entry here is an out-of-bounds heap write. + for(Eigen::Index i = 0; i < orig_to_ls.size(); ++i){ + const int ls_id = orig_to_ls(i); + if(ls_id == GenericContainer::_deactivated_bus_id) continue; // -1: this original bus has no lightsim bus + if((ls_id < 0) || (static_cast(ls_id) >= nb_bus_ls)){ + std::ostringstream exc_; + exc_ << "LSGrid::set_orig_to_ls: orig_to_ls[" << i << "] = " << ls_id + << " is out of range: entries must be either -1 (this original bus has no " + << "counterpart in lightsim2grid) or a lightsim bus id in [0, " << nb_bus_ls << ")."; + throw std::out_of_range(exc_.str()); } } + // _ls_to_orig is the INVERSE of _orig_to_ls: _ls_to_orig[ls_id] == orig_id iff + // _orig_to_ls[orig_id] == ls_id. Walk the whole input (not just its first + // nb_bus_ls entries -- the non -1 ones are not necessarily at the front) and + // index the result by the lightsim id, so the two arrays really are inverses. + _orig_to_ls = orig_to_ls; + _ls_to_orig = IntVect::Constant(nb_bus_ls, -1); + for(Eigen::Index orig_id = 0; orig_id < _orig_to_ls.size(); ++orig_id){ + const int ls_id = _orig_to_ls(orig_id); + if(ls_id >= 0) _ls_to_orig[ls_id] = static_cast(orig_id); + } } void LSGrid::set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept{ @@ -1799,6 +1928,29 @@ void LSGrid::update_storages_p(const Eigen::Ref > & has_changed, const Eigen::Ref > & new_values) { + // Both arrays come straight from python and are indexed BY POSITION IN THE + // TOPOLOGY VECTOR: each container does `has_changed(pos_topo_vect_(el_id))` / + // `new_values(pos_topo_vect_(el_id))` with an unchecked Eigen operator(). The + // positions themselves are validated (check_grid() proves they form a + // permutation of [0, dim_topo)), but nothing checked that the caller's arrays + // are dim_topo long -- a shorter one reads past its end. dim_topo is exactly + // the number of topology-participating element sides, so compute it and demand + // both arrays match it. + const Eigen::Index dim_topo = + static_cast(loads_.nb()) + + static_cast(generators_.nb()) + + static_cast(storages_.nb()) + + 2 * static_cast(powerlines_.nb()) + + 2 * static_cast(trafos_.nb()); + if((has_changed.rows() != dim_topo) || (new_values.rows() != dim_topo)){ + std::ostringstream exc_; + exc_ << "LSGrid::update_topo: 'has_changed' (size " << has_changed.rows() + << ") and 'new_values' (size " << new_values.rows() << ") must both have the size of " + << "the topology vector (" << dim_topo << " = nb loads + nb gens + nb storages + " + << "2 * nb lines + 2 * nb trafos). They are indexed by position in the topology " + << "vector, so a shorter array would be read out of bounds."; + throw std::runtime_error(exc_.str()); + } loads_.update_topo(has_changed, new_values, algo_controler_, substations_); generators_.update_topo(has_changed, new_values, algo_controler_, substations_); storages_.update_topo(has_changed, new_values, algo_controler_, substations_); diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 76425c5c..7514143e 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1824,7 +1824,13 @@ class LS2G_API LSGrid final } protected: - void set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept; // set both _ls_to_orig and _orig_to_ls + // set both _ls_to_orig and _orig_to_ls. `noexcept`, and it indexes + // _orig_to_ls with the values of `ls_to_orig`: only ever call it on a vector + // that check_ls_to_orig_values() has already accepted (set_ls_to_orig does). + void set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept; + // throws std::out_of_range unless every entry is -1 or a sane, non-negative + // original-grid bus id -- see the definition in LSGrid.cpp for why. + static void check_ls_to_orig_values(const Eigen::Ref & ls_to_orig); // init the Ybus matrix (its size, it is filled up elsewhere) and also the // converter from "my bus id" to the "solver bus id" (id_me_to_solver and id_solver_to_me) diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 611e6ca6..77efdd1c 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -9,7 +9,9 @@ #include "SubstationContainer.hpp" #include "BinaryArchive.hpp" +#include #include +#include #include namespace ls2g { @@ -34,30 +36,158 @@ SubstationContainer::StateRes SubstationContainer::get_state() const void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) { - n_sub_ = std::get<0>(my_state); - nmax_busbar_per_sub_ = std::get<1>(my_state); - n_bus_max_ = n_sub_ * nmax_busbar_per_sub_; - - // the generators themelves + // NB every field below comes straight from a pickle or a binary file, ie from + // outside the C++ core. This container is the *root* of the grid's index space: + // `nb_bus()` (= bus_vn_kv_.size()) is the upper bound LSGrid::check_grid() + // validates every element's bus id against, while `bus_status_` is what + // `is_bus_connected()` / `disconnect_all_buses()` actually index with those same + // ids, and `n_sub_` is both the substation-id bound and a modulo divisor + // (sub_id_of_bus). Nothing downstream re-derives these from each other, so if + // they are allowed to disagree here, a *validated* bus id still reads and writes + // past the end of bus_status_ -- release wheels are built -O3 -DNDEBUG, so + // neither Eigen nor the standard library catches it. Validate them all now, + // before anything is assigned. + const int n_sub = std::get<0>(my_state); + const int nmax_busbar_per_sub = std::get<1>(my_state); std::vector & sub_vn_kv = std::get<2>(my_state); std::vector & bus_status = std::get<3>(my_state); std::vector & bus_vn_kv = std::get<4>(my_state); + const std::vector & sub_names = std::get<5>(my_state); + std::vector & bus_vmin_kv = std::get<6>(my_state); + std::vector & bus_vmax_kv = std::get<7>(my_state); - // check sizes - // TODO dev switches + // a default-constructed container has n_sub_ == nmax_busbar_per_sub_ == -1 and + // no bus at all: that state must still round-trip, so it is the one case where + // negative counts are legal. + const bool is_empty = bus_vn_kv.empty() && bus_status.empty(); + if(!(is_empty && (n_sub <= 0) && (nmax_busbar_per_sub <= 0))) + { + if(n_sub <= 0){ + std::ostringstream exc_; + exc_ << "SubstationContainer::set_state: the number of substations must be strictly " + << "positive on a grid that has buses, got " << n_sub << " (it is also used as a " + << "modulo divisor in sub_id_of_bus, so 0 would be a division by zero)."; + throw std::runtime_error(exc_.str()); + } + if(nmax_busbar_per_sub <= 0){ + std::ostringstream exc_; + exc_ << "SubstationContainer::set_state: the maximum number of busbars per substation " + << "must be strictly positive, got " << nmax_busbar_per_sub << "."; + throw std::runtime_error(exc_.str()); + } + // n_sub_ * nmax_busbar_per_sub_ is stored in an int: reject a product that + // does not fit rather than letting it overflow (UB, and a negative bus count). + if(nmax_busbar_per_sub > std::numeric_limits::max() / n_sub){ + std::ostringstream exc_; + exc_ << "SubstationContainer::set_state: n_sub (" << n_sub << ") * nmax_busbar_per_sub (" + << nmax_busbar_per_sub << ") overflows the maximum number of buses representable."; + throw std::runtime_error(exc_.str()); + } + } + const std::int64_t n_bus_max = static_cast(n_sub) * static_cast(nmax_busbar_per_sub); - // assign data + // bus_vn_kv_ defines nb_bus(), the bound every other index is checked against; + // bus_status_ is indexed with those same ids and MUST match it exactly. + const auto check_len = [](std::size_t actual, std::int64_t expected, const char * name){ + if(static_cast(actual) != expected){ + std::ostringstream exc_; + exc_ << "SubstationContainer::set_state: '" << name << "' has " << actual + << " elements but " << expected << " were expected. The grid state is inconsistent " + << "(this field is indexed with ids validated against another one, so a mismatch " + << "would cause an out-of-bounds access)."; + throw std::runtime_error(exc_.str()); + } + }; + if(!is_empty) check_len(bus_vn_kv.size(), n_bus_max, "bus_vn_kv"); + check_len(bus_status.size(), static_cast(bus_vn_kv.size()), "bus_status"); + // Optional fields: either absent, or fully sized. sub_vn_kv_ and sub_names_ are + // only filled by init_sub() / init_sub_names(), which the usual init_bus() path + // does not call -- so "empty" is the normal state for most grids, NOT a defect. + if(!sub_vn_kv.empty()) check_len(sub_vn_kv.size(), n_sub, "sub_vn_kv"); + if(!sub_names.empty()) check_len(sub_names.size(), n_sub, "sub_names"); + if(!bus_vmin_kv.empty()) check_len(bus_vmin_kv.size(), static_cast(bus_vn_kv.size()), "bus_vmin_kv"); + if(!bus_vmax_kv.empty()) check_len(bus_vmax_kv.size(), static_cast(bus_vn_kv.size()), "bus_vmax_kv"); + + // assign data (nothing above has modified `this`, so a rejected state leaves + // the container untouched) + n_sub_ = n_sub; + nmax_busbar_per_sub_ = nmax_busbar_per_sub; + n_bus_max_ = static_cast(n_bus_max); sub_vn_kv_ = RealVect::Map(sub_vn_kv.data(), sub_vn_kv.size()); bus_status_ = bus_status; bus_vn_kv_ = RealVect::Map(bus_vn_kv.data(), bus_vn_kv.size()); - sub_names_ = std::get<5>(my_state); - - std::vector & bus_vmin_kv = std::get<6>(my_state); - std::vector & bus_vmax_kv = std::get<7>(my_state); + sub_names_ = sub_names; bus_vmin_kv_ = bus_vmin_kv.empty() ? RealVect() : RealVect::Map(bus_vmin_kv.data(), bus_vmin_kv.size()); bus_vmax_kv_ = bus_vmax_kv.empty() ? RealVect() : RealVect::Map(bus_vmax_kv.data(), bus_vmax_kv.size()); } +void SubstationContainer::check_valid() const +{ + // a default-constructed / never-initialized container is consistent by + // definition: it has no bus at all, so nothing can index into it. + if((bus_vn_kv_.size() == 0) && bus_status_.empty()) return; + + if(n_sub_ <= 0){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the grid declares " << n_sub_ << " substation(s) but has " + << bus_vn_kv_.size() << " buses. The number of substations must be strictly positive " + << "(it is also used as a modulo divisor in sub_id_of_bus)."; + throw std::runtime_error(exc_.str()); + } + if(nmax_busbar_per_sub_ <= 0){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the grid declares " << nmax_busbar_per_sub_ + << " busbar(s) per substation; it must be strictly positive."; + throw std::runtime_error(exc_.str()); + } + // bus_status_ is indexed with the very bus ids check_grid() validates against + // nb_bus() (= bus_vn_kv_.size()), and disconnect_all_buses() writes nb_bus() + // entries into it: the two must have exactly the same length. + if(bus_status_.size() != static_cast(bus_vn_kv_.size())){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the bus status vector has " << bus_status_.size() + << " entries while the grid has " << bus_vn_kv_.size() << " buses. Both are indexed " + << "by bus id and must have the same length."; + throw std::runtime_error(exc_.str()); + } + if(static_cast(bus_vn_kv_.size()) != + static_cast(n_sub_) * static_cast(nmax_busbar_per_sub_)){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the grid has " << bus_vn_kv_.size() << " buses but declares " + << n_sub_ << " substations of at most " << nmax_busbar_per_sub_ << " busbars (" + << static_cast(n_sub_) * static_cast(nmax_busbar_per_sub_) + << " buses). Bus ids are laid out as `sub_id + (busbar - 1) * n_sub`, so both must match."; + throw std::runtime_error(exc_.str()); + } + // optional fields: either absent, or one entry per substation / per bus. + // sub_vn_kv_ / sub_names_ are only filled by init_sub() / init_sub_names(), + // which the usual init_bus() path does not call: empty is the normal state. + if((sub_vn_kv_.size() != 0) && (sub_vn_kv_.size() != n_sub_)){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the substation nominal-voltage vector has " << sub_vn_kv_.size() + << " entries for " << n_sub_ << " substations (it must be either empty or complete)."; + throw std::runtime_error(exc_.str()); + } + if(!sub_names_.empty() && (sub_names_.size() != static_cast(n_sub_))){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the substation names vector has " << sub_names_.size() + << " entries for " << n_sub_ << " substations (it must be either empty or complete)."; + throw std::runtime_error(exc_.str()); + } + if((bus_vmin_kv_.size() != 0) && (bus_vmin_kv_.size() != bus_vn_kv_.size())){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the per-bus min voltage vector has " << bus_vmin_kv_.size() + << " entries for " << bus_vn_kv_.size() << " buses (it must be either empty or complete)."; + throw std::runtime_error(exc_.str()); + } + if((bus_vmax_kv_.size() != 0) && (bus_vmax_kv_.size() != bus_vn_kv_.size())){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: the per-bus max voltage vector has " << bus_vmax_kv_.size() + << " entries for " << bus_vn_kv_.size() << " buses (it must be either empty or complete)."; + throw std::runtime_error(exc_.str()); + } +} + void SubstationContainer::save_binary(const std::string & path, bool atomic) const { ls2g::save_binary_generic(*this, path, VERSION_MAJOR, VERSION_MEDIUM, VERSION_MINOR, atomic); } diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index b50d88fe..9b014091 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -71,6 +71,16 @@ class LS2G_API SubstationContainer final : public IteratorAdder= r_data.nb()) return; - name = r_data.sub_names_[my_id]; + // sub_names_ is OPTIONAL (empty unless set_substation_names() was called, which + // eg the pandapower / matpower / powermodels loaders never do) and bus_vn_kv_ is + // empty on a substation container that was declared but never given its buses: + // both must be bounds-checked here, `my_id < nb()` only bounds them against + // n_sub_. Same reason as everywhere else: -O3 -DNDEBUG release wheels have no + // std::vector / Eigen bounds check to fall back on. + if(my_id < static_cast(r_data.sub_names_.size())) name = r_data.sub_names_[my_id]; nb_max_busbars = r_data.nmax_busbar_per_sub_; - vn_kv = r_data.bus_vn_kv_[my_id]; + if(my_id < static_cast(r_data.bus_vn_kv_.size())) vn_kv = r_data.bus_vn_kv_[my_id]; } diff --git a/src/core/element_container/GeneratorContainer.cpp b/src/core/element_container/GeneratorContainer.cpp index 62e6768b..6e97f349 100644 --- a/src/core/element_container/GeneratorContainer.cpp +++ b/src/core/element_container/GeneratorContainer.cpp @@ -597,6 +597,16 @@ void GeneratorContainer::update_slack_weights( DualAlgoControl & solver_control) { const int nb_gen = nb(); + // `could_be_slack` comes from python and is indexed by generator id below with + // an unchecked Eigen operator(): a shorter array would be read out of bounds + // (release wheels are -O3 -DNDEBUG, so Eigen's own assert is gone). + if(could_be_slack.rows() != nb_gen){ + std::ostringstream exc_; + exc_ << "GeneratorContainer::update_slack_weights: 'could_be_slack' has " + << could_be_slack.rows() << " elements but this grid has " << nb_gen + << " generators. It is indexed by generator id, so both must match."; + throw std::runtime_error(exc_.str()); + } std::vector gen_slack_id; for(int gen_id = 0; gen_id < nb_gen; ++gen_id) { diff --git a/src/core/powerflow_algorithm/NRAlgo.hpp b/src/core/powerflow_algorithm/NRAlgo.hpp index 929eb28c..9b0e5aa3 100644 --- a/src/core/powerflow_algorithm/NRAlgo.hpp +++ b/src/core/powerflow_algorithm/NRAlgo.hpp @@ -227,6 +227,37 @@ class NRAlgo final : public BaseAlgo } return v; } + // An AlgoConfig stores the two policy enums as plain ints, and it can come from + // a pickle or a binary file: an out-of-range value would be cast to the scoped + // enum and then silently fall into a `default:` branch, quietly changing the + // algorithm's behaviour AND round-tripping back out through get_config(). Reject + // it instead. (create_scaling_policy() already throws on an unknown scaling type; + // this makes the refactor side symmetric and gives a better message for both.) + static RefactorPolicyType _checked_refactor_policy(int v) { + if ((v < static_cast(RefactorPolicyType::AlwaysRefactor)) || + (v > static_cast(RefactorPolicyType::Chord))) { + std::ostringstream exc_; + exc_ << "NRAlgo: unknown refactorization policy " << v << " (expected " + << static_cast(RefactorPolicyType::AlwaysRefactor) << " = AlwaysRefactor, " + << static_cast(RefactorPolicyType::EveryN) << " = EveryN or " + << static_cast(RefactorPolicyType::Chord) << " = Chord)."; + throw std::runtime_error(exc_.str()); + } + return static_cast(v); + } + static ScalingPolicyType _checked_scaling_policy(int v) { + if ((v < static_cast(ScalingPolicyType::NoScaling)) || + (v > static_cast(ScalingPolicyType::Iwamoto))) { + std::ostringstream exc_; + exc_ << "NRAlgo: unknown step-scaling policy " << v << " (expected " + << static_cast(ScalingPolicyType::NoScaling) << " = NoScaling, " + << static_cast(ScalingPolicyType::MaxVoltageChange) << " = MaxVoltageChange, " + << static_cast(ScalingPolicyType::LineSearch) << " = LineSearch or " + << static_cast(ScalingPolicyType::Iwamoto) << " = Iwamoto)."; + throw std::runtime_error(exc_.str()); + } + return static_cast(v); + } // MaxVoltageChange params real_type get_max_dVa() const { return max_dVa_; } @@ -276,6 +307,11 @@ class NRAlgo final : public BaseAlgo void set_config(const AlgoConfig& cfg) override { if (cfg.int_params.size() < 4) throw std::runtime_error("NRAlgo::set_config: int_params must have at least 4 elements"); if (cfg.real_params.size() < 6) throw std::runtime_error("NRAlgo::set_config: real_params must have at least 6 elements"); + // Validate the two policy enums BEFORE touching any member: applying a + // config must be all-or-nothing, otherwise a config rejected halfway + // through leaves the algorithm with a mix of new and old parameters. + const RefactorPolicyType new_refactor_policy = _checked_refactor_policy(cfg.int_params[1]); + const ScalingPolicyType new_scaling_policy = _checked_scaling_policy(cfg.int_params[0]); // NB an AlgoConfig is part of the serialized grid state, so these values // can come straight from a pickle or a binary file: validate them exactly // like the setters do (see the note on _checked_refactor_every_n). @@ -287,8 +323,8 @@ class NRAlgo final : public BaseAlgo iw_mu_max_ = _checked_finite(static_cast(cfg.real_params[5]), "iw_mu_max"); ls_max_iter_ = _checked_non_negative(cfg.int_params[2], "ls_max_iter"); refactor_every_n_= _checked_refactor_every_n(cfg.int_params[3]); - refactor_policy_ = static_cast(cfg.int_params[1]); - set_scaling_policy(static_cast(cfg.int_params[0])); + refactor_policy_ = new_refactor_policy; + set_scaling_policy(new_scaling_policy); } // ----- debug --------------------------------------------------------------- diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index 961314b5..e1f3775c 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -119,6 +119,49 @@ std::vector& gen_regulated_bus(LSGrid::StateRes & st) { return std::get<8>(std::get(st)); } +// SUBSTATION_ID -> SubstationContainer::StateRes = tuple<[0] n_sub, +// [1] nmax_busbar_per_sub, [2] sub_vn_kv, [3] bus_status, [4] bus_vn_kv, +// [5] sub_names, [6] bus_vmin_kv, [7] bus_vmax_kv> +int& sub_n_sub(LSGrid::StateRes & st) +{ + return std::get<0>(std::get(st)); +} +int& sub_nmax_busbar(LSGrid::StateRes & st) +{ + return std::get<1>(std::get(st)); +} +std::vector& sub_sub_vn_kv(LSGrid::StateRes & st) +{ + return std::get<2>(std::get(st)); +} +std::vector& sub_bus_status(LSGrid::StateRes & st) +{ + return std::get<3>(std::get(st)); +} +std::vector& sub_bus_vn_kv(LSGrid::StateRes & st) +{ + return std::get<4>(std::get(st)); +} +std::vector& sub_names(LSGrid::StateRes & st) +{ + return std::get<5>(std::get(st)); +} +std::vector& ls_to_orig(LSGrid::StateRes & st) +{ + return std::get(st); +} +std::vector& init_kwargs_keys(LSGrid::StateRes & st) +{ + return std::get(st); +} +std::vector& init_kwargs_values(LSGrid::StateRes & st) +{ + return std::get(st); +} +std::vector& bus_fusion_rep(LSGrid::StateRes & st) +{ + return std::get(st); +} // A well-behaved AC solver (returns the input voltages, claims convergence). // Registered under a non-built-in name it stands in for a plugin solver, whose @@ -313,6 +356,245 @@ TEST_CASE("check_grid rejects an out-of-range pos_topo_vect entry", "[check_grid CHECK_THROWS_AS(grid.check_grid(), std::out_of_range); } +// --- the substation container: the ROOT of the grid's index space ------------ +// nb_bus() (= bus_vn_kv_.size()) is the bound every element's bus id is checked +// against, bus_status_ is what those same ids actually index, and n_sub_ is both +// the substation-id bound and a modulo divisor (sub_id_of_bus). Nothing +// re-derives them from one another, so a state in which they disagree turns a +// *validated* bus id into an out-of-bounds access -- disconnect_all_buses() +// writes nb_bus() entries into bus_status_, which is a heap write past the end. +// These must be rejected by set_state()/check_grid(), never reach a powerflow. + +TEST_CASE("check_grid rejects a bus_status shorter than the bus count", "[check_grid][substation]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(sub_bus_status(st).size() == 3); + sub_bus_status(st).resize(1); // 1 status for 3 buses + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects a bus count that disagrees with n_sub * nmax_busbar", "[check_grid][substation]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + sub_nmax_busbar(st) = 1000; // 3 * 1000 buses claimed, 3 actually stored + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects a zero substation count (modulo divisor)", "[check_grid][substation]") +{ + // sub_id_of_bus() does `gridmodel_bus_id % n_sub_`: n_sub_ == 0 is an integer + // division by zero (SIGFPE), the same class of bug as refactor_every_n == 0. + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + sub_n_sub(st) = 0; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects an n_sub * nmax_busbar product that overflows", "[check_grid][substation]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + sub_n_sub(st) = 100000; + sub_nmax_busbar(st) = 100000; // 10^10 does not fit in the int n_bus_max_ + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects a partially-filled substation names vector", "[check_grid][substation]") +{ + LSGrid grid = make_valid_grid(); + grid.set_substation_names({"a", "b", "c"}); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(sub_names(st).size() == 3); + sub_names(st).resize(2); // names are optional, but all-or-nothing + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects a sub_vn_kv that does not match n_sub", "[check_grid][substation]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + sub_sub_vn_kv(st).resize(1); + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("substation info is readable on a grid with no substation names", "[check_grid][substation]") +{ + // sub_names_ is OPTIONAL (the pandapower / matpower / powermodels loaders never + // set it), and SubstationInfo used to read sub_names_[id] unconditionally -- + // an out-of-bounds read of a std::string on any such grid, no poisoned input + // needed at all. + LSGrid grid = make_valid_grid(); + REQUIRE(grid.get_substation_names().empty()); + const auto & substations = grid.get_substations(); + for (int sub_id = 0; sub_id < 3; ++sub_id) { + ls2g::SubstationInfo info(substations, sub_id); + CHECK(info.id == sub_id); + CHECK(info.name.empty()); + CHECK(info.vn_kv == 138.); + } +} + +// --- the bus-id mapping vectors ---------------------------------------------- + +TEST_CASE("a negative (non-sentinel) _ls_to_orig entry is rejected, not indexed", "[check_grid]") +{ + // set_ls_to_orig_internal sizes _orig_to_ls from lpNorm() (the max + // ABSOLUTE value) and then writes at _orig_to_ls[el]: an entry of -5 sizes the + // vector from 5 and writes at index -5 -- an out-of-bounds heap write. + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + ls_to_orig(st) = {-5, 1, 2}; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("an absurdly large _ls_to_orig entry is rejected, not allocated for", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + ls_to_orig(st) = {std::numeric_limits::max(), 1, 2}; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("a valid _ls_to_orig still round-trips", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + ls_to_orig(st) = {2, 0, 1}; + + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + const IntVect & back = restored.get_ls_to_orig(); + REQUIRE(back.size() == 3); + CHECK(back(0) == 2); + CHECK(back(1) == 0); + CHECK(back(2) == 1); +} + +TEST_CASE("_orig_to_ls and _ls_to_orig really are inverses of each other", "[check_grid]") +{ + // orig bus 0 has no lightsim counterpart; orig 1 -> ls 2, orig 2 -> ls 0, + // orig 3 -> ls 1. The inverse must therefore be ls 0 -> 2, ls 1 -> 3, ls 2 -> 1. + LSGrid grid = make_valid_grid(); + IntVect orig_to_ls(4); + orig_to_ls << -1, 2, 0, 1; + REQUIRE_NOTHROW(grid.set_orig_to_ls(orig_to_ls)); + const IntVect & back = grid.get_ls_to_orig(); + REQUIRE(back.size() == 3); + CHECK(back(0) == 2); + CHECK(back(1) == 3); + CHECK(back(2) == 1); +} + +TEST_CASE("an out-of-range _orig_to_ls entry is rejected, not indexed", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + IntVect orig_to_ls(3); + orig_to_ls << 0, 1, 99; // 99 is used to index a 3-slot _ls_to_orig + CHECK_THROWS_AS(grid.set_orig_to_ls(orig_to_ls), std::out_of_range); +} + +TEST_CASE("an out-of-range _bus_fusion_rep entry is rejected", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + bus_fusion_rep(st) = {0, 1, 9999}; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +// --- the serialized _init_kwargs map ----------------------------------------- + +TEST_CASE("_init_kwargs with more keys than values is rejected, not over-read", "[check_grid]") +{ + // the map is serialized as two parallel vectors whose lengths are stored + // independently: set_state used to walk the keys and index the values with the + // same counter, ie construct std::strings from whatever lay past the end. + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + init_kwargs_keys(st) = {"a", "b", "c"}; + init_kwargs_values(st) = {"1"}; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("a consistent _init_kwargs still round-trips", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + init_kwargs_keys(st) = {"sort_index", "buses_for_sub"}; + init_kwargs_values(st) = {"True", "False"}; + + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + const auto & kwargs = restored.get_init_kwargs(); + REQUIRE(kwargs.size() == 2); + CHECK(kwargs.at("sort_index") == "True"); + CHECK(kwargs.at("buses_for_sub") == "False"); +} + +// --- caller-supplied arrays indexed by position / by element id -------------- + +TEST_CASE("update_topo rejects arrays that are not dim_topo long", "[check_grid]") +{ + // each container does has_changed(pos_topo_vect_(el_id)) with an unchecked + // Eigen operator(): the positions are validated, the caller's array length was + // not, so a short array was simply read past its end. + LSGrid grid = make_valid_grid(); + IntVect load_pos(1); load_pos << 0; + IntVect gen_pos(1); gen_pos << 1; + IntVect line_pos1(2); line_pos1 << 2, 3; + IntVect line_pos2(2); line_pos2 << 4, 5; + grid.set_load_pos_topo_vect(load_pos); + grid.set_gen_pos_topo_vect(gen_pos); + grid.set_line_pos1_topo_vect(line_pos1); + grid.set_line_pos2_topo_vect(line_pos2); + REQUIRE_NOTHROW(grid.check_grid()); // 6 topo slots, a valid permutation + + Eigen::Array too_short_flags(2); + too_short_flags << false, false; + Eigen::Array too_short_vals(2); + too_short_vals << 1, 1; + CHECK_THROWS_AS(grid.update_topo(too_short_flags, too_short_vals), std::runtime_error); + + // the correctly-sized, no-op call must still work + Eigen::Array flags(6); + flags << false, false, false, false, false, false; + Eigen::Array vals(6); + vals << 1, 1, 1, 1, 1, 1; + CHECK_NOTHROW(grid.update_topo(flags, vals)); +} + +TEST_CASE("update_slack_weights rejects an array that is not nb_gen long", "[check_grid]") +{ + LSGrid grid = make_valid_grid(); // exactly one generator + Eigen::Array too_short(0); + CHECK_THROWS_AS(grid.update_slack_weights(too_short), std::runtime_error); + + Eigen::Array ok(1); + ok << true; + CHECK_NOTHROW(grid.update_slack_weights(ok)); +} + // --- solver policy parameters carried by the serialized state ---------------- // AlgoConfig is part of StateRes, so these values arrive from a pickle / binary // file. refactor_every_n == 0 used to reach `iter % refactor_every_n` and kill @@ -368,6 +650,30 @@ TEST_CASE("a valid algo config still round-trips", "[check_grid][algo_config]") CHECK(restored.get_ac_algo_config().int_params[3] == grid.get_ac_algo_config().int_params[3]); } +TEST_CASE("a state with an unknown refactor policy is rejected", "[check_grid][algo_config]") +{ + // int_params[1] is a RefactorPolicyType stored as a plain int. An out-of-range + // value used to be cast to the scoped enum, fall into should_refactor_policy's + // `default:` branch and round-trip back out of get_config() unchanged. + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(ac_algo_int_params(st).size() >= 4); + ac_algo_int_params(st)[1] = 42; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("a state with an unknown scaling policy is rejected", "[check_grid][algo_config]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + ac_algo_int_params(st)[0] = 42; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + TEST_CASE("a solver returning a wrong-sized voltage vector is rejected, not indexed", "[check_grid][plugin]") { ls2g::AlgorithmRegistry::instance().register_solver( From acf2cf990e8a70ee03aad08ff67d9053fac757df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 18:15:47 +0000 Subject: [PATCH 162/166] Enforce the per-substation nominal voltage invariant, and fix init_sub Follow-up on the substation container, prompted by the question "shouldn't sub_vn_kv be set whenever bus_vn_kv is?". It is not, and cannot be made mandatory: sub_vn_kv_ is only ever filled by init_sub() or the two-argument constructor, neither of which is called anywhere. The live path is the default constructor followed by init_bus(), which fills bus_vn_kv_ only -- so sub_vn_kv is empty on every grid produced by init_from_pandapower / _pypowsybl / _matpower / _powermodels, on every already-saved binary file, and on this repo's own format-4 compatibility fixture. Requiring it would reject all of them. The field stays optional and now says so where it is declared. What that question did surface: the invariant it appeals to -- every busbar of a substation carries that substation's nominal voltage -- was never actually enforced. A gridmodel bus id is `sub_id + (local_bus_id - 1) * n_sub` and LocalBusId runs 1..nmax_busbar_per_sub, but init_bus's check looped local ids [1, nmax_busbar_per_sub): it opened by comparing the reference bus with itself and stopped one busbar short. At the usual nmax_busbar_per_sub == 2 it compared bus sub_id with bus sub_id and checked nothing whatsoever. Fixed to run local ids 2..nmax, and lifted into check_valid() so it is re-checked on load -- set_state bypasses init_bus entirely, so a pickle or a binary file could always carry a grid violating it. When sub_vn_kv IS present it is now checked against bus_vn_kv too. Separately, init_sub() itself wrote n_sub values into sub_vn_kv_ with an unchecked operator[] without ever sizing it -- an out-of-bounds heap write on the only construction path that exists. Latent (it is uncalled and not bound to python) but wrong for whoever wires it up next, so it now sizes its destinations. reset_bus_status() likewise iterates the vector it writes rather than the separately-stored n_bus_max_. Verified: the violating grids are now rejected, and case14 / case118 / case300 via pandapower, ieee14 via pypowsybl and the shipped format-4 fixture all still pass check_grid(). C++ suite 108 cases green, [check_grid] clean under valgrind (0 errors), and the python run shows the same 123 pre-existing environment failures as the unmodified tree -- no new ones. Signed-off-by: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015CfJVFAd2ifp4qyZxp5Ec5 --- CHANGELOG.rst | 19 +++++++ src/core/SubstationContainer.cpp | 22 ++++++++ src/core/SubstationContainer.hpp | 81 ++++++++++++++++++++++------ src/tests/test_check_grid.cpp | 93 ++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 49de2ef0..90fbf0e2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -164,6 +164,25 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding - [FIXED] ``check_grid()`` now also validates the substation container's own internal consistency and the bus-id mapping vectors (``_ls_to_orig`` / ``_orig_to_ls`` / ``_bus_fusion_rep``), which it previously ignored entirely. +- [FIXED] the "every bus of a substation must have the same nominal voltage" check in + ``init_bus`` never fired: a gridmodel bus id is ``sub_id + (local_bus_id - 1) * n_sub`` + and ``LocalBusId`` runs ``1..nmax_busbar_per_sub``, but the loop ran local ids + ``[1, nmax_busbar_per_sub)`` -- so it started by comparing the reference bus with + *itself* and stopped one busbar short. With the usual ``nmax_busbar_per_sub == 2`` it + checked nothing at all, and the invariant was silently unenforced on every path. It is + now checked over local ids ``2..nmax_busbar_per_sub``, and re-checked by + ``check_grid()`` -- ``set_state`` bypasses ``init_bus``, so a pickle / binary file could + always carry a grid violating it. +- [FIXED] ``SubstationContainer::init_sub`` wrote ``n_sub`` values into ``sub_vn_kv_`` + (and into ``bus_vn_kv_``) with an unchecked ``operator[]`` without sizing them first. + Only the two-argument constructor sizes ``sub_vn_kv_``, and nothing uses it: the live + path is the default constructor followed by ``init_bus()``, which leaves ``sub_vn_kv_`` + empty. Calling ``init_sub()`` on such a container was an out-of-bounds heap write. It is + currently uncalled and unbound, so this was latent rather than reachable, but it sized + its destinations wrongly for whoever wired it up next. ``sub_vn_kv`` is also validated + against ``bus_vn_kv`` by ``check_grid()`` when it is present (it stays optional: it is + empty on every grid produced by any loader today, and on every already-saved binary + file including this repo's own format-4 compatibility fixture). - [FIXED] an ``AlgoConfig`` (part of the serialized grid state) carrying an out-of-range ``RefactorPolicyType`` / ``ScalingPolicyType`` is now rejected instead of being cast to the enum, silently falling into a ``default:`` branch and round-tripping back out of diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 77efdd1c..1f5a6d82 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -186,6 +186,28 @@ void SubstationContainer::check_valid() const << " entries for " << bus_vn_kv_.size() << " buses (it must be either empty or complete)."; throw std::runtime_error(exc_.str()); } + + // All busbars of a substation share its nominal voltage. init_bus() is supposed + // to enforce this at build time, but set_state() bypasses init_bus() entirely, + // so a pickle / binary file could always carry a grid violating it. Re-check + // here, which is the point of check_grid(). + check_bus_vn_kv_uniform_per_sub(); + + // sub_vn_kv_ is optional (see the note on StateRes), but when it IS present it + // is by definition the common nominal voltage of each substation's busbars, + // i.e. bus_vn_kv_ restricted to the first n_sub_ bus ids (busbar 1 of each + // substation). A stored value disagreeing with that is an inconsistent state. + if(sub_vn_kv_.size() != 0){ + for(int sub_id = 0; sub_id < n_sub_; ++sub_id){ + if(std::abs(sub_vn_kv_(sub_id) - bus_vn_kv_(sub_id)) > BaseConstants::_tol_equal_float){ + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: substation " << sub_id << " declares a nominal voltage of " + << sub_vn_kv_(sub_id) << " kV but its buses carry " << bus_vn_kv_(sub_id) + << " kV. Both must agree."; + throw std::runtime_error(exc_.str()); + } + } + } } void SubstationContainer::save_binary(const std::string & path, bool atomic) const { diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 9b014091..e44ebbfc 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -15,7 +15,9 @@ #include #include // for int32 #include -#include // for PI +#include // for PI, std::abs +#include +#include #include "Utils.hpp" #include "BaseConstants.hpp" @@ -58,7 +60,16 @@ class LS2G_API SubstationContainer final : public IteratorAdder, // sub_vn_kv_; + // sub_vn_kv_: OPTIONAL, and empty in practice on every grid produced + // today. It is only ever filled by init_sub() / the two-argument + // constructor, neither of which is called anywhere (the live path is + // the default constructor + init_bus(), which fills bus_vn_kv_ only), + // and nothing reads it back. It is therefore NOT valid to require it + // whenever bus_vn_kv_ is set: doing so rejects every grid built by + // init_from_pandapower / _pypowsybl / _matpower / _powermodels, and + // every already-saved binary file -- including this repo's own + // format-4 compatibility fixture (tests/binary_format_fixture). + std::vector, // sub_vn_kv_ (optional, see above) std::vector, // bus_status_; std::vector, // bus_vn_kv_; std::vector, // sub_names_ @@ -102,13 +113,31 @@ class LS2G_API SubstationContainer final : public IteratorAdder & sub_vn_kv){ + if((n_sub_ <= 0) || (nmax_busbar_per_sub_ <= 0)){ + throw std::runtime_error("SubstationContainer::init_sub: the substations must be declared " + "first (see init_bus / the two-argument constructor)."); + } if(sub_vn_kv.size() != n_sub_){ throw std::range_error("SubstationContainer::init_sub: sub_vn_kv should have the size of the number of substations on the grid."); } + // Both destinations are written with an unchecked operator[] below, and + // neither is guaranteed to be sized yet: only the two-argument + // constructor sizes sub_vn_kv_, and the live construction path is the + // default constructor followed by init_bus(), which leaves sub_vn_kv_ + // EMPTY. Writing n_sub_ doubles into it would be an out-of-bounds heap + // write. Size both here instead of assuming. + const Eigen::Index nb_bus_total = + static_cast(n_sub_) * static_cast(nmax_busbar_per_sub_); + if(sub_vn_kv_.size() != n_sub_) sub_vn_kv_ = RealVect::Zero(n_sub_); + if(bus_vn_kv_.size() != nb_bus_total) bus_vn_kv_ = RealVect::Zero(nb_bus_total); for(int i = 0; i < n_sub_; ++i){ // store substation vn kv sub_vn_kv_[i] = sub_vn_kv[i]; @@ -193,20 +222,40 @@ class LS2G_API SubstationContainer final : public IteratorAdder(nb_bus(), true); // by default everything is connected // check that a "substation" always has the same vn_kv for all of its buses - for(int sub_id=0; sub_id < n_sub; sub_id++){ - real_type ref_vn_kv = bus_vn_kv(sub_id); - for(int bus_id=1; bus_id < nmax_busbar_per_sub; bus_id++) + check_bus_vn_kv_uniform_per_sub(); + } + + /** + * Every busbar of a substation must carry the same nominal voltage. + * + * A gridmodel bus id is laid out as `sub_id + (local_bus_id - 1) * n_sub_` + * (see local_to_gridmodel), so the buses of one substation are strided by + * **n_sub_**, not by nmax_busbar_per_sub_, and LocalBusId is 1-BASED: + * local id 1 is the first busbar (gridmodel id == sub_id), local id + * nmax_busbar_per_sub_ is the last one. + * + * That 1-based convention is what made the original loop here a no-op: it + * ran local ids `[1, nmax_busbar_per_sub_)`, so it started by comparing the + * reference bus with itself and stopped one busbar short -- with the usual + * nmax_busbar_per_sub_ == 2 it compared bus `sub_id` to bus `sub_id` and + * checked nothing at all. Hence the range below: local ids 2 .. nmax + * inclusive, i.e. every busbar *after* the reference one. + */ + void check_bus_vn_kv_uniform_per_sub() const + { + for(int sub_id = 0; sub_id < n_sub_; sub_id++){ + const real_type ref_vn_kv = bus_vn_kv_(sub_id); + for(int local_bus_id = 2; local_bus_id <= nmax_busbar_per_sub_; local_bus_id++) { - real_type this_bus_vn_kv = bus_vn_kv(local_to_gridmodel(sub_id, LocalBusId(bus_id)).cast_int()); - if(abs(this_bus_vn_kv - ref_vn_kv) > BaseConstants::_tol_equal_float){ - const std::string msg = R"mydelimiter( - Each bus of each substation must have the same nominal voltage. - Check substation )mydelimiter" + - std::to_string(sub_id) + - " and buses 0 and " + - std::to_string(bus_id)+"."; - - throw std::runtime_error(msg); + const int bus_id = local_to_gridmodel(sub_id, LocalBusId(local_bus_id)).cast_int(); + const real_type this_bus_vn_kv = bus_vn_kv_(bus_id); + if(std::abs(this_bus_vn_kv - ref_vn_kv) > BaseConstants::_tol_equal_float){ + std::ostringstream exc_; + exc_ << "SubstationContainer: each bus of a substation must have the same nominal " + << "voltage. Substation " << sub_id << " has " << ref_vn_kv + << " kV on its busbar 1 (bus id " << sub_id << ") but " << this_bus_vn_kv + << " kV on its busbar " << local_bus_id << " (bus id " << bus_id << ")."; + throw std::runtime_error(exc_.str()); } } } diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index e1f3775c..4a3417c8 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -431,6 +431,99 @@ TEST_CASE("check_grid rejects a sub_vn_kv that does not match n_sub", "[check_gr CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); } +TEST_CASE("busbars of one substation must share their nominal voltage", "[check_grid][substation]") +{ + // A gridmodel bus id is `sub_id + (local_bus_id - 1) * n_sub`, and LocalBusId + // runs 1..nmax_busbar_per_sub. The original check looped local ids + // [1, nmax_busbar_per_sub), so it compared the reference bus with ITSELF and + // stopped one busbar short -- with the usual nmax == 2 it checked nothing at + // all, and the invariant was silently unenforced on every path. + ls2g::SubstationContainer substations; + // 2 substations x 3 busbars: sub 0 owns bus ids 0, 2, 4 ; sub 1 owns 1, 3, 5. + RealVect bad(6); + bad << 100., 200., 100., 200., 999., 200.; // sub 0's LAST busbar disagrees + CHECK_THROWS_AS(substations.init_bus(2, 3, bad), std::runtime_error); + + // the nmax == 2 case, which the old loop could not catch at all + ls2g::SubstationContainer two_busbars; + RealVect bad2(4); + bad2 << 100., 200., 999., 200.; // sub 0: busbar 1 = 100, busbar 2 = 999 + CHECK_THROWS_AS(two_busbars.init_bus(2, 2, bad2), std::runtime_error); + + // a uniform grid is still accepted (stride is n_sub, not nmax_busbar_per_sub) + ls2g::SubstationContainer ok; + RealVect good(6); + good << 225., 400., 225., 400., 225., 400.; + CHECK_NOTHROW(ok.init_bus(2, 3, good)); + CHECK_NOTHROW(ok.check_valid()); +} + +TEST_CASE("check_grid rejects non-uniform busbar voltages restored from a state", "[check_grid][substation]") +{ + // set_state() bypasses init_bus() entirely, so this invariant has to be + // re-checked on load -- a crafted pickle / binary file could always carry it. + LSGrid grid = make_valid_grid(); // 3 substations, 1 busbar each + LSGrid::StateRes st = grid.get_state(); + sub_n_sub(st) = 1; + sub_nmax_busbar(st) = 3; // 1 substation x 3 busbars = 3 buses + sub_bus_vn_kv(st) = {138., 138., 400.}; // its third busbar disagrees + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("check_grid rejects a sub_vn_kv that contradicts bus_vn_kv", "[check_grid][substation]") +{ + LSGrid grid = make_valid_grid(); + LSGrid::StateRes st = grid.get_state(); + // sub_vn_kv is optional, but when present it must be the common nominal + // voltage of each substation's busbars (== bus_vn_kv on the first n_sub ids). + sub_sub_vn_kv(st) = {138., 400., 138.}; // substation 1 contradicts its bus + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); + + // the consistent value is accepted + LSGrid::StateRes ok = grid.get_state(); + sub_sub_vn_kv(ok) = {138., 138., 138.}; + LSGrid restored_ok; + CHECK_NOTHROW(restored_ok.set_state(ok)); +} + +TEST_CASE("init_sub is safe on the live construction path", "[check_grid][substation]") +{ + // sub_vn_kv_ is only sized by the two-argument constructor, which nothing uses: + // the live path is the default constructor + init_bus(), which leaves it EMPTY. + // init_sub() wrote n_sub_ doubles into it with an unchecked operator[], ie an + // out-of-bounds heap write. It must size its destinations instead. + ls2g::SubstationContainer substations; + RealVect bus_vn_kv(6); + bus_vn_kv << 90., 90., 90., 90., 90., 90.; + substations.init_bus(3, 2, bus_vn_kv); + REQUIRE(substations.nb_bus() == 6); + + RealVect sub_vn_kv(3); + sub_vn_kv << 225., 400., 63.; + REQUIRE_NOTHROW(substations.init_sub(sub_vn_kv)); + // every busbar of a substation inherits that substation's nominal voltage + // (bus id == sub_id + (busbar - 1) * n_sub) + CHECK(substations.get_bus_vn_kv()(0) == 225.); + CHECK(substations.get_bus_vn_kv()(1) == 400.); + CHECK(substations.get_bus_vn_kv()(2) == 63.); + CHECK(substations.get_bus_vn_kv()(3) == 225.); + CHECK(substations.get_bus_vn_kv()(5) == 63.); + CHECK_NOTHROW(substations.check_valid()); + + // a wrong-sized input is still refused + RealVect too_short(2); + too_short << 225., 400.; + CHECK_THROWS(substations.init_sub(too_short)); + + // and it refuses to run at all before the substations are declared + ls2g::SubstationContainer fresh; + CHECK_THROWS_AS(fresh.init_sub(sub_vn_kv), std::runtime_error); +} + TEST_CASE("substation info is readable on a grid with no substation names", "[check_grid][substation]") { // sub_names_ is OPTIONAL (the pandapower / matpower / powermodels loaders never From cdda703bcb40134faca3082ed3bf9a3eaa45f009 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 18:31:24 +0000 Subject: [PATCH 163/166] Express the per-substation voltage check as index arithmetic, TODO sub_vn_kv Rewrite check_bus_vn_kv_uniform_per_sub as the plain index relation, with no LocalBusId / local_to_gridmodel indirection: for all 0 <= I < n, for all 0 <= j < k: bus_vn_kv[I] == bus_vn_kv[I + j*n] (n = n_sub, k = nmax_busbar_per_sub). Same checks as before, stated directly. The stride is n: consecutive entries of bus_vn_kv are different SUBSTATIONS, which is why the shipped case14 fixture legitimately has bus_vn_kv[4] == 138 next to bus_vn_kv[5] == 20 -- a stride of 1 would reject every grid with more than one voltage level. The comment records why the old formulation went wrong, so it does not come back. Also document sub_vn_kv_ as the dead state it is, with a TODO on the member and an entry in the CHANGELOG's TODO section: neither of its two writers (init_sub, the two-argument constructor) is called anywhere, nothing reads it back, so it is empty on every grid every loader builds and in every binary file saved so far -- yet it is serialized into all of them. It is also redundant, being bus_vn_kv restricted to the first n_sub ids, which check_valid() now enforces when it is present. Either drop it from StateRes (needs a format bump) or have init_bus fill it and require it (changes what newly-saved files contain); both notes say so, so the choice is not lost. Verified: violating grids rejected at k == 2 and k == 3; case14 / case118 / case300 (pandapower), ieee14 (pypowsybl) and the format-4 fixture all still pass check_grid(). C++ 108 cases green, [check_grid] clean under valgrind (0 errors), python run shows the same 123 pre-existing environment failures as the unmodified tree and no others. Signed-off-by: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015CfJVFAd2ifp4qyZxp5Ec5 --- CHANGELOG.rst | 10 +++++ src/core/SubstationContainer.hpp | 75 ++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 90fbf0e2..f0ec1e46 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,16 @@ Change Log [TODO] -------- +- ``SubstationContainer::sub_vn_kv_`` is dead state: its only writers (``init_sub()`` + and the two-argument constructor) are called from nowhere, and nothing reads it + back, so it is empty on every grid every loader produces and in every binary file + saved so far. It is also redundant -- a substation's nominal voltage is the common + vn_kv of its buses, i.e. ``sub_vn_kv_[I] == bus_vn_kv_[I]`` for ``I < n_sub``, which + ``check_valid()`` now enforces when it is present. Decide: either drop it from + ``StateRes`` (needs a ``BINARY_FORMAT_VERSION`` bump) or have ``init_bus()`` fill it + and make it mandatory (changes what newly-saved files contain, and can only become + mandatory once no already-saved file needs to load). See the long note on the member + itself in ``SubstationContainer.hpp``. - [refacto] have a structure in cpp for the buses - [refacto] have the id_grid_to_solver and id_solver_to_grid etc. directly in the solver and NOT in the gridmodel. - [refacto] put some method in the DataGeneric as well as some attribute (_status for example) diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index e44ebbfc..872a35e5 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -226,35 +226,41 @@ class LS2G_API SubstationContainer final : public IteratorAdder BaseConstants::_tol_equal_float){ std::ostringstream exc_; exc_ << "SubstationContainer: each bus of a substation must have the same nominal " - << "voltage. Substation " << sub_id << " has " << ref_vn_kv - << " kV on its busbar 1 (bus id " << sub_id << ") but " << this_bus_vn_kv - << " kV on its busbar " << local_bus_id << " (bus id " << bus_id << ")."; + << "voltage. Substation " << I << " has " << ref_vn_kv + << " kV on bus id " << I << " but " << this_bus_vn_kv + << " kV on bus id " << bus_id << " (its busbar " << (j + 1) << ")."; throw std::runtime_error(exc_.str()); } } @@ -313,6 +319,37 @@ class LS2G_API SubstationContainer final : public IteratorAdder bus_status_; RealVect bus_vn_kv_; From abfd89216f6e0f22bedce840cac41db19ee6a5fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:16:20 +0000 Subject: [PATCH 164/166] Address PR review: solver origin, vn_kv > 0, overflow, noexcept Decide "built-in or plugin?" with the registry instead of AlgorithmType. AlgorithmType is a fixed enum of serialized solver identities, and a built-in only appears in it if a member was added for it -- the NRRefactorRetry_KLU/_NICSLU/_CKTSO family never got one, so name_to_algo_type() reported those three in-tree solvers as AlgorithmType::Custom exactly like a plugin, and they were paying for the external-solver output check (voltage size + finiteness) on every single solve, which built-ins are explicitly supposed to cost nothing. The registry now records a SolverOrigin at registration time, where the answer is actually known; External is the default, so a solver registered without saying anything stays validated. AlgorithmSelector caches the flag on change_algorithm so the hot path is a bool read, and every built-in registration goes through one tagging helper, so a solver added later cannot be silently misclassified by forgetting the tag. Check that a nominal voltage is finite and strictly positive -- for bus_vn_kv / sub_vn_kv only, never bus_vmin_kv / bus_vmax_kv, which use NaN as the documented "no limit set" sentinel (the same distinction that made the earlier element-level finiteness checks false-positive on real grids). Give init_bus and init_sub the overflow-safe n_sub * nmax_busbar_per_sub they were missing: all three entry points, set_state included, now share a checked_nb_bus helper that multiplies in 64 bits and refuses a product that does not fit the int it is stored in and indexed with. Drop the noexcept on set_ls_to_orig_internal: it assigns one Eigen vector and allocates another, either of which throws std::bad_alloc, and a throw out of a noexcept function is std::terminate() rather than something a caller can handle. Nothing needed the guarantee. Restore the "check sizes / TODO dev switches" comment in SubstationContainer::set_state, on the size checks it refers to, and lowercase the loop variable in check_bus_vn_kv_uniform_per_sub. Verified: NaN / 0 / negative vn_kv and the overflowing product are rejected; case14 / case118 / case300 (pandapower), ieee14 (pypowsybl) and the format-4 fixture all still pass check_grid(). C++ 110 cases green, [check_grid] clean under valgrind (0 errors), python run shows the same 123 pre-existing environment failures as the unmodified tree and no others. Signed-off-by: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015CfJVFAd2ifp4qyZxp5Ec5 --- CHANGELOG.rst | 25 +++++++ src/core/AlgorithmRegistry.cpp | 25 ++++++- src/core/AlgorithmRegistry.hpp | 41 ++++++++++- src/core/AlgorithmSelector.cpp | 4 ++ src/core/AlgorithmSelector.hpp | 14 ++++ src/core/BuiltinSolversRegistration.cpp | 60 +++++++++------- src/core/LSGrid.cpp | 20 ++++-- src/core/LSGrid.hpp | 17 +++-- src/core/SubstationContainer.cpp | 36 +++------- src/core/SubstationContainer.hpp | 94 +++++++++++++++++++++---- src/tests/test_check_grid.cpp | 62 ++++++++++++++++ 11 files changed, 322 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f0ec1e46..162801ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -174,6 +174,31 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding - [FIXED] ``check_grid()`` now also validates the substation container's own internal consistency and the bus-id mapping vectors (``_ls_to_orig`` / ``_orig_to_ls`` / ``_bus_fusion_rep``), which it previously ignored entirely. +- [FIXED] whether a solver is one of lightsim2grid's own or comes from a plugin was + decided by ``AlgorithmType``, which cannot answer that question: it is a fixed enum of + *serialized* solver identities, and a built-in only appears in it if a member was added + for it. The ``NRRefactorRetry_KLU`` / ``_NICSLU`` / ``_CKTSO`` family never got one, so + ``name_to_algo_type()`` reported those three **built-in** solvers as + ``AlgorithmType::Custom`` exactly like a plugin -- and they were consequently paying for + the external-solver output check (voltage size + finiteness) on *every single solve*, + which built-in solvers are explicitly supposed to cost nothing. The registry now records + a ``SolverOrigin`` (``Builtin`` / ``External``) when a solver is registered, which is + where the answer is actually known; ``AlgorithmSelector::is_builtin_algo()`` caches it so + the hot path stays a bool read. ``SolverOrigin::External`` is the default, so a solver + registered without saying anything is treated as untrusted. +- [FIXED] ``SubstationContainer`` did not check that a nominal voltage is a finite, + strictly positive number; 0, a negative value or NaN silently produced nonsense per-unit + conversions. NB this deliberately covers ``bus_vn_kv`` / ``sub_vn_kv`` only, never + ``bus_vmin_kv`` / ``bus_vmax_kv``, which use NaN as the documented "no limit set" sentinel. +- [FIXED] ``n_sub * nmax_busbar_per_sub`` (the total bus count, stored in an ``int`` and + used in ``int`` index arithmetic) was computed without an overflow check in ``init_bus`` + and ``init_sub``. All three entry points (those two plus ``set_state``) now go through a + single ``checked_nb_bus`` helper doing the multiplication in 64 bits and refusing a + product that does not fit, along with the positivity checks. +- [FIXED] ``LSGrid::set_ls_to_orig_internal`` was declared ``noexcept`` although it assigns + one Eigen vector and allocates another, either of which throws ``std::bad_alloc`` on + failure -- which in a ``noexcept`` function is an immediate ``std::terminate()`` rather + than something the caller can handle. Nothing needed the guarantee, so it is gone. - [FIXED] the "every bus of a substation must have the same nominal voltage" check in ``init_bus`` never fired: a gridmodel bus id is ``sub_id + (local_bus_id - 1) * n_sub`` and ``LocalBusId`` runs ``1..nmax_busbar_per_sub``, but the loop ran local ids diff --git a/src/core/AlgorithmRegistry.cpp b/src/core/AlgorithmRegistry.cpp index 10a22bfa..3a24b7b0 100644 --- a/src/core/AlgorithmRegistry.cpp +++ b/src/core/AlgorithmRegistry.cpp @@ -15,7 +15,8 @@ AlgorithmRegistry& AlgorithmRegistry::instance() { return reg; } -void AlgorithmRegistry::register_solver(const std::string& name, Factory f, Ls2gAbiTag caller_tag) { +void AlgorithmRegistry::register_solver(const std::string& name, Factory f, Ls2gAbiTag caller_tag, + SolverOrigin origin) { const Ls2gAbiTag& this_core_tag = core_abi_tag(); if (caller_tag != this_core_tag) { throw std::runtime_error( @@ -37,9 +38,10 @@ void AlgorithmRegistry::register_solver(const std::string& name, Factory f, Ls2g throw std::invalid_argument("AlgorithmRegistry: a solver named '" + printable(name) + "' is already registered."); } _factories[name] = std::move(f); + _origins[name] = origin; } -void AlgorithmRegistry::register_all(FactoryMap batch, Ls2gAbiTag caller_tag) { +void AlgorithmRegistry::register_all(FactoryMap batch, Ls2gAbiTag caller_tag, SolverOrigin origin) { const Ls2gAbiTag& this_core_tag = core_abi_tag(); if (caller_tag != this_core_tag) { throw std::runtime_error( @@ -70,10 +72,14 @@ void AlgorithmRegistry::register_all(FactoryMap batch, Ls2gAbiTag caller_tag) { // throws (e.g. std::bad_alloc), _factories is left exactly as it was. FactoryMap merged = _factories; merged.reserve(_factories.size() + batch.size()); + std::unordered_map merged_origins = _origins; + merged_origins.reserve(_origins.size() + batch.size()); for (auto& kv : batch) { + merged_origins.emplace(kv.first, origin); merged.emplace(kv.first, std::move(kv.second)); } _factories.swap(merged); + _origins.swap(merged_origins); } std::unique_ptr AlgorithmRegistry::make(const std::string& name) const { @@ -89,6 +95,21 @@ bool AlgorithmRegistry::is_registered(const std::string& name) const { return _factories.find(name) != _factories.end(); } +SolverOrigin AlgorithmRegistry::origin_of(const std::string& name) const { + auto it = _origins.find(name); + // an unknown name is reported External: the cautious answer (see the header) + if (it == _origins.end()) return SolverOrigin::External; + return it->second; +} + +std::vector AlgorithmRegistry::builtin_algorithm_names() const { + std::vector names; + for (auto it = _origins.begin(); it != _origins.end(); ++it) { + if (it->second == SolverOrigin::Builtin) names.push_back(it->first); + } + return names; +} + std::vector AlgorithmRegistry::available_algorithm_names() const { std::vector names; names.reserve(_factories.size()); diff --git a/src/core/AlgorithmRegistry.hpp b/src/core/AlgorithmRegistry.hpp index 8528d54f..42f585b3 100644 --- a/src/core/AlgorithmRegistry.hpp +++ b/src/core/AlgorithmRegistry.hpp @@ -83,6 +83,25 @@ inline std::string solver_name_rule() { "letters, digits, '_' or '.'"; } +/** + * Where a registered solver comes from: shipped with lightsim2grid, or brought in + * from outside (a plugin). + * + * This is the ONLY trustworthy answer to "is this solver ours?". `AlgorithmType` + * cannot answer it: it is a fixed enum of *serialized* solver identities, and a + * built-in solver only has a member there if someone added one -- the + * `NRRefactorRetry_*` family never got one, so `name_to_algo_type()` returns + * `AlgorithmType::Custom` for them exactly as it does for a plugin. Anything + * gating on `== Custom` therefore silently mistreats those built-ins (see + * LSGrid::process_results, which used to run the external-solver output check on + * them). Origin is recorded at registration time, where the truth is actually + * known, and cannot drift out of sync with the enum. + */ +enum class SolverOrigin { + Builtin, // registered by BuiltinSolversRegistration.cpp, ie shipped in-tree + External // registered by a plugin (or anything else at run time) +}; + class LS2G_API AlgorithmRegistry { public: using Factory = std::function()>; @@ -95,8 +114,14 @@ class LS2G_API AlgorithmRegistry { // this, the default captures that TU's own Eigen SIMD/alignment settings. // Throws if it disagrees with lightsim2grid_core's own tag -- see // Ls2gAbiTag.hpp. Used for the built-in solvers (BuiltinSolversRegistration). + // + // `origin` defaults to External on purpose: External is the *cautious* + // answer (it is what makes a solver's output validated before use), so a + // caller that does not say anything is treated as untrusted. Only + // BuiltinSolversRegistration.cpp passes SolverOrigin::Builtin. void register_solver(const std::string& name, Factory f, - Ls2gAbiTag caller_tag = ls2g_current_abi_tag()); + Ls2gAbiTag caller_tag = ls2g_current_abi_tag(), + SolverOrigin origin = SolverOrigin::External); // Register a whole batch of solvers atomically: either every name in // `batch` is added, or none is and the registry is left exactly as it was. @@ -104,15 +129,27 @@ class LS2G_API AlgorithmRegistry { // that a plugin exposing several solvers can never half-register when a // later name collides with an already-registered one. Throws (leaving the // registry unchanged) on an ABI-tag mismatch or a name already present. - void register_all(FactoryMap batch, Ls2gAbiTag caller_tag = ls2g_current_abi_tag()); + void register_all(FactoryMap batch, Ls2gAbiTag caller_tag = ls2g_current_abi_tag(), + SolverOrigin origin = SolverOrigin::External); std::unique_ptr make(const std::string& name) const; bool is_registered(const std::string& name) const; std::vector available_algorithm_names() const; + // Origin of an already-registered solver. An unknown name is reported as + // External -- again the cautious answer, and it keeps callers from having to + // handle a third "not registered" case. + SolverOrigin origin_of(const std::string& name) const; + bool is_builtin(const std::string& name) const { + return origin_of(name) == SolverOrigin::Builtin; + } + // Names registered with SolverOrigin::Builtin, ie the solvers shipped in-tree. + std::vector builtin_algorithm_names() const; + private: AlgorithmRegistry() = default; FactoryMap _factories; + std::unordered_map _origins; }; // Staging area a plugin fills in with its solvers before they are committed to diff --git a/src/core/AlgorithmSelector.cpp b/src/core/AlgorithmSelector.cpp index d5ba3343..cbb52621 100644 --- a/src/core/AlgorithmSelector.cpp +++ b/src/core/AlgorithmSelector.cpp @@ -35,6 +35,7 @@ FDPF_BX_SparseLU& AlgorithmSelector::get_fdpf_bx_lu() { AlgorithmSelector::AlgorithmSelector() : _algo_type(AlgorithmType::NR_SparseLU), _algo_name("NR_SparseLU"), + _algo_is_builtin(true), // NR_SparseLU is shipped in-tree _algo_type_used_for_nr(AlgorithmType::NR_SparseLU), _gridmodel_ptr(nullptr) { @@ -102,6 +103,9 @@ void AlgorithmSelector::change_algorithm(const std::string& name) _algo = std::move(new_algo); _algo_type = type; _algo_name = name; // keeps the plugin name, which `type` (Custom) loses + // Ask the registry, not the enum: NRRefactorRetry_* are built-ins with no + // AlgorithmType member, so `type` is Custom for them just as for a plugin. + _algo_is_builtin = AlgorithmRegistry::instance().is_builtin(name); _algo_type_used_for_nr = type; if (_gridmodel_ptr) _algo->set_lsgrid(_gridmodel_ptr); diff --git a/src/core/AlgorithmSelector.hpp b/src/core/AlgorithmSelector.hpp index 43dff999..df367ff3 100644 --- a/src/core/AlgorithmSelector.hpp +++ b/src/core/AlgorithmSelector.hpp @@ -119,6 +119,18 @@ class LS2G_API AlgorithmSelector final // using a plugin can be restored (see LSGrid::get_state/set_state). const std::string& get_name() const { return _algo_name; } + // Is the currently selected algorithm one shipped with lightsim2grid? + // + // Use THIS, never `get_type() == AlgorithmType::Custom`, to tell an + // in-tree solver from a plugin. AlgorithmType is a fixed enum of + // *serialized* solver identities, and a built-in only appears in it if a + // member was added for it: the NRRefactorRetry_* family never got one, so + // name_to_algo_type() reports those built-ins as Custom exactly like a + // plugin. The registry records the real answer at registration time (see + // SolverOrigin), and it is cached here at change_algorithm() time so + // asking costs nothing on the per-solve path. + bool is_builtin_algo() const { return _algo_is_builtin; } + // Polymorphic (instance-level) capability queries: unlike the // type-keyed overloads above (needed by LSGrid::change_algorithm to // route BEFORE a solver is constructed), these ask the CURRENTLY @@ -390,6 +402,8 @@ class LS2G_API AlgorithmSelector final std::unique_ptr _algo; AlgorithmType _algo_type; std::string _algo_name; // registry name; survives for plugin solvers + // cached SolverOrigin of _algo_name, see is_builtin_algo() + bool _algo_is_builtin; AlgorithmType _algo_type_used_for_nr; const LSGrid* _gridmodel_ptr; }; diff --git a/src/core/BuiltinSolversRegistration.cpp b/src/core/BuiltinSolversRegistration.cpp index 15d9e6f6..aedf03a1 100644 --- a/src/core/BuiltinSolversRegistration.cpp +++ b/src/core/BuiltinSolversRegistration.cpp @@ -13,63 +13,73 @@ namespace ls2g { void register_builtin_solvers(AlgorithmRegistry& reg) { - reg.register_solver("NR_SparseLU", + // Every solver registered here is shipped in-tree, so it is tagged + // SolverOrigin::Builtin. That tag -- NOT AlgorithmType -- is what tells + // lightsim2grid apart from a plugin at run time: AlgorithmType is a fixed enum + // of serialized solver identities and the NRRefactorRetry_* family has no + // member there, so name_to_algo_type() reports them as AlgorithmType::Custom + // exactly like a plugin. Going through this helper means a built-in added later + // cannot silently be treated as external by forgetting the tag. + const auto add_builtin = [®](const std::string& name, AlgorithmRegistry::Factory f) { + reg.register_solver(name, std::move(f), ls2g_current_abi_tag(), SolverOrigin::Builtin); + }; + add_builtin("NR_SparseLU", []{ return std::make_unique(); }); - reg.register_solver("NRSing_SparseLU", + add_builtin("NRSing_SparseLU", []{ return std::make_unique(); }); - reg.register_solver("DC_SparseLU", + add_builtin("DC_SparseLU", []{ return std::make_unique(); }); - reg.register_solver("GaussSeidel", + add_builtin("GaussSeidel", []{ return std::make_unique(); }); - reg.register_solver("GaussSeidelSynch", + add_builtin("GaussSeidelSynch", []{ return std::make_unique(); }); - reg.register_solver("FDPF_XB_SparseLU", + add_builtin("FDPF_XB_SparseLU", []{ return std::make_unique(); }); - reg.register_solver("FDPF_BX_SparseLU", + add_builtin("FDPF_BX_SparseLU", []{ return std::make_unique(); }); #ifdef KLU_SOLVER_AVAILABLE - reg.register_solver("NR_KLU", + add_builtin("NR_KLU", []{ return std::make_unique(); }); - reg.register_solver("NRSing_KLU", + add_builtin("NRSing_KLU", []{ return std::make_unique(); }); - reg.register_solver("DC_KLU", + add_builtin("DC_KLU", []{ return std::make_unique(); }); - reg.register_solver("FDPF_XB_KLU", + add_builtin("FDPF_XB_KLU", []{ return std::make_unique(); }); - reg.register_solver("FDPF_BX_KLU", + add_builtin("FDPF_BX_KLU", []{ return std::make_unique(); }); - reg.register_solver("NRRefactorRetry_KLU", + add_builtin("NRRefactorRetry_KLU", []{ return std::make_unique(); }); #endif // KLU_SOLVER_AVAILABLE #ifdef NICSLU_SOLVER_AVAILABLE - reg.register_solver("NR_NICSLU", + add_builtin("NR_NICSLU", []{ return std::make_unique(); }); - reg.register_solver("NRSing_NICSLU", + add_builtin("NRSing_NICSLU", []{ return std::make_unique(); }); - reg.register_solver("DC_NICSLU", + add_builtin("DC_NICSLU", []{ return std::make_unique(); }); - reg.register_solver("FDPF_XB_NICSLU", + add_builtin("FDPF_XB_NICSLU", []{ return std::make_unique(); }); - reg.register_solver("FDPF_BX_NICSLU", + add_builtin("FDPF_BX_NICSLU", []{ return std::make_unique(); }); - reg.register_solver("NRRefactorRetry_NICSLU", + add_builtin("NRRefactorRetry_NICSLU", []{ return std::make_unique(); }); #endif // NICSLU_SOLVER_AVAILABLE #ifdef CKTSO_SOLVER_AVAILABLE - reg.register_solver("NR_CKTSO", + add_builtin("NR_CKTSO", []{ return std::make_unique(); }); - reg.register_solver("NRSing_CKTSO", + add_builtin("NRSing_CKTSO", []{ return std::make_unique(); }); - reg.register_solver("DC_CKTSO", + add_builtin("DC_CKTSO", []{ return std::make_unique(); }); - reg.register_solver("FDPF_XB_CKTSO", + add_builtin("FDPF_XB_CKTSO", []{ return std::make_unique(); }); - reg.register_solver("FDPF_BX_CKTSO", + add_builtin("FDPF_BX_CKTSO", []{ return std::make_unique(); }); - reg.register_solver("NRRefactorRetry_CKTSO", + add_builtin("NRRefactorRetry_CKTSO", []{ return std::make_unique(); }); #endif // CKTSO_SOLVER_AVAILABLE } diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 26401e4a..35b96307 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -546,7 +546,8 @@ void LSGrid::set_orig_to_ls(const Eigen::Ref & orig_to_ls){ } } -void LSGrid::set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept{ +// NB deliberately NOT noexcept -- it allocates, see the declaration in LSGrid.hpp. +void LSGrid::set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig){ if(ls_to_orig.size() == 0){ _ls_to_orig = IntVect(); _orig_to_ls = IntVect(); @@ -1388,11 +1389,20 @@ void LSGrid::process_results(bool conv, // voltages. Validate their size/finiteness before we index them below // (compute_results / _get_results_back_to_orig_nodes both index the solver // vectors with an unchecked operator()). - // Only external solvers are checked: AlgorithmType::Custom is precisely - // "not one of the built-in solvers" (see name_to_algo_type), and the - // built-in ones are covered by the test suite, so they pay nothing here. + // Only external solvers are checked: the built-in ones are covered by the + // test suite, so they pay nothing here. + // + // "Is it built-in?" is asked of the REGISTRY, which records it at + // registration time (SolverOrigin), not of AlgorithmType. Gating on + // `get_type() == AlgorithmType::Custom` was wrong: AlgorithmType is a fixed + // enum of serialized solver identities, and a built-in only has a member + // there if one was added for it -- the NRRefactorRetry_* family never got + // one, so name_to_algo_type() reports those three built-ins as Custom + // exactly like a plugin, and they were paying for this check on every + // single solve. The flag is cached by AlgorithmSelector::change_algorithm, + // so this stays a bool read on the hot path. const bool is_external_algo = - (ac ? _algo.get_type() : _dc_algo.get_type()) == AlgorithmType::Custom; + !(ac ? _algo.is_builtin_algo() : _dc_algo.is_builtin_algo()); if (is_external_algo) conv = _check_solver_output(ac); } if (conv){ diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 7514143e..930d6c0d 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -1824,10 +1824,19 @@ class LS2G_API LSGrid final } protected: - // set both _ls_to_orig and _orig_to_ls. `noexcept`, and it indexes - // _orig_to_ls with the values of `ls_to_orig`: only ever call it on a vector - // that check_ls_to_orig_values() has already accepted (set_ls_to_orig does). - void set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig) noexcept; + // Set both _ls_to_orig and _orig_to_ls. + // + // NOT noexcept (it used to be declared so, wrongly): it assigns one Eigen + // vector and allocates another, either of which throws std::bad_alloc on + // failure -- and a throw out of a noexcept function is not an error the + // caller can handle, it is an immediate std::terminate(). Nothing needs the + // guarantee (the only callers are the copy constructor and set_ls_to_orig, + // neither of them noexcept), so the honest declaration is the plain one. + // + // It indexes _orig_to_ls with the *values* of `ls_to_orig`: only ever call + // it on a vector check_ls_to_orig_values() has already accepted, as + // set_ls_to_orig does. + void set_ls_to_orig_internal(const Eigen::Ref & ls_to_orig); // throws std::out_of_range unless every entry is -1 or a sane, non-negative // original-grid bus id -- see the definition in LSGrid.cpp for why. static void check_ls_to_orig_values(const Eigen::Ref & ls_to_orig); diff --git a/src/core/SubstationContainer.cpp b/src/core/SubstationContainer.cpp index 1f5a6d82..5a226e4b 100644 --- a/src/core/SubstationContainer.cpp +++ b/src/core/SubstationContainer.cpp @@ -60,32 +60,15 @@ void SubstationContainer::set_state(SubstationContainer::StateRes & my_state) // no bus at all: that state must still round-trip, so it is the one case where // negative counts are legal. const bool is_empty = bus_vn_kv.empty() && bus_status.empty(); - if(!(is_empty && (n_sub <= 0) && (nmax_busbar_per_sub <= 0))) - { - if(n_sub <= 0){ - std::ostringstream exc_; - exc_ << "SubstationContainer::set_state: the number of substations must be strictly " - << "positive on a grid that has buses, got " << n_sub << " (it is also used as a " - << "modulo divisor in sub_id_of_bus, so 0 would be a division by zero)."; - throw std::runtime_error(exc_.str()); - } - if(nmax_busbar_per_sub <= 0){ - std::ostringstream exc_; - exc_ << "SubstationContainer::set_state: the maximum number of busbars per substation " - << "must be strictly positive, got " << nmax_busbar_per_sub << "."; - throw std::runtime_error(exc_.str()); - } - // n_sub_ * nmax_busbar_per_sub_ is stored in an int: reject a product that - // does not fit rather than letting it overflow (UB, and a negative bus count). - if(nmax_busbar_per_sub > std::numeric_limits::max() / n_sub){ - std::ostringstream exc_; - exc_ << "SubstationContainer::set_state: n_sub (" << n_sub << ") * nmax_busbar_per_sub (" - << nmax_busbar_per_sub << ") overflows the maximum number of buses representable."; - throw std::runtime_error(exc_.str()); - } - } - const std::int64_t n_bus_max = static_cast(n_sub) * static_cast(nmax_busbar_per_sub); + // positivity + no int overflow on n_sub * nmax_busbar_per_sub, the same guarantee + // init_bus() / init_sub() get from the same helper. + const std::int64_t n_bus_max = is_empty + ? std::int64_t{0} + : static_cast(checked_nb_bus(n_sub, nmax_busbar_per_sub, + "SubstationContainer::set_state")); + // check sizes + // TODO dev switches // bus_vn_kv_ defines nb_bus(), the bound every other index is checked against; // bus_status_ is indexed with those same ids and MUST match it exactly. const auto check_len = [](std::size_t actual, std::int64_t expected, const char * name){ @@ -187,6 +170,9 @@ void SubstationContainer::check_valid() const throw std::runtime_error(exc_.str()); } + // every nominal voltage must be a finite, strictly positive number + check_vn_kv_positive(); + // All busbars of a substation share its nominal voltage. init_bus() is supposed // to enforce this at build time, but set_state() bypasses init_bus() entirely, // so a pickle / binary file could always carry a grid violating it. Re-check diff --git a/src/core/SubstationContainer.hpp b/src/core/SubstationContainer.hpp index 872a35e5..e282629b 100644 --- a/src/core/SubstationContainer.hpp +++ b/src/core/SubstationContainer.hpp @@ -13,7 +13,8 @@ #include // #include #include -#include // for int32 +#include // for int32, int64 +#include #include #include // for PI, std::abs #include @@ -128,16 +129,17 @@ class LS2G_API SubstationContainer final : public IteratorAdder(n_sub_) * static_cast(nmax_busbar_per_sub_); if(sub_vn_kv_.size() != n_sub_) sub_vn_kv_ = RealVect::Zero(n_sub_); - if(bus_vn_kv_.size() != nb_bus_total) bus_vn_kv_ = RealVect::Zero(nb_bus_total); + if(bus_vn_kv_.size() != nb_bus_checked) bus_vn_kv_ = RealVect::Zero(nb_bus_checked); for(int i = 0; i < n_sub_; ++i){ // store substation vn kv sub_vn_kv_[i] = sub_vn_kv[i]; @@ -202,9 +204,74 @@ class LS2G_API SubstationContainer final : public IteratorAdder(n_sub) * + static_cast(nmax_busbar_per_sub); + if(total > static_cast(std::numeric_limits::max())){ + std::ostringstream exc_; + exc_ << caller << ": n_sub (" << n_sub << ") * nmax_busbar_per_sub (" + << nmax_busbar_per_sub << ") = " << total << " buses, which does not fit in the " + << "int used to index them."; + throw std::runtime_error(exc_.str()); + } + return static_cast(total); + } + + /** + * Every bus nominal voltage must be a finite, strictly positive number. + * + * NB this is deliberately only about `bus_vn_kv_` / `sub_vn_kv_`, never about + * bus_vmin_kv_ / bus_vmax_kv_: those use NaN as the documented "no limit set + * for this bus" sentinel. Same reason the element-level finiteness checks were + * dropped earlier (non-finite thermal limits / reactive bounds are legitimate + * sentinels on real grids) -- a NOMINAL voltage has no such convention, 0 or a + * negative value is simply meaningless and would silently produce nonsense + * per-unit conversions (v_kv_from_vpu multiplies by it). + */ + void check_vn_kv_positive() const + { + const auto check_one = [](real_type v, int id, const char * what){ + if(!std::isfinite(v) || (v <= 0.)){ + std::ostringstream exc_; + exc_ << "SubstationContainer: " << what << " " << id << " has a nominal voltage of " + << v << " kV; it must be a finite, strictly positive number."; + throw std::runtime_error(exc_.str()); + } + }; + for(Eigen::Index i = 0; i < bus_vn_kv_.size(); ++i) check_one(bus_vn_kv_(i), static_cast(i), "bus"); + for(Eigen::Index i = 0; i < sub_vn_kv_.size(); ++i) check_one(sub_vn_kv_(i), static_cast(i), "substation"); + } + void init_bus(int n_sub, int nmax_busbar_per_sub, const Eigen::Ref & bus_vn_kv) { - if(bus_vn_kv.size() != n_sub * nmax_busbar_per_sub){ + // positivity + no int overflow on n_sub * nmax_busbar_per_sub + const int nb_bus_total = checked_nb_bus(n_sub, nmax_busbar_per_sub, "Substation::init_bus"); + if(bus_vn_kv.size() != nb_bus_total){ std::ostringstream exc_; exc_ << "Substation::init_bus: "; exc_ << "your model counts "; @@ -223,6 +290,7 @@ class LS2G_API SubstationContainer final : public IteratorAdder BaseConstants::_tol_equal_float){ std::ostringstream exc_; exc_ << "SubstationContainer: each bus of a substation must have the same nominal " - << "voltage. Substation " << I << " has " << ref_vn_kv - << " kV on bus id " << I << " but " << this_bus_vn_kv + << "voltage. Substation " << i << " has " << ref_vn_kv + << " kV on bus id " << i << " but " << this_bus_vn_kv << " kV on bus id " << bus_id << " (its busbar " << (j + 1) << ")."; throw std::runtime_error(exc_.str()); } diff --git a/src/tests/test_check_grid.cpp b/src/tests/test_check_grid.cpp index 4a3417c8..4b1df099 100644 --- a/src/tests/test_check_grid.cpp +++ b/src/tests/test_check_grid.cpp @@ -30,6 +30,7 @@ #include "LSGrid.hpp" #include "AlgorithmRegistry.hpp" +#include "AlgorithmTypeNames.hpp" // name_to_algo_type #include "powerflow_algorithm/BaseAlgo.hpp" using ls2g::LSGrid; @@ -767,6 +768,67 @@ TEST_CASE("a state with an unknown scaling policy is rejected", "[check_grid][al CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); } +TEST_CASE("built-in vs plugin is decided by the registry, not by AlgorithmType", "[check_grid][plugin]") +{ + // AlgorithmType is a fixed enum of *serialized* solver identities; a built-in + // only appears in it if a member was added for it. The NRRefactorRetry_* family + // never got one, so name_to_algo_type() reports those three IN-TREE solvers as + // AlgorithmType::Custom, exactly like a plugin -- and everything gating on + // `== Custom` (notably the external-solver output check in + // LSGrid::process_results) silently mistreated them. Origin is recorded at + // registration time instead. + auto & registry = ls2g::AlgorithmRegistry::instance(); + + // a built-in that DOES have an AlgorithmType member + CHECK(registry.is_builtin("NR_SparseLU")); + CHECK(registry.origin_of("NR_SparseLU") == ls2g::SolverOrigin::Builtin); + + // the real regression: a built-in whose name maps to AlgorithmType::Custom must + // still be reported as built-in. Only registered when KLU/NICSLU/CKTSO is + // available, so assert it for whichever of them this build has. + const std::vector refactor_retry = { + "NRRefactorRetry_KLU", "NRRefactorRetry_NICSLU", "NRRefactorRetry_CKTSO"}; + for (const auto & name : refactor_retry) { + if (!registry.is_registered(name)) continue; + INFO("solver " << name); + CHECK(ls2g::name_to_algo_type(name) == ls2g::AlgorithmType::Custom); // no enum member + CHECK(registry.is_builtin(name)); // yet in-tree + } + + // a solver registered the way a plugin does is External, and stays External + // even though its name likewise maps to Custom. + registry.register_solver("__origin_probe_external__", + [] { return std::unique_ptr(new PluginLikeAlgo()); }); + CHECK(ls2g::name_to_algo_type("__origin_probe_external__") == ls2g::AlgorithmType::Custom); + CHECK_FALSE(registry.is_builtin("__origin_probe_external__")); + + // an unregistered name is reported External (the cautious answer) + CHECK_FALSE(registry.is_builtin("__never_registered__")); + + // and every name the registry calls built-in really is registered + for (const auto & name : registry.builtin_algorithm_names()) { + INFO("builtin " << name); + CHECK(registry.is_registered(name)); + } +} + +TEST_CASE("the selector caches the origin of the algorithm it selects", "[check_grid][plugin]") +{ + auto & registry = ls2g::AlgorithmRegistry::instance(); + registry.register_solver("__origin_probe_selector__", + [] { return std::unique_ptr(new PluginLikeAlgo()); }); + + LSGrid grid = make_valid_grid(); + // default solver is a built-in + CHECK(grid.get_algo().is_builtin_algo()); + + grid.change_algorithm("__origin_probe_selector__"); + CHECK_FALSE(grid.get_algo().is_builtin_algo()); + + grid.change_algorithm("NR_SparseLU"); + CHECK(grid.get_algo().is_builtin_algo()); +} + TEST_CASE("a solver returning a wrong-sized voltage vector is rejected, not indexed", "[check_grid][plugin]") { ls2g::AlgorithmRegistry::instance().register_solver( From cd7421f9ac064b0d3632ebcb331bb26185a5a8a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 19:11:34 +0000 Subject: [PATCH 165/166] Close five more memory-safety holes on the untrusted-input paths Second security audit of the n1_full_compute branch, over the channels docs/security.rst covers (pickle, the binary format, and the python bindings including the grid loaders). Five issues, each reproduced as a segfault or as heap corruption before the fix: * TrafoContainer::set_state copied the two per-transformer (alpha -> r/x correction) tables with no check and then called _update_model_coeffs(), which indexes them per transformer with an unchecked operator[]. Fewer tables than transformers builds a std::vector out of heap memory past the end of the array and dereferences its pointers; alpha / correction tables of different lengths over-read the shorter one. This runs inside set_state, before check_grid() gets a chance to see the grid. * TwoSidesContainer::set_tsc_state never checked status_global_'s length -- nb() is side_1_.nb() -- while that vector is indexed with element ids bounded by nb() throughout the class and the batch solvers. Affects powerlines, transformers and hvdc lines. * check_grid() did not range-check SvcContainer::regulated_bus_id_, although it is a gridmodel bus id used directly as an index (and followed by a write into V) in SvcContainer::set_vm and in LSGrid::fill_voltage_control_solver_data. Reachable from a crafted state and straight from init_svcs / init_from_pypowsybl. It is the counterpart of the generator field of the same name, which was already validated. * check_grid() collected pos_topo_vect from every container, so the dim_topo it proved the permutation against could exceed the one update_topo() sizes its caller arrays with (which excludes shunts, sgens, svcs and hvdc). A validated load position could then be past the end of the array update_topo() indexes with it. * PandaPowerConverter never checked that its inputs have the same length (there was a TODO where the check belonged). Element count comes from the first argument, the rest are read with unchecked accessors and combined in Eigen expressions sized from one of them, so mismatched lengths wrote past the end of the shorter arrays -- reproducible as heap corruption from plain python, and reachable through init_from_pandapower on a net with a duplicated bus label. Covered by new tests: src/tests/test_state_poisoning.cpp (14 cases, in the suite that also runs under valgrind/ASan) and lightsim2grid/tests/test_state_poisoning.py (21 cases). Signed-off-by: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019K1jRmqbpB4JUsuHHy4t9A --- CHANGELOG.rst | 49 +++ docs/security.rst | 9 +- lightsim2grid/tests/test_state_poisoning.py | 284 +++++++++++++++++ src/core/DataConverter.cpp | 77 ++++- src/core/DataConverter.hpp | 37 +++ src/core/LSGrid.cpp | 33 +- src/core/element_container/SvcContainer.cpp | 34 ++ src/core/element_container/SvcContainer.hpp | 10 + src/core/element_container/TrafoContainer.cpp | 40 ++- .../element_container/TwoSidesContainer.hpp | 8 + src/tests/CMakeLists.txt | 1 + src/tests/test_state_poisoning.cpp | 300 ++++++++++++++++++ 12 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 lightsim2grid/tests/test_state_poisoning.py create mode 100644 src/tests/test_state_poisoning.cpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 162801ca..6b9bf27f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -134,6 +134,55 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an out-of-range bus id in a crafted binary file) now raises a clean exception instead of causing an out-of-bounds access during the next powerflow. +- [FIXED] ``PandaPowerConverter`` (``get_trafo_param_pp2`` / ``get_trafo_param_pp3`` / + ``get_line_param`` / ``get_line_param_legacy``, all exposed to python) never checked + that the arrays it is given have the same length -- there was a ``TODO check all + vectors have the same size`` where the check belonged. Their element count is taken + from the *first* argument, and the others are then read with unchecked accessors + (``vect.coeff(i)``, ``is_tap_hv_side[i]``) and combined in coefficient-wise Eigen + expressions whose result is sized from one of them. Eigen's own size assertions are + compiled out of the release wheels (``-O3 -DNDEBUG``), so a caller passing a longer + first array both read *and wrote* past the end of the shorter ones -- reproducible as + ``free(): invalid next size (fast)``, i.e. heap corruption, from a handful of lines of + plain python. All the input lengths are now checked up front. +- [FIXED] ``TrafoContainer::set_state`` copied the two per-transformer + ``alpha -> r/x correction`` tables (the phase-shifter impedance dependency, see + ``set_shift_dependent_rx``) out of the state with no check at all, and then called + ``_update_model_coeffs()``, which indexes ``rx_corr_alpha_[el_id]`` / + ``rx_corr_pct_[el_id]`` for every transformer with an unchecked + ``std::vector::operator[]``. A pickle or binary file declaring fewer tables than + transformers therefore read a ``std::vector`` object out of heap memory past the end + of the array and dereferenced its pointers -- a segfault, or worse. Two tables of + *different* lengths for the same transformer had the same effect one level down + (``_shift_rx_corr_pct`` interpolates ``ys`` with indices derived from ``xs.size()``). + This ran inside ``set_state``, i.e. **before** ``check_grid()``; both shapes are now + validated there, exactly as ``init()`` / ``set_shift_dependent_rx()`` maintain them. +- [FIXED] ``TwoSidesContainer::set_tsc_state`` (powerlines, transformers, hvdc lines) + never checked the length of ``status_global_``: ``nb()`` is ``side_1_.nb()``, so a + pickle or binary file could declare a shorter -- in particular empty -- + ``status_global`` while both sides carried the real element count. That vector is then + indexed with element ids bounded by ``nb()`` all over the class and the batch solvers + (``resolve_status``, ``_deactivate``, ``fillYbus``, ``ContingencyAnalysis``...) with an + unchecked ``operator[]``. Its length must now match exactly. +- [FIXED] ``check_grid()`` did not range-check ``SvcContainer``'s ``regulated_bus_id_``, + although it is a gridmodel bus id used *directly as an index* by the powerflow -- + ``id_grid_to_solver[regulated_bus_id_(svc_id)]`` in ``SvcContainer::set_vm`` (followed + by a **write** into ``V``) and in ``LSGrid::fill_voltage_control_solver_data``. Nothing + bounded it: ``init_svcs`` only checks the vector's length, so an SVC regulating an + out-of-range bus -- from a crafted pickle / binary file, or straight from a grid file + through ``init_from_pypowsybl`` -- passed ``check_grid()`` and then read and wrote out + of bounds on the next ``ac_pf``. It is now validated like the generator field of the + same name (``-1``, meaning "regulates no bus", stays legal). +- [FIXED] ``check_grid()`` collected the (optional) ``pos_topo_vect`` of *every* + container -- shunts, static generators, svcs and hvdc lines included -- into the set it + proves is a permutation of ``[0, dim_topo)``. But ``dim_topo`` there was just how many + positions it had collected, while ``update_topo()`` sizes its caller arrays as + ``nb loads + nb gens + nb storages + 2 * nb lines + 2 * nb trafos``, which excludes + those containers. A state putting positions on a shunt therefore inflated the bound, so + a *validated* load position could still be past the end of the array ``update_topo()`` + indexes with it. Only the containers ``update_topo()`` actually drives contribute now, + and a shunt / sgen / svc / hvdc carrying a topology-vector position (there is no setter + for one: such a state can only come from a crafted file) is rejected. - [FIXED] ``SubstationContainer::set_state`` performed **no** validation at all, and it restores the root of the grid's index space: ``nb_bus()`` (the bound ``check_grid()`` validates every element bus id against), ``bus_status_`` (the vector those same ids are diff --git a/docs/security.rst b/docs/security.rst index 09b923aa..149b4d90 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -82,8 +82,13 @@ the file, and it is validated at two levels: :py:meth:`LSGrid.check_grid` verifies that a grid is internally consistent and safe to run a powerflow on. It checks that every index the grid carries is in range — the bus id of every element, the substation -id and the position in the topology vector (both optional), and the generator -slack and remote-regulated bus references. It also checks the *shape* of the grid +id and the position in the topology vector (both optional), and the generator and +SVC slack / remote-regulated bus references. The topology-vector positions are +additionally required to form a permutation of ``[0, dim_topo)`` over exactly the +elements :py:meth:`LSGrid.update_topo` drives (loads, generators, storage units, +powerlines and transformers): that is what makes them safe to index the +``dim_topo``-sized arrays that method is given, so a shunt, static generator, SVC +or hvdc line claiming a position in that vector is rejected. It also checks the *shape* of the grid itself: that the substation container's own arrays agree with each other (the bus count, the per-bus status vector and ``n_sub × nmax_busbar_per_sub`` all describe the same set of buses), and that the bus-id mapping vectors carried alongside the diff --git a/lightsim2grid/tests/test_state_poisoning.py b/lightsim2grid/tests/test_state_poisoning.py new file mode 100644 index 00000000..6c2293dc --- /dev/null +++ b/lightsim2grid/tests/test_state_poisoning.py @@ -0,0 +1,284 @@ +# Copyright (c) 2026, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +"""Python-visible behaviour of the "poisoned state" rejections. + +A pickle (or a binary file: both go through ``LSGrid::set_state``) is only +length-checked while it is being read. Whatever it declares afterwards is copied +straight into the C++ containers, and release wheels are built ``-O3 -DNDEBUG``, +so an inconsistency that nothing rejects becomes an out-of-bounds read or write +on the next powerflow rather than an error. Every case below segfaulted the +interpreter before its fix. + +The deep ``set_state`` cases are covered exhaustively (and under +valgrind/ASan) by the C++ suite, ``src/tests/test_state_poisoning.cpp``; what is +checked here is that the same poisoned states raise a clean Python exception +through the real ``pickle.loads`` path, and that the checks reachable without any +serialization at all (an out-of-range SVC regulated bus set by a grid loader) +raise too. +""" + +import pickle +import unittest + +import numpy as np + +from lightsim2grid.lightsim2grid_cpp import LSGrid, PandaPowerConverter +from lightsim2grid.tests._exotic_elements_case_ls import build_exotic_elements_case_grid + + +# state indices, mirroring LSGrid::StateRes (see LSGrid.hpp) +LINE_ID = 8 +SHUNT_ID = 9 +TRAFO_ID = 10 +HVDC_ID = 15 +SVC_ID = 16 + +BIG = 10 ** 7 + + +def _rebuild(state): + """Reconstruct an LSGrid from a (possibly tampered) pickle state, exactly the + way ``pickle.loads`` does -- ``__setstate__`` on an already-built object is a + no-op for a pybind11 ``py::pickle`` type.""" + obj = LSGrid.__new__(LSGrid) + obj.__setstate__(state) + return obj + + +def _set_at(state, path, value): + """Return a copy of the nested state tuple with `path` replaced by `value`.""" + if not path: + return value + head, rest = path[0], path[1:] + as_list = list(state) + as_list[head] = _set_at(as_list[head], rest, value) + return tuple(as_list) + + +def _get_at(state, path): + for i in path: + state = state[i] + return state + + +class TestPoisonedState(unittest.TestCase): + """The grid carries a phase-shifting transformer, an SVC and 3 HVDC lines, so + every container the cases below poison is non-empty.""" + + def setUp(self): + self.grid = build_exotic_elements_case_grid() + self.state = self.grid.__getstate__() + # (version_major, version_medium, version_minor, LSGrid::StateRes) + self.inner = (3,) + + def _poison(self, path, value): + return _set_at(self.state, self.inner + tuple(path), value) + + # ------------------------------------------------------------------ + # sanity: the untouched state round-trips + # ------------------------------------------------------------------ + def test_valid_state_round_trips(self): + grid = pickle.loads(pickle.dumps(self.grid)) + self.assertIsNone(grid.check_grid()) + v0 = np.ones(grid.total_bus(), dtype=complex) + self.assertNotEqual(len(grid.ac_pf(v0, 20, 1e-7)), 0) + + # ------------------------------------------------------------------ + # TrafoContainer: the (alpha -> r/x correction) tables. They are indexed by + # transformer id inside set_state itself (_update_model_coeffs), i.e. before + # check_grid() gets a chance to run. + # ------------------------------------------------------------------ + def test_rx_corr_tables_missing_is_rejected(self): + # shift-dependent r/x enabled (it is, in this grid) but no table at all + self.assertTrue(_get_at(self.state, (3, TRAFO_ID, 5))) + bad = self._poison((TRAFO_ID, 8), ()) + bad = _set_at(bad, (3, TRAFO_ID, 9), ()) + with self.assertRaises(RuntimeError): + _rebuild(bad) + + def test_rx_corr_tables_too_short_is_rejected(self): + alpha = _get_at(self.state, (3, TRAFO_ID, 8)) + self.assertGreater(len(alpha), 1) + with self.assertRaises(RuntimeError): + _rebuild(self._poison((TRAFO_ID, 8), tuple(alpha)[:-1])) + + def test_rx_corr_alpha_and_pct_of_different_lengths_is_rejected(self): + alpha = list(_get_at(self.state, (3, TRAFO_ID, 8))) + pct = list(_get_at(self.state, (3, TRAFO_ID, 9))) + alpha[0] = (-0.5, 0.0, 0.5) + pct[0] = (1.0, 2.0) # one correction short + bad = self._poison((TRAFO_ID, 8), tuple(alpha)) + bad = _set_at(bad, (3, TRAFO_ID, 9), tuple(pct)) + with self.assertRaises(RuntimeError): + _rebuild(bad) + + # ------------------------------------------------------------------ + # TwoSidesContainer: status_global_. `nb()` is side_1_.nb(), so nothing tied + # status_global_'s length to the number of elements indexing it. + # ------------------------------------------------------------------ + def test_empty_status_global_on_lines_is_rejected(self): + with self.assertRaises(RuntimeError): + _rebuild(self._poison((LINE_ID, 0, 0, 3), ())) + + def test_short_status_global_on_lines_is_rejected(self): + status = _get_at(self.state, (3, LINE_ID, 0, 0, 3)) + with self.assertRaises(RuntimeError): + _rebuild(self._poison((LINE_ID, 0, 0, 3), tuple(status)[:-1])) + + def test_wrong_status_global_on_trafos_is_rejected(self): + with self.assertRaises(RuntimeError): + _rebuild(self._poison((TRAFO_ID, 0, 0, 3), ())) + + def test_wrong_status_global_on_hvdc_is_rejected(self): + status = _get_at(self.state, (3, HVDC_ID, 0, 3)) + with self.assertRaises(RuntimeError): + _rebuild(self._poison((HVDC_ID, 0, 3), tuple(status) + (True,))) + + # ------------------------------------------------------------------ + # SvcContainer: the regulated bus id, used as an index by the powerflow. + # ------------------------------------------------------------------ + def test_out_of_range_svc_regulated_bus_is_rejected(self): + reg = _get_at(self.state, (3, SVC_ID, 6)) + self.assertGreater(len(reg), 0) + with self.assertRaises(IndexError): + _rebuild(self._poison((SVC_ID, 6), (BIG,) * len(reg))) + + def test_negative_svc_regulated_bus_is_rejected(self): + reg = _get_at(self.state, (3, SVC_ID, 6)) + with self.assertRaises(IndexError): + _rebuild(self._poison((SVC_ID, 6), (-5,) * len(reg))) + + def test_minus_one_svc_regulated_bus_is_accepted(self): + reg = _get_at(self.state, (3, SVC_ID, 6)) + grid = _rebuild(self._poison((SVC_ID, 6), (-1,) * len(reg))) + self.assertIsNone(grid.check_grid()) + + def test_out_of_range_svc_regulated_bus_from_init_svcs_is_rejected(self): + # the same hole with no serialization involved: this is what + # init_from_pypowsybl does with the bus ids it reads from the source grid + grid = build_exotic_elements_case_grid() + grid.init_svcs([1], + np.array([1.0]), np.array([0.0]), np.array([0.0]), + np.array([-0.03]), np.array([0.03]), + np.array([BIG], dtype=np.int32), # regulated bus, out of range + np.array([5], dtype=np.int32)) + with self.assertRaises(IndexError): + grid.check_grid() + + # ------------------------------------------------------------------ + # pos_topo_vect on an element that is not in the grid2op topology vector. + # check_grid() proved the collected positions were a permutation of [0, K) + # with K counting shunts / sgens / svcs / hvdc too, while update_topo() sizes + # its caller arrays without them -- so a validated load position could still + # be past the end of the array update_topo() indexes with it. + # ------------------------------------------------------------------ + def test_pos_topo_vect_on_a_shunt_is_rejected(self): + nb_shunt = len(_get_at(self.state, (3, SHUNT_ID, 0, 0, 1))) + self.assertGreater(nb_shunt, 0) + bad = self._poison((SHUNT_ID, 0, 0, 5), True) + bad = _set_at(bad, (3, SHUNT_ID, 0, 0, 6), tuple(range(nb_shunt))) + with self.assertRaises(RuntimeError): + _rebuild(bad) + + def test_update_topo_stays_in_bounds_with_a_full_topo_vect(self): + # the legitimate arrangement the check above protects: positions only on + # the containers update_topo() drives, forming a permutation of + # [0, dim_topo). + grid = build_exotic_elements_case_grid() + n_load = len(grid.get_loads()) + n_gen = len(grid.get_generators()) + n_sto = len(grid.get_storages()) + n_line = len(grid.get_lines()) + n_trafo = len(grid.get_trafos()) + dim_topo = n_load + n_gen + n_sto + 2 * n_line + 2 * n_trafo + pos = np.arange(dim_topo, dtype=np.int32) + off = 0 + for setter, nb in ((grid.set_load_pos_topo_vect, n_load), + (grid.set_gen_pos_topo_vect, n_gen), + (grid.set_storage_pos_topo_vect, n_sto), + (grid.set_line_pos1_topo_vect, n_line), + (grid.set_line_pos2_topo_vect, n_line), + (grid.set_trafo_pos1_topo_vect, n_trafo), + (grid.set_trafo_pos2_topo_vect, n_trafo)): + setter(pos[off:off + nb]) + off += nb + self.assertIsNone(grid.check_grid()) + grid.update_topo(np.zeros(dim_topo, dtype=bool), np.ones(dim_topo, dtype=np.int32)) + + +class TestPandaPowerConverterInputSizes(unittest.TestCase): + """``PandaPowerConverter`` walks its inputs together element by element with + unchecked accessors, and combines them in coefficient-wise Eigen expressions + sized from one of them. Eigen's own size assertions are compiled out of the + release wheels, so mismatched lengths used to write past the end of the + shorter arrays -- observed as heap corruption (``free(): invalid next size``), + not an exception.""" + + def setUp(self): + self.conv = PandaPowerConverter() + self.conv.set_f_hz(50.0) + self.conv.set_sn_mva(100.0) + self.long = np.ones(200) + self.one = np.ones(1) + + def _trafo_args(self, first): + return (first, self.one, self.one, [True], + self.one, self.one, self.one, self.one, self.one, self.one, self.one) + + def test_get_trafo_param_pp2_rejects_mismatched_sizes(self): + with self.assertRaises(RuntimeError): + self.conv.get_trafo_param_pp2(*self._trafo_args(self.long)) + + def test_get_trafo_param_pp3_rejects_mismatched_sizes(self): + with self.assertRaises(RuntimeError): + self.conv.get_trafo_param_pp3(*self._trafo_args(self.long), True) + + def test_get_trafo_param_rejects_a_short_bool_vector(self): + args = (self.one, self.one, self.one, [], # is_tap_hv_side empty + self.one, self.one, self.one, self.one, self.one, self.one, self.one) + with self.assertRaises(RuntimeError): + self.conv.get_trafo_param_pp2(*args) + + def test_get_line_param_rejects_mismatched_sizes(self): + with self.assertRaises(RuntimeError): + self.conv.get_line_param(self.long, self.one, self.one, self.one, + self.one, self.one) + + def test_get_line_param_legacy_rejects_mismatched_sizes(self): + with self.assertRaises(RuntimeError): + self.conv.get_line_param_legacy(self.one, self.one, self.one, self.long, + self.one, self.one) + + def test_malformed_pandapower_net_raises_instead_of_corrupting_the_heap(self): + # the same hole, reached through init_from_pandapower: the loader looks the + # transformer / line end voltages up with `pp_net.bus.loc[pp_net.trafo["hv_bus"]]`, + # so a net whose bus index carries a DUPLICATE label hands the converter more + # nominal voltages than there are branches. + try: + import pandas as pd + import pandapower.networks as pn + from lightsim2grid.network import init_from_pandapower + except ImportError: + self.skipTest("pandapower is not installed") + net = pn.case14() + net.bus = pd.concat([net.bus, net.bus.loc[[3]].copy()]) # bus label 3 twice + with self.assertRaises(RuntimeError): + init_from_pandapower(net) + + def test_matching_sizes_still_work(self): + n = 3 + v = np.ones(n) + r, x, h = self.conv.get_trafo_param_pp2(v, v, v, [True] * n, v, v, v, v, v, v, v) + self.assertEqual(r.shape[0], n) + lr, lx, h_or, h_ex = self.conv.get_line_param(v, v, v, v, v, v) + self.assertEqual(lr.shape[0], n) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/core/DataConverter.cpp b/src/core/DataConverter.cpp index 9f7d21d8..7c350e94 100644 --- a/src/core/DataConverter.cpp +++ b/src/core/DataConverter.cpp @@ -9,9 +9,70 @@ #include "DataConverter.hpp" #include +#include +#include namespace ls2g { +void PandaPowerConverter::_check_same_size(const std::string & fun_name, + Eigen::Index expected, + const std::string & expected_name, + const std::string & name, + Eigen::Index actual) +{ + if(actual == expected) return; + std::ostringstream exc_; + exc_ << "PandaPowerConverter::" << fun_name << ": '" << name << "' has " << actual + << " element(s) while '" << expected_name << "' has " << expected + << ". All the inputs describe the same elements, one entry each, and are read " + "together element by element: they must all have the same length."; + throw std::runtime_error(exc_.str()); +} + +void PandaPowerConverter::_check_trafo_inputs(const std::string & fun_name, + const Eigen::Ref & tap_step_pct, + const Eigen::Ref & tap_pos, + const Eigen::Ref & tap_angles, + const std::vector & is_tap_hv_side, + const Eigen::Ref & vn_hv, + const Eigen::Ref & vn_lv, + const Eigen::Ref & trafo_vk_percent, + const Eigen::Ref & trafo_vkr_percent, + const Eigen::Ref & trafo_sn_trafo_mva, + const Eigen::Ref & trafo_pfe_kw, + const Eigen::Ref & trafo_i0_pct) +{ + const Eigen::Index nb_trafo = tap_step_pct.size(); + const char * ref = "tap_step_pct"; + _check_same_size(fun_name, nb_trafo, ref, "tap_pos", tap_pos.size()); + _check_same_size(fun_name, nb_trafo, ref, "tap_angles", tap_angles.size()); + _check_same_size(fun_name, nb_trafo, ref, "is_tap_hv_side", + static_cast(is_tap_hv_side.size())); + _check_same_size(fun_name, nb_trafo, ref, "vn_hv", vn_hv.size()); + _check_same_size(fun_name, nb_trafo, ref, "vn_lv", vn_lv.size()); + _check_same_size(fun_name, nb_trafo, ref, "trafo_vk_percent", trafo_vk_percent.size()); + _check_same_size(fun_name, nb_trafo, ref, "trafo_vkr_percent", trafo_vkr_percent.size()); + _check_same_size(fun_name, nb_trafo, ref, "trafo_sn_trafo_mva", trafo_sn_trafo_mva.size()); + _check_same_size(fun_name, nb_trafo, ref, "trafo_pfe_kw", trafo_pfe_kw.size()); + _check_same_size(fun_name, nb_trafo, ref, "trafo_i0_pct", trafo_i0_pct.size()); +} + +void PandaPowerConverter::_check_line_inputs(const std::string & fun_name, + const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_g, + const Eigen::Ref & branch_c, + const Eigen::Ref & branch_from_kv, + const Eigen::Ref & branch_to_kv) +{ + const Eigen::Index nb_line = branch_r.size(); + const char * ref = "branch_r"; + _check_same_size(fun_name, nb_line, ref, "branch_x", branch_x.size()); + _check_same_size(fun_name, nb_line, ref, "branch_g", branch_g.size()); + _check_same_size(fun_name, nb_line, ref, "branch_c", branch_c.size()); + _check_same_size(fun_name, nb_line, ref, "branch_from_kv", branch_from_kv.size()); + _check_same_size(fun_name, nb_line, ref, "branch_to_kv", branch_to_kv.size()); +} void PandaPowerConverter::_check_init() const { if(sn_mva_ <= 0.){ @@ -41,7 +102,9 @@ std::tuple(tap_step_pct.size()); - // TODO check all vectors have the same size + _check_trafo_inputs("get_trafo_param_pp2", tap_step_pct, tap_pos, tap_angles, is_tap_hv_side, + vn_hv, vn_lv, trafo_vk_percent, trafo_vkr_percent, trafo_sn_trafo_mva, + trafo_pfe_kw, trafo_i0_pct); // compute the adjusted for phase shifter and tap side auto tap_steps = 0.01 * tap_step_pct.array() * tap_pos.array(); @@ -134,10 +197,12 @@ std::tuple & branch_g, const Eigen::Ref & branch_c, const Eigen::Ref & branch_from_kv, - const Eigen::Ref & /*branch_to_kv*/) const + const Eigen::Ref & branch_to_kv) const { //TODO does not use c at the moment! _check_init(); + _check_line_inputs("get_line_param_legacy", branch_r, branch_x, branch_g, branch_c, + branch_from_kv, branch_to_kv); const int nb_line = static_cast(branch_r.size()); RealVect branch_from_pu = branch_from_kv.array() * branch_from_kv.array() / sn_mva_; @@ -166,9 +231,11 @@ std::tuple & branch_g, const Eigen::Ref & branch_c, const Eigen::Ref & branch_from_kv, - const Eigen::Ref & /*branch_to_kv*/) const + const Eigen::Ref & branch_to_kv) const { _check_init(); + _check_line_inputs("get_line_param", branch_r, branch_x, branch_g, branch_c, + branch_from_kv, branch_to_kv); const int nb_line = static_cast(branch_r.size()); RealVect branch_from_pu = branch_from_kv.array() * branch_from_kv.array() / sn_mva_; @@ -212,7 +279,9 @@ std::tuple(tap_step_pct.size()); - // TODO check all vectors have the same size + _check_trafo_inputs("get_trafo_param_pp3", tap_step_pct, tap_pos, tap_angles, is_tap_hv_side, + vn_hv, vn_lv, trafo_vk_percent, trafo_vkr_percent, trafo_sn_mva, + trafo_pfe_kw, trafo_i0_pct); // compute the adjusted for phase shifter and tap side auto tap_steps = 0.01 * tap_step_pct.array() * tap_pos.array(); diff --git a/src/core/DataConverter.hpp b/src/core/DataConverter.hpp index cc824fec..afd32f1e 100644 --- a/src/core/DataConverter.hpp +++ b/src/core/DataConverter.hpp @@ -8,6 +8,7 @@ #ifndef DATACONVERTER_H #define DATACONVERTER_H +#include #include #include "Utils.hpp" @@ -105,6 +106,42 @@ class LS2G_API PandaPowerConverter final : public BaseConstants private: void _check_init() const; + + // Every method above walks its inputs together, element by element, with + // unchecked accessors (`vect.coeff(i)`, `is_tap_hv_side[i]`) and combines + // them in coefficient-wise Eigen expressions whose result is sized from + // one of them. Eigen's own size assertions are compiled out of release + // wheels (-O3 -DNDEBUG), so a caller passing arrays of different lengths + // reads AND writes past the end of the shorter ones (observed as heap + // corruption, not a clean error). These are public python bindings + // (`PandaPowerConverter`), so the sizes have to be checked here. + static void _check_same_size(const std::string & fun_name, + Eigen::Index expected, + const std::string & expected_name, + const std::string & name, + Eigen::Index actual); + // the shared input-shape check of get_trafo_param_pp2 / get_trafo_param_pp3 + // (identical argument lists) + static void _check_trafo_inputs(const std::string & fun_name, + const Eigen::Ref & tap_step_pct, + const Eigen::Ref & tap_pos, + const Eigen::Ref & tap_angles, + const std::vector & is_tap_hv_side, + const Eigen::Ref & vn_hv, + const Eigen::Ref & vn_lv, + const Eigen::Ref & trafo_vk_percent, + const Eigen::Ref & trafo_vkr_percent, + const Eigen::Ref & trafo_sn_trafo_mva, + const Eigen::Ref & trafo_pfe_kw, + const Eigen::Ref & trafo_i0_pct); + // ... and of get_line_param / get_line_param_legacy + static void _check_line_inputs(const std::string & fun_name, + const Eigen::Ref & branch_r, + const Eigen::Ref & branch_x, + const Eigen::Ref & branch_g, + const Eigen::Ref & branch_c, + const Eigen::Ref & branch_from_kv, + const Eigen::Ref & branch_to_kv); }; // TODO have a converter from ppc ! diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 35b96307..79ea5046 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -338,18 +338,37 @@ void LSGrid::check_grid() const const int nb_sub = substations_.nb_sub(); // Per-container range + finiteness checks. Each container appends the - // pos_topo_vect entries it carries (an optional field) to all_pos_topo_vect - // for the global permutation check below. - std::vector all_pos_topo_vect; + // pos_topo_vect entries it carries (an optional field) to the accumulator it is + // given, for the global permutation check below. + // + // Only the containers update_topo() actually walks may contribute to + // `all_pos_topo_vect`: that vector's own length is the `dim_topo` the permutation + // is proved against, and update_topo() sizes its caller arrays as + // `nb loads + nb gens + nb storages + 2 * nb lines + 2 * nb trafos`. Letting a + // shunt / sgen / svc / hvdc position into the same pot inflates that bound, so a + // *validated* load position could still be >= the length of the arrays + // update_topo() indexes with it. Those containers have no position in the grid2op + // topology vector to begin with (there is no setter for one), so a state carrying + // one is inconsistent: collect them apart and reject. + std::vector all_pos_topo_vect; // elements update_topo() drives + std::vector pos_topo_vect_not_in_topo; // must stay empty powerlines_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); trafos_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); - shunts_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); generators_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); loads_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); - sgens_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); storages_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); - hvdc_lines_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); - svcs_.check_valid(nb_bus, nb_sub, substations_, all_pos_topo_vect); + shunts_.check_valid(nb_bus, nb_sub, substations_, pos_topo_vect_not_in_topo); + sgens_.check_valid(nb_bus, nb_sub, substations_, pos_topo_vect_not_in_topo); + hvdc_lines_.check_valid(nb_bus, nb_sub, substations_, pos_topo_vect_not_in_topo); + svcs_.check_valid(nb_bus, nb_sub, substations_, pos_topo_vect_not_in_topo); + if(!pos_topo_vect_not_in_topo.empty()) + { + throw std::runtime_error( + "LSGrid::check_grid: a shunt, static generator, svc or hvdc line declares a position " + "in the grid2op topology vector. Only loads, generators, storage units, powerlines and " + "transformers are part of that vector (it is what sizes the arrays passed to " + "update_topo), so this state is inconsistent."); + } // pos_topo_vect is grid2op-specific and optional: it is either set on every // topology-participating element or on none. When set, the collected values diff --git a/src/core/element_container/SvcContainer.cpp b/src/core/element_container/SvcContainer.cpp index 8b584f57..2f779940 100644 --- a/src/core/element_container/SvcContainer.cpp +++ b/src/core/element_container/SvcContainer.cpp @@ -11,6 +11,7 @@ #include #include +#include namespace ls2g { @@ -85,6 +86,39 @@ void SvcContainer::set_state(SvcContainer::StateRes & my_state) reset_results(); } +void SvcContainer::check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const +{ + // one-side index checks (bus / subid / pos_topo_vect) + check_valid_osc(nb_bus, nb_sub, substations, all_pos_topo_vect, "svc"); + + // `regulated_bus_id_` is a gridmodel bus id that the powerflow uses *as an index* + // without re-checking it: `id_grid_to_solver[regulated_bus_id_(svc_id)]` in + // SvcContainer::set_vm and in LSGrid::fill_voltage_control_solver_data, and + // `Vm(regulated_bus_id_(svc_id))` in get_vm_for_dc. Nothing on the way in bounds it + // (SvcContainer::init only checks the vector's length, and a pickle / binary file + // sets it verbatim), so an out-of-range value is an out-of-bounds read followed by + // an out-of-bounds write into V. Same check as the generator field of the same name. + const int nb_svc = nb(); + const bool has_reg_info = regulated_bus_id_.size() > 0; + if(!has_reg_info) return; + for(int svc_id = 0; svc_id < nb_svc; ++svc_id) + { + // -1 is legal: this SVC regulates no bus (disconnected / no remote target) + const int reg = regulated_bus_id_(svc_id); + if(reg == _deactivated_bus_id) continue; + if((reg < 0) || (reg >= nb_bus)) + { + std::ostringstream exc_; + exc_ << "LSGrid::check_grid: svc id " << svc_id << " regulates bus id " << reg + << " which is out of range [0, " << nb_bus << ")."; + throw std::out_of_range(exc_.str()); + } + } +} + void SvcContainer::fillSbus(Eigen::Ref Sbus, const SolverBusIdVect & id_grid_to_solver, bool /*ac*/) const { const int nb_svc = nb(); diff --git a/src/core/element_container/SvcContainer.hpp b/src/core/element_container/SvcContainer.hpp index 74cb1003..2148448a 100644 --- a/src/core/element_container/SvcContainer.hpp +++ b/src/core/element_container/SvcContainer.hpp @@ -109,6 +109,16 @@ class LS2G_API SvcContainer final : public OneSideContainer_PQ, public IteratorA SvcContainer::StateRes get_state() const; void set_state(SvcContainer::StateRes & my_state); + // Whole-grid semantic validation (see GenericContainer::check_valid): the + // one-side checks, plus the range of `regulated_bus_id_`. That last one is + // a *grid* bus id used directly as an index (`id_grid_to_solver[...]` in + // set_vm / LSGrid::fill_voltage_control_solver_data, `Vm(...)` in + // get_vm_for_dc), exactly like the generator field of the same name. + void check_valid(int nb_bus, + int nb_sub, + const SubstationContainer & substations, + std::vector & all_pos_topo_vect) const override; + // fast binary serialization (additive alternative to pickle, see BinaryArchive.hpp) void save_binary(const std::string & path, bool atomic = true) const; static SvcContainer load_binary(const std::string & path); diff --git a/src/core/element_container/TrafoContainer.cpp b/src/core/element_container/TrafoContainer.cpp index 3f16ac1c..bb6f1730 100644 --- a/src/core/element_container/TrafoContainer.cpp +++ b/src/core/element_container/TrafoContainer.cpp @@ -128,8 +128,44 @@ void TrafoContainer::set_state(TrafoContainer::StateRes & my_state) GenericContainer::check_size(base_x, size, "base_x"); base_r_ = RealVect::Map(base_r.data(), size); base_x_ = RealVect::Map(base_x.data(), size); - rx_corr_alpha_ = std::get<8>(my_state); - rx_corr_pct_ = std::get<9>(my_state); + + // The two alpha -> r/x correction tables come straight from a pickle or a binary + // file. `_update_model_coeffs()` right below walks el_id over [0, nb()) and reads + // `rx_corr_alpha_[el_id]` / `rx_corr_pct_[el_id]` with an unchecked + // std::vector::operator[], and `_shift_rx_corr_pct` then interpolates `ys` using + // indices derived from `xs.size()`. So a state declaring fewer tables than + // transformers builds a std::vector out of whatever the heap holds past the end + // (and dereferences its pointers), and a state whose alpha / correction tables + // differ in length reads past the end of the shorter one. Neither is caught by + // check_grid(): this runs *before* it. Validate both shapes here, exactly the + // invariant init() and set_shift_dependent_rx() maintain. + const std::vector > & rx_corr_alpha = std::get<8>(my_state); + const std::vector > & rx_corr_pct = std::get<9>(my_state); + if(rx_corr_alpha.empty() && rx_corr_pct.empty()){ + // no table at all: only legal when nothing would index them + if(shift_dependent_rx_ && (size > 0)){ + std::ostringstream exc_; + exc_ << "TrafoContainer::set_state: shift-dependent r/x is enabled for " << size + << " transformer(s) but the state carries no (alpha -> correction) table at all. " + << "The tables are indexed by transformer id, so this state is inconsistent."; + throw std::runtime_error(exc_.str()); + } + } else { + GenericContainer::check_size(rx_corr_alpha, size, "rx_corr_alpha"); + GenericContainer::check_size(rx_corr_pct, size, "rx_corr_pct"); + for(std::size_t el_id = 0; el_id < rx_corr_alpha.size(); ++el_id){ + if(rx_corr_alpha[el_id].size() != rx_corr_pct[el_id].size()){ + std::ostringstream exc_; + exc_ << "TrafoContainer::set_state: transformer " << el_id << " has " + << rx_corr_alpha[el_id].size() << " alpha sample(s) but " + << rx_corr_pct[el_id].size() << " r/x correction value(s). Both tables are " + << "read together (one correction per alpha) and must have the same length."; + throw std::runtime_error(exc_.str()); + } + } + } + rx_corr_alpha_ = rx_corr_alpha; + rx_corr_pct_ = rx_corr_pct; _update_model_coeffs(); reset_results(); diff --git a/src/core/element_container/TwoSidesContainer.hpp b/src/core/element_container/TwoSidesContainer.hpp index 0c601bdb..59a8e67c 100644 --- a/src/core/element_container/TwoSidesContainer.hpp +++ b/src/core/element_container/TwoSidesContainer.hpp @@ -430,6 +430,14 @@ class TwoSidesContainer : public GenericContainer if(names_.size() > 0) check_size(names_, size, "names"); // names are optional if(static_cast(side_1_.nb()) != size) throw std::runtime_error("Side_1 do not have the proper size"); if(static_cast(side_2_.nb()) != size) throw std::runtime_error("Side_2 do not have the proper size"); + // `nb()` is side_1_.nb(), NOT status_global_.size(): nothing above ties the + // two together, yet status_global_ is indexed with element ids bounded by + // nb() all over this class (resolve_status, _deactivate, fillYbus, the batch + // solvers...) with an unchecked operator[]. A pickle / binary file declaring + // a shorter (in particular empty) status_global_ therefore reads and writes + // past its end -- and check_grid() never sees it, it runs later and only + // looks at the per-side data. Demand the exact length here. + check_size(status_global_, size, "status_global"); } bool resolve_status(int el_id, bool side_1_modif, DualAlgoControl & solver_control){ diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 81d80ecf..d62b9b8a 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -58,6 +58,7 @@ add_executable(lightsim2grid_unit_tests test_ls2g_abi_tag.cpp test_plugin_registration.cpp test_check_grid.cpp + test_state_poisoning.cpp ) target_link_libraries(lightsim2grid_unit_tests PRIVATE lightsim2grid_core Catch2::Catch2WithMain) diff --git a/src/tests/test_state_poisoning.cpp b/src/tests/test_state_poisoning.cpp new file mode 100644 index 00000000..71dd9b51 --- /dev/null +++ b/src/tests/test_state_poisoning.cpp @@ -0,0 +1,300 @@ +// Copyright (c) 2026, RTE (https://www.rte-france.com) +// See AUTHORS.txt +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +// Second round of "poisoned state" tests, on the element types the first one +// (test_check_grid.cpp) could not reach with its 3-bus / 2-line / 1-load / +// 1-gen fixture: transformers with a shift-dependent r/x table, SVCs, HVDC +// lines. The grid comes from case_exotic_elements.hpp (IEEE14 + SVC + storage + +// 3 HVDC lines + a phase-shifting transformer), so every container involved is +// non-empty. +// +// Same concern as test_check_grid.cpp: release wheels are built -O3 -DNDEBUG, so +// an inconsistency restored from a well-formed-but-poisoned pickle / binary file +// turns into an out-of-bounds read or write rather than an error. Every case +// below segfaulted before the corresponding fix; they also run under +// valgrind/ASan in the cpp_unit_tests workflow, which is what proves the +// rejection happens *before* anything is indexed. + +#include +#include +#include + +#include + +#include "LSGrid.hpp" +#include "case_exotic_elements.hpp" + +using ls2g::CplxVect; +using ls2g::LSGrid; +using ls2g::RealVect; +using ls2g::cplx_type; +using ls2g::real_type; + +namespace { + +LSGrid exotic() { return ls2g_test::make_exotic_elements_grid(); } + +CplxVect flat_start(const LSGrid & grid) +{ + return CplxVect::Constant(static_cast(grid.total_bus()), cplx_type(1., 0.)); +} + +// --- accessors into the (deeply nested) StateRes tuple ----------------------- +// TRAFO_ID -> TrafoContainer::StateRes = tuple<[0] TwoSidesContainer_rxh_A::StateRes, +// ratio, is_tap_side1, shift, ignore_tap_side_for_shift, [5] shift_dependent_rx, +// base_r, base_x, [8] rx_corr_alpha, [9] rx_corr_pct> +bool& trafo_shift_dependent_rx(LSGrid::StateRes & st) +{ + return std::get<5>(std::get(st)); +} +std::vector >& trafo_rx_corr_alpha(LSGrid::StateRes & st) +{ + return std::get<8>(std::get(st)); +} +std::vector >& trafo_rx_corr_pct(LSGrid::StateRes & st) +{ + return std::get<9>(std::get(st)); +} +// SVC_ID -> SvcContainer::StateRes = tuple +std::vector& svc_regulated_bus(LSGrid::StateRes & st) +{ + return std::get<6>(std::get(st)); +} +// LINE_ID / TRAFO_ID -> [0] TwoSidesContainer_rxh_A::StateRes +// -> [0] TwoSidesContainer::StateRes = tuple +template +std::vector& branch_status_global(LSGrid::StateRes & st) +{ + return std::get<3>(std::get<0>(std::get<0>(std::get(st)))); +} +// HVDC_ID -> HvdcLineContainer::StateRes = tuple<[0] TwoSidesContainer::StateRes, ...> +std::vector& hvdc_status_global(LSGrid::StateRes & st) +{ + return std::get<3>(std::get<0>(std::get(st))); +} +// SHUNT_ID -> ShuntContainer::StateRes -> [0] OneSideContainer_PQ::StateRes +// -> [0] OneSideContainer::StateRes = tuple +bool& shunt_has_pos_topo_vect(LSGrid::StateRes & st) +{ + return std::get<5>(std::get<0>(std::get<0>(std::get(st)))); +} +std::vector& shunt_pos_topo_vect(LSGrid::StateRes & st) +{ + return std::get<6>(std::get<0>(std::get<0>(std::get(st)))); +} + +} // namespace + +TEST_CASE("the exotic-elements grid state round-trips and stays valid", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + CHECK_NOTHROW(restored.check_grid()); + const CplxVect V = restored.ac_pf(flat_start(restored), 20, 1e-7); + CHECK(V.size() == 14); +} + +// --------------------------------------------------------------------------- +// TrafoContainer: the (alpha -> r/x correction) tables +// --------------------------------------------------------------------------- +// `_update_model_coeffs()` runs at the very end of TrafoContainer::set_state -- +// long before check_grid() -- and reads rx_corr_alpha_[el_id] / rx_corr_pct_[el_id] +// for el_id in [0, nb()) with an unchecked std::vector::operator[]. A state +// declaring fewer tables than transformers therefore builds a std::vector out of +// heap memory past the end of the array and dereferences its pointers. + +TEST_CASE("set_state rejects shift-dependent r/x with no correction table at all", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(trafo_shift_dependent_rx(st)); // the fixture uses this feature + REQUIRE(trafo_rx_corr_alpha(st).size() == 3u); + trafo_rx_corr_alpha(st).clear(); + trafo_rx_corr_pct(st).clear(); + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("set_state rejects a correction table shorter than the number of trafos", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(trafo_rx_corr_alpha(st).size() == 3u); + trafo_rx_corr_alpha(st).pop_back(); // 2 tables for 3 transformers + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("set_state rejects alpha / correction tables of different lengths", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + // one alpha sample without its correction: _shift_rx_corr_pct() indexes `ys` + // with positions derived from `xs.size()` + trafo_rx_corr_alpha(st)[0] = std::vector{-0.5, 0., 0.5}; + trafo_rx_corr_pct(st)[0] = std::vector{1., 2.}; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("set_state accepts a grid with no correction table and the feature off", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + trafo_shift_dependent_rx(st) = false; + trafo_rx_corr_alpha(st).clear(); + trafo_rx_corr_pct(st).clear(); + + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + CHECK_NOTHROW(restored.check_grid()); +} + +// --------------------------------------------------------------------------- +// SvcContainer: the regulated bus id +// --------------------------------------------------------------------------- +// `regulated_bus_id_` is a gridmodel bus id used directly as an index in +// SvcContainer::set_vm (`id_grid_to_solver[...]`, then a write into V) and in +// LSGrid::fill_voltage_control_solver_data. Nothing bounded it: SvcContainer::init +// only checks the vector's length, and set_state copies it verbatim. It is the +// exact counterpart of GeneratorContainer's field of the same name, which +// check_grid() has always validated. + +TEST_CASE("check_grid rejects an out-of-range svc regulated bus id (set_state)", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(svc_regulated_bus(st).size() == 1u); + svc_regulated_bus(st)[0] = 10000000; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("check_grid rejects a negative (non-sentinel) svc regulated bus id", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + svc_regulated_bus(st)[0] = -5; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::out_of_range); +} + +TEST_CASE("check_grid rejects an out-of-range svc regulated bus set through init_svcs", "[check_grid][state_poisoning]") +{ + // same hole, reached without any serialization: a grid loader (init_from_pypowsybl + // builds the SVCs) passing a bus id its source file got wrong. + LSGrid grid = exotic(); + const std::vector mode{1}; // VOLTAGE + RealVect target_vm_pu(1), q_setpoint(1), slope(1), b_min(1), b_max(1); + target_vm_pu << 1.0; + q_setpoint << 0.; + slope << 0.; + b_min << -0.03; + b_max << 0.03; + Eigen::VectorXi regulated_bus(1), svc_bus(1); + regulated_bus << 10000000; // out of range + svc_bus << 5; + grid.init_svcs(mode, target_vm_pu, q_setpoint, slope, b_min, b_max, regulated_bus, svc_bus); + + CHECK_THROWS_AS(grid.check_grid(), std::out_of_range); +} + +TEST_CASE("check_grid accepts the -1 sentinel as an svc regulated bus", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + svc_regulated_bus(st)[0] = -1; // regulates no bus + + LSGrid restored; + REQUIRE_NOTHROW(restored.set_state(st)); + CHECK_NOTHROW(restored.check_grid()); +} + +// --------------------------------------------------------------------------- +// TwoSidesContainer: status_global_ +// --------------------------------------------------------------------------- +// `nb()` is side_1_.nb(), NOT status_global_.size(), and set_tsc_state tied the +// two together nowhere -- while status_global_[el_id] (unchecked operator[], with +// el_id bounded by nb()) is read all over the class and the batch solvers. + +TEST_CASE("set_state rejects an empty status_global on the powerlines", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(!branch_status_global(st).empty()); + branch_status_global(st).clear(); + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("set_state rejects a short status_global on the powerlines", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + branch_status_global(st).pop_back(); + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("set_state rejects a wrong-sized status_global on the trafos", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + branch_status_global(st).clear(); + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +TEST_CASE("set_state rejects a wrong-sized status_global on the hvdc lines", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + REQUIRE(!hvdc_status_global(st).empty()); + hvdc_status_global(st).push_back(true); // one too many + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// pos_topo_vect on an element that is not in the grid2op topology vector +// --------------------------------------------------------------------------- +// check_grid() proves the collected positions are a permutation of [0, K) where +// K is how many it collected. update_topo() sizes its caller arrays as +// `nb loads + nb gens + nb storages + 2 * nb lines + 2 * nb trafos` and indexes +// them with those very positions. Letting a shunt / sgen / svc / hvdc position +// into the same pot makes K bigger than that, so a *validated* position could +// still be past the end of the arrays update_topo() reads. + +TEST_CASE("check_grid rejects a pos_topo_vect on a shunt", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + LSGrid::StateRes st = grid.get_state(); + const std::size_t nb_shunt = std::get<1>(std::get<0>(std::get<0>(std::get(st)))).size(); + REQUIRE(nb_shunt > 0u); + shunt_has_pos_topo_vect(st) = true; + std::vector pos(nb_shunt); + for(std::size_t i = 0; i < nb_shunt; ++i) pos[i] = static_cast(i); + shunt_pos_topo_vect(st) = pos; + + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); +} From a13ea8c2121c8bb460b12b972c6741e21a8e04d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 19:11:34 +0000 Subject: [PATCH 166/166] Validate the grid-wide per-unit scalars (sn_mva, init_vm_pu) Follow-up audit of the Ybus / Sbus construction, looking for divisions by zero and degenerate physical parameters. No memory-safety problem was found there: every division by a physical parameter (1/(r+jx), 1/x, 1/tau, /sn_mva) is floating point, so it yields Inf/NaN rather than a signal, and none of those values is ever used as an index. 30+ degenerate-parameter cases (r/x/ratio/vn_kv = 0, negative, NaN, Inf) produced no crash and no hang. What it did find is a *silent* failure mode, in the one place the solvers' guards cannot see. sn_mva is the base power of the whole per-unit system: Sbus is divided by it, every MW/MVar result multiplied back by it, and ac_pf even scales the solver tolerance with it. It was validated nowhere -- not by set_sn_mva, not by check_grid, and the loaders take it verbatim from their source file (init_from_powermodels / init_from_matpower do set_sn_mva(float(network["baseMVA"]))). A degenerate value does not make the powerflow fail, it makes it quietly wrong: * sn_mva = NaN or Inf -> the DC powerflow reports CONVERGENCE and returns NaN branch flows. The built-in solvers' finiteness guards cannot catch it: they inspect Va, which is perfectly finite (Bbus does not involve sn_mva), and the NaN only appears afterwards when the results are scaled back to MW. The size/finiteness check at the solver->grid seam is deliberately reserved for external solvers. * sn_mva < 0 -> a plausible-looking but sign-inverted per-unit system. Both sn_mva and init_vm_pu must now be finite and strictly positive, checked by their setters and re-checked by check_grid() (which is what covers pickle, the binary format and every loader, since set_state bypasses the setters). PandaPowerConverter::_check_init had the same shape of bug for its own sn_mva / f_hz: it tested `<= 0.`, and every comparison with NaN is false, so a NaN sailed through both -- and they are divisors in every method of that class. Deliberately NOT added: value checks on r / x / h / tap ratio. An earlier version of check_grid rejected non-finite physical inputs and had to be reverted (pypowsybl encodes "no thermal limit" as a non-finite limit_a_ka; unbounded reactive limits are +/-Inf). Signs are no better an invariant -- a negative reactance is a series capacitor, and pandapower's own case300 and case9241pegase carry negative r and x. Measured across 8 pandapower cases, the 4 pypowsybl IEEE cases and the exotic-elements fixture: the only non-finite values anywhere are operational limits. A connected branch with r == x == 0 is a real degenerate case (DC reports convergence with non-finite flows) but is NOT rejected either: a lossless branch can be legitimate -- test_bus_fusion_pypowsybl asserts that an ideal 225/90 kV transformer must stay connected -- and the flag fuse_zero_impedance_branches exists to handle the ones that are not. It is documented as a known limitation in docs/security.rst instead. C++ suite 127 cases green; python suite 1226 passed / 0 failed. Signed-off-by: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019K1jRmqbpB4JUsuHHy4t9A --- CHANGELOG.rst | 19 +++++ docs/security.rst | 37 ++++++++- lightsim2grid/tests/test_state_poisoning.py | 83 +++++++++++++++++++++ src/core/DataConverter.cpp | 20 ++++- src/core/LSGrid.cpp | 32 ++++++++ src/core/LSGrid.hpp | 21 +++++- src/tests/test_state_poisoning.cpp | 60 +++++++++++++++ 7 files changed, 265 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6b9bf27f..50ccf77f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -134,6 +134,25 @@ TODO: Levenberg-Marquardt damping (a.k.a. Tikhonov-regularized Newton) : adding / ``init_from_powermodels``). A well-formed but inconsistent state (e.g. an out-of-range bus id in a crafted binary file) now raises a clean exception instead of causing an out-of-bounds access during the next powerflow. +- [FIXED] ``sn_mva`` (the base power of the whole per-unit system) and ``init_vm_pu`` + (the flat-start voltage magnitude) were validated **nowhere**: not by their python + setters, not by ``check_grid()``, and the loaders take them verbatim from their source + file (``init_from_powermodels`` / ``init_from_matpower`` do + ``set_sn_mva(float(network["baseMVA"]))``). A degenerate value does not make the + powerflow fail, it makes it *quietly wrong*: with ``sn_mva = NaN`` the **DC powerflow + reported convergence and returned NaN branch flows**, and with a negative one it + returned a plausible-looking but sign-inverted per-unit system. The built-in solvers' + own finiteness guards cannot catch this -- they check ``Va``, which is finite, since + ``Bbus`` does not involve ``sn_mva``; the NaN only appears afterwards, when the results + are scaled back to MW / MVar -- and the size/finiteness check on the solver output is + deliberately reserved for external solvers. Both scalars must now be finite and + strictly positive, checked by the setters and re-checked by ``check_grid()`` (which is + what covers pickle, the binary format and every loader, since ``set_state`` bypasses + the setters). +- [FIXED] ``PandaPowerConverter::_check_init`` tested ``sn_mva_ <= 0.`` / ``f_hz_ <= 0.``. + Every comparison with NaN is false, so a NaN sailed through both -- and they are + divisors in every method of that class, so the whole converted grid came back NaN with + no error raised anywhere. Both are now checked as finite and strictly positive. - [FIXED] ``PandaPowerConverter`` (``get_trafo_param_pp2`` / ``get_trafo_param_pp3`` / ``get_line_param`` / ``get_line_param_legacy``, all exposed to python) never checked that the arrays it is given have the same length -- there was a ``TODO check all diff --git a/docs/security.rst b/docs/security.rst index 149b4d90..445c06a0 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -88,7 +88,11 @@ additionally required to form a permutation of ``[0, dim_topo)`` over exactly th elements :py:meth:`LSGrid.update_topo` drives (loads, generators, storage units, powerlines and transformers): that is what makes them safe to index the ``dim_topo``-sized arrays that method is given, so a shunt, static generator, SVC -or hvdc line claiming a position in that vector is rejected. It also checks the *shape* of the grid +or hvdc line claiming a position in that vector is rejected. It checks the two +grid-wide per-unit scalars, ``sn_mva`` (the base power the whole grid is +normalised against) and ``init_vm_pu``, are finite and strictly positive — a +degenerate value there does not make a powerflow fail, it makes it silently +*wrong*. It also checks the *shape* of the grid itself: that the substation container's own arrays agree with each other (the bus count, the per-bus status vector and ``n_sub × nmax_busbar_per_sub`` all describe the same set of buses), and that the bus-id mapping vectors carried alongside the @@ -110,6 +114,37 @@ It is exposed so you can validate a grid you built or modified by hand. It runs in time proportional to the number of elements in the grid, so it is relatively cheap compared to a powerflow. +What it deliberately does **not** check is the *value* of the per-element +electrical parameters: a line's ``r`` / ``x``, a transformer's tap ratio, an +injection's ``p`` / ``q``. Values that look degenerate are usually legitimate — +``r = 0`` is a lossless branch, a **negative reactance is a series capacitor**, +and negative ``r`` / ``x`` also come out of three-winding-transformer star +equivalents (pandapower's own ``case300`` and ``case9241pegase`` carry them). +Non-finite values are legitimate too: pypowsybl encodes "no thermal limit" as a +non-finite ``limit_a_ka``, and unbounded generator reactive limits are +conventionally ``±Inf``. None of these can corrupt anything either — they are +never used as indices, so they cannot cause an out-of-bounds access; they flow +into ``Ybus`` / ``Bbus`` and the solvers' own guards report the solve as +**non-converged**. + +What *is* checked are the two grid-wide scalars ``sn_mva`` and ``init_vm_pu``. +They escape the safety net above: ``sn_mva`` scales results back to MW / MVar +*outside* the solver, so a bad value is invisible to the solver's guards — a NaN +there produced a DC powerflow that reported convergence and returned NaN flows. + +.. warning:: + The solver's guards are not a complete safety net for the *derived* results. + They check the bus voltages, not the branch flows computed from them, so a + grid whose model is degenerate in a way that leaves the voltages finite can + still produce a **converged** solve with non-finite flows. A connected branch + with ``r == 0`` *and* ``x == 0`` is the known case: its admittance + ``1 / (r + j.x)`` is infinite, and in DC the flows come out non-finite while + the solve reports success. Such a branch is not rejected on load, because a + lossless branch can be perfectly legitimate (an ideal step-up transformer), + and ``init_from_pypowsybl(fuse_zero_impedance_branches=True)`` exists exactly + to fuse the buses of the ones that are not. **Check that the flows you get + back are finite** if your grid may contain zero-impedance branches. + .. note:: Release wheels are compiled with ``-O3 -DNDEBUG``, which removes Eigen's and diff --git a/lightsim2grid/tests/test_state_poisoning.py b/lightsim2grid/tests/test_state_poisoning.py index 6c2293dc..6485302e 100644 --- a/lightsim2grid/tests/test_state_poisoning.py +++ b/lightsim2grid/tests/test_state_poisoning.py @@ -212,6 +212,75 @@ def test_update_topo_stays_in_bounds_with_a_full_topo_vect(self): grid.update_topo(np.zeros(dim_topo, dtype=bool), np.ones(dim_topo, dtype=np.int32)) +class TestDegenerateGridScalars(unittest.TestCase): + """``sn_mva`` is the base power of the whole per-unit system and ``init_vm_pu`` + the flat-start voltage magnitude. Neither was validated anywhere, and a + degenerate value does not make the powerflow fail -- it makes it quietly + wrong. With ``sn_mva = nan`` the DC powerflow used to report CONVERGENCE and + return NaN branch flows: the built-in solvers' own finiteness guards see a + perfectly finite ``Va`` (Bbus does not involve sn_mva), the NaN only appears + later when the results are scaled back to MW, and the size/finiteness check on + the solver output is deliberately reserved for external solvers.""" + + BAD = (0.0, -100.0, float("nan"), float("inf")) + + def test_set_sn_mva_rejects_degenerate_values(self): + grid = build_exotic_elements_case_grid() + for v in self.BAD: + with self.subTest(sn_mva=v), self.assertRaises(RuntimeError): + grid.set_sn_mva(v) + + def test_set_init_vm_pu_rejects_degenerate_values(self): + grid = build_exotic_elements_case_grid() + for v in self.BAD: + with self.subTest(init_vm_pu=v), self.assertRaises(RuntimeError): + grid.set_init_vm_pu(v) + + def test_check_grid_rejects_degenerate_sn_mva_from_a_state(self): + # set_state assigns sn_mva straight from the serialized state, bypassing + # the setter: check_grid() is the backstop for pickle / the binary format + state = build_exotic_elements_case_grid().__getstate__() + for v in self.BAD: + with self.subTest(sn_mva=v), self.assertRaises(RuntimeError): + _rebuild(_set_at(state, (3, 5), v)) + + def test_check_grid_rejects_degenerate_init_vm_pu_from_a_state(self): + state = build_exotic_elements_case_grid().__getstate__() + for v in self.BAD: + with self.subTest(init_vm_pu=v), self.assertRaises(RuntimeError): + _rebuild(_set_at(state, (3, 4), v)) + + def test_good_scalars_still_accepted(self): + grid = build_exotic_elements_case_grid() + grid.set_sn_mva(100.0) + grid.set_init_vm_pu(1.04) + self.assertIsNone(grid.check_grid()) + v0 = np.ones(grid.total_bus(), dtype=complex) + self.assertNotEqual(len(grid.ac_pf(v0, 20, 1e-7)), 0) + + def test_powermodels_loader_rejects_a_degenerate_baseMVA(self): + # init_from_matpower routes through this loader, which does + # `set_sn_mva(float(network["baseMVA"]))` straight from the source file + import copy + import json + import os + try: + from lightsim2grid.network import init_from_powermodels + except ImportError: + self.skipTest("powermodels loader not available") + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "pf_delta_case14.json") + if not os.path.exists(path): + self.skipTest("pf_delta_case14.json fixture not available") + with open(path) as f: + network = json.load(f)["network"] + for v in (0.0, -100.0, float("nan")): + bad = copy.deepcopy(network) + bad["baseMVA"] = v + with self.subTest(baseMVA=v), self.assertRaises(RuntimeError): + init_from_powermodels(bad) + + class TestPandaPowerConverterInputSizes(unittest.TestCase): """``PandaPowerConverter`` walks its inputs together element by element with unchecked accessors, and combines them in coefficient-wise Eigen expressions @@ -239,6 +308,20 @@ def test_get_trafo_param_pp3_rejects_mismatched_sizes(self): with self.assertRaises(RuntimeError): self.conv.get_trafo_param_pp3(*self._trafo_args(self.long), True) + def test_check_init_rejects_a_nan_sn_mva_or_f_hz(self): + # the guard was `sn_mva_ <= 0.`, and every comparison with NaN is false, so a + # NaN went straight through -- and it is a divisor in every method here + conv = PandaPowerConverter() + conv.set_sn_mva(float("nan")) + conv.set_f_hz(50.0) + with self.assertRaises(RuntimeError): + conv.get_line_param(self.one, self.one, self.one, self.one, self.one, self.one) + conv2 = PandaPowerConverter() + conv2.set_sn_mva(100.0) + conv2.set_f_hz(float("nan")) + with self.assertRaises(RuntimeError): + conv2.get_line_param(self.one, self.one, self.one, self.one, self.one, self.one) + def test_get_trafo_param_rejects_a_short_bool_vector(self): args = (self.one, self.one, self.one, [], # is_tap_hv_side empty self.one, self.one, self.one, self.one, self.one, self.one, self.one) diff --git a/src/core/DataConverter.cpp b/src/core/DataConverter.cpp index 7c350e94..82d70758 100644 --- a/src/core/DataConverter.cpp +++ b/src/core/DataConverter.cpp @@ -8,6 +8,7 @@ #include "DataConverter.hpp" +#include // std::isfinite (_check_init) #include #include #include @@ -75,11 +76,22 @@ void PandaPowerConverter::_check_line_inputs(const std::string & fun_name, } void PandaPowerConverter::_check_init() const { - if(sn_mva_ <= 0.){ - throw std::runtime_error("PandaPowerConverter::_check_init: sn_mva has not been initialized"); + // `!(x > 0.)` rather than `x <= 0.`: every comparison with NaN is false, so the + // naive form let a NaN sn_mva / f_hz straight through -- and both are divisors + // here (`vk_percent / 100. / sn_trafo_mva * tap_lv`, `2 * f_hz * pi * c * baseR`), + // so the whole converted grid came back NaN with no error anywhere. + if(!(sn_mva_ > 0.) || !std::isfinite(sn_mva_)){ + std::ostringstream exc_; + exc_ << "PandaPowerConverter::_check_init: sn_mva is " << sn_mva_ + << "; it must be a finite, strictly positive number (it is the base power of the " + "per-unit system this converter produces)."; + throw std::runtime_error(exc_.str()); } - if(f_hz_ <= 0.){ - throw std::runtime_error("PandaPowerConverter::_check_init: f_hz has not been initialized"); + if(!(f_hz_ > 0.) || !std::isfinite(f_hz_)){ + std::ostringstream exc_; + exc_ << "PandaPowerConverter::_check_init: f_hz is " << f_hz_ + << "; it must be a finite, strictly positive number."; + throw std::runtime_error(exc_.str()); } } diff --git a/src/core/LSGrid.cpp b/src/core/LSGrid.cpp index 79ea5046..ba6875f9 100644 --- a/src/core/LSGrid.cpp +++ b/src/core/LSGrid.cpp @@ -10,7 +10,10 @@ #include "AlgorithmSelector.hpp" // to avoid circular references #include "BinaryArchive.hpp" +#include // std::isfinite (check_positive_finite) #include +#include +#include namespace ls2g { @@ -328,6 +331,23 @@ void LSGrid::_restore_algorithm(AlgorithmSelector & algo_selector, void LSGrid::check_grid() const { + // The two grid-wide scalars, before anything else. `set_state` assigns them + // straight from the serialized state (bypassing the setters), and the loaders take + // them verbatim from their source file -- `init_from_powermodels` / + // `init_from_matpower` do `set_sn_mva(float(network["baseMVA"]))`, and + // `init_from_pandapower` `set_sn_mva(pp_net.sn_mva)`. + // + // sn_mva_ is the base power of the whole per-unit system: Sbus is divided by it, + // every MW / MVar result multiplied back by it, and ac_pf even scales the solver + // tolerance with it. A degenerate value does not make the powerflow fail, it makes + // it *quietly wrong*: with sn_mva_ = NaN the DC powerflow reports CONVERGENCE and + // hands back NaN flows (the built-in solvers' own finiteness guards see a perfectly + // finite Va -- Bbus does not involve sn_mva -- and the size/finiteness check on the + // solver output is deliberately reserved for external solvers), and with a negative + // one it reports a plausible-looking but sign-inverted per-unit system. + check_positive_finite(sn_mva_, "sn_mva"); + check_positive_finite(init_vm_pu_, "init_vm_pu"); + // The substation container FIRST: it defines nb_bus / nb_sub, the bounds every // per-element check below is expressed against, and it carries the vector // (bus_status_) those very ids are used to index. Validating elements against a @@ -500,6 +520,18 @@ void LSGrid::set_ls_to_orig(const Eigen::Ref & ls_to_orig){ // allocation for a handful of buses). Both are rejected here, before any allocation. // This runs on the python property setter AND on set_state(), ie on every pickle and // every binary file. +void LSGrid::check_positive_finite(real_type value, const char * name) +{ + // NB `!(value > 0.)`, not `value <= 0.`: every comparison with NaN is false, so the + // naive form silently accepts a NaN -- which is exactly the value that does the most + // damage here (see check_grid()). + if(std::isfinite(value) && (value > 0.)) return; + std::ostringstream exc_; + exc_ << "LSGrid: '" << name << "' is " << value + << "; it must be a finite, strictly positive number."; + throw std::runtime_error(exc_.str()); +} + void LSGrid::check_ls_to_orig_values(const Eigen::Ref & ls_to_orig) { // an original grid can never have more buses than the biggest vector we could diff --git a/src/core/LSGrid.hpp b/src/core/LSGrid.hpp index 930d6c0d..d5314690 100644 --- a/src/core/LSGrid.hpp +++ b/src/core/LSGrid.hpp @@ -309,11 +309,28 @@ class LS2G_API LSGrid final // All methods to init this data model, all need to be pair unit when applicable void init_bus(unsigned int n_sub, unsigned int n_busbar_per_sub, const Eigen::Ref & bus_vn_kv, int nb_line, int nb_trafo); - void set_init_vm_pu(real_type init_vm_pu) {init_vm_pu_ = init_vm_pu; } + // Both scalars below must be finite and strictly positive: they are physical + // per-unit quantities, and a degenerate value does not fail loudly, it produces + // a *confidently wrong* answer. `sn_mva_` is the base power of the entire + // per-unit system (Sbus is divided by it, every MW/MVar result multiplied back + // by it, and even the solver tolerance is scaled by it in ac_pf), and + // `init_vm_pu_` is the flat-start voltage magnitude. See check_positive_finite. + void set_init_vm_pu(real_type init_vm_pu) { + check_positive_finite(init_vm_pu, "init_vm_pu"); + init_vm_pu_ = init_vm_pu; + } [[nodiscard]] real_type get_init_vm_pu() const {return init_vm_pu_;} - void set_sn_mva(real_type sn_mva) {sn_mva_ = sn_mva; } + void set_sn_mva(real_type sn_mva) { + check_positive_finite(sn_mva, "sn_mva"); + sn_mva_ = sn_mva; + } [[nodiscard]] real_type get_sn_mva() const {return sn_mva_;} + // Throws std::runtime_error unless `value` is finite and > 0. Written as + // `!(value > 0.)` on purpose: `value <= 0.` is FALSE for NaN, so the naive + // form lets a NaN straight through. + static void check_positive_finite(real_type value, const char * name); + void init_powerlines(const Eigen::Ref & branch_r, const Eigen::Ref & branch_x, const Eigen::Ref & branch_h, diff --git a/src/tests/test_state_poisoning.cpp b/src/tests/test_state_poisoning.cpp index 71dd9b51..fceef08e 100644 --- a/src/tests/test_state_poisoning.cpp +++ b/src/tests/test_state_poisoning.cpp @@ -20,6 +20,8 @@ // valgrind/ASan in the cpp_unit_tests workflow, which is what proves the // rejection happens *before* anything is indexed. +#include +#include #include #include #include @@ -274,6 +276,64 @@ TEST_CASE("set_state rejects a wrong-sized status_global on the hvdc lines", "[c CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); } +// --------------------------------------------------------------------------- +// the grid-wide per-unit scalars +// --------------------------------------------------------------------------- +// sn_mva_ scales the whole per-unit system (Sbus is divided by it, results are +// multiplied back by it, ac_pf even scales the solver tolerance with it) and +// init_vm_pu_ is the flat-start voltage magnitude. Neither was validated +// anywhere. A degenerate value does not fail loudly: with sn_mva_ = NaN the DC +// powerflow reported CONVERGENCE and returned NaN flows, because the built-in +// solvers' finiteness guards only see Va (which Bbus, and so sn_mva, never +// touches) and the finiteness check on the solver output is reserved for +// external solvers. + +TEST_CASE("check_grid rejects a non-positive or non-finite sn_mva", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + const std::vector bad = {0., + -100., + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}; + for(const real_type v : bad) + { + LSGrid::StateRes st = grid.get_state(); + std::get(st) = v; + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); + } +} + +TEST_CASE("check_grid rejects a non-positive or non-finite init_vm_pu", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + const std::vector bad = {0., + -1., + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}; + for(const real_type v : bad) + { + LSGrid::StateRes st = grid.get_state(); + std::get(st) = v; + LSGrid restored; + CHECK_THROWS_AS(restored.set_state(st), std::runtime_error); + } +} + +TEST_CASE("the sn_mva / init_vm_pu setters reject degenerate values", "[check_grid][state_poisoning]") +{ + LSGrid grid = exotic(); + CHECK_THROWS_AS(grid.set_sn_mva(0.), std::runtime_error); + CHECK_THROWS_AS(grid.set_sn_mva(-100.), std::runtime_error); + CHECK_THROWS_AS(grid.set_sn_mva(std::numeric_limits::quiet_NaN()), std::runtime_error); + CHECK_THROWS_AS(grid.set_init_vm_pu(std::numeric_limits::infinity()), std::runtime_error); + // and a sane value still goes through, leaving a usable grid + REQUIRE_NOTHROW(grid.set_sn_mva(100.)); + REQUIRE_NOTHROW(grid.set_init_vm_pu(1.04)); + CHECK_NOTHROW(grid.check_grid()); + CHECK(grid.ac_pf(flat_start(grid), 20, 1e-7).size() == 14); +} + // --------------------------------------------------------------------------- // pos_topo_vect on an element that is not in the grid2op topology vector // ---------------------------------------------------------------------------