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