Comparison with pypowsybl default load-flow

In this section of the documentation we attempt to compare lightsim2grid and the default implementation of pypowsybl (which is OLF - Open Load Flow)

All the tests were conducted on the same laptop and on publically available grid:

  • ieee 9 bus

  • ieee 14 bus

  • ieee 30 bus

  • ieee 57 bus

  • ieee 118 bus

  • ieee 300 bus

In all cases, the lightsim2grid LSGrid (lightsim2grid internal representation of a powergrid) were initialized from the pypowsybl grid.

Disclaimer

Compared to pypowsybl, lightsim2grid has only a very (very) limited number of possible behaviour.

Pypowsybl is likely to be more accurate (if parametrized properly) for industrial grid as it can emulate the behaviour of much more elements in much more detail than lightsim2grid.

For example, lighsim2grid does not look at the “reactive power limits” of generators, pypowsybl (open load-flow) can. LightSim2Grid does not change the tap ratio of any transformers during computation, pypowsybl is perfectly able to do that, when the slack bus is distributed in lightsim2grid, lightsim2grid does not check whether or not the generators can produce / absorb the the active power they are supposed to, pypowsybl is able to dynamically meet this criteria etc. etc.

Also, we want to note that the comparison here will limit to computations that both lightsim2grid and pypowsybl are able to perform. At time of writing, pypowsybl could do many more things than lightsim2grid.

Important

The overall message of this page is not to show that lightsim2grid should be prefered to pypowsybl.

Its goal is rather to explain how to get consistent results between pypowsybl and lightsim2grid.

Why this comparison isn’t trivial: OLF’s “outer loops”

All the bullet points in the disclaimer above (reactive limits, tap ratio, distributed slack, …) are really different faces of a single architectural difference between the two engines, and it is the reason a naive comparison disagrees and why the “baking” machinery below exists at all.

OpenLoadFlow structures a powerflow as an inner Newton-Raphson solve wrapped in outer loops: solve, look at the result, adjust some input if a criterion is not met (a generator exceeds a reactive limit -> switch it from PV to fixed-Q; a tap changer is not at the position that would satisfy its regulation -> move it a step; the slack mismatch is not shared the way the model prescribes -> redistribute it; area interchange / secondary voltage control targets are not met -> adjust), then solve again – repeating until every outer loop is satisfied or a round limit is hit.

lightsim2grid does not have this. Its algorithms (Newton-Raphson, Fast-Decoupled, Gauss-Seidel, see Available powerflow algorithms) solve a single, fixed problem: the topology, injections, tap positions and voltage-control targets you hand it are exactly what gets solved, once, with nothing adjusted in response to the result. There is no generic outer-loop mechanism in the lightsim2grid core.

This is not simply “OLF has more features, lightsim2grid has fewer” – the two architectures make a different trade-off, and where a given feature ends up depends on that trade-off:

  • Distributed slack is the one outer loop lightsim2grid folds into the inner solve instead of dropping. MultiSlackNRSystem adds the slack-participation unknowns and their weighting directly into the same Newton-Raphson Jacobian as the rest of the problem (see src/core/powerflow_algorithm/NRSystem.hpp), instead of an OLF-style outer loop that re-solves a single-slack problem and redistributes the mismatch between rounds. One solve, one Jacobian, no outer iteration – faster, at the cost of a larger and more coupled linear system.

  • Reactive-limit PV<->PQ switching, discrete tap / shunt control, area interchange and secondary voltage control are not implemented at all, in either form (no outer loop, and no in-Jacobian equivalent). This is precisely the gap bake_outer_loops() papers over for the sake of comparison: it does not give lightsim2grid these capabilities, it just freezes OLF’s already-converged answer for them into fixed inputs, so that what is left is a problem lightsim2grid can solve exactly.

Whether a given outer loop could instead be folded into the inner NR formulation (like distributed slack was) or fundamentally needs iteration around the solve (like discrete tap positions, which are not differentiable) is a case-by-case question, and remains open for most of OLF’s outer loops as far as lightsim2grid is concerned.

See also

examples/dist_slack_algorithm/ is a solver plugin (see External Algorithm Plugins) that reimplements distributed slack the other way: as an explicit outer loop around a single-slack inner Newton-Raphson solve, mirroring OLF’s own DistributedSlackOuterLoop architecture, instead of lightsim2grid’s default in-Jacobian MultiSlackNRSystem extension. It exists to demonstrate (and test) that lightsim2grid’s plugin mechanism can express an OLF-style outer loop at all, not to replace the built-in distributed slack – see the comment at the top of examples/dist_slack_algorithm/NRAlgoDistSlack.hpp for the full rationale (in short: it was used to investigate a step-damping / convergence interaction specific to the in-Jacobian approach).

Methodology

The results will show the difference between pypowsybl and lightsim2grid when running the same simulation (an AC powerflow) on the same grid.

It will expose:

  • the parameters used to initialize the lightsim2grid LSGrid

  • the parameters used to run the powerflow computation with pypowsybl

  • the time it takes to perform these powerflows in different settings

  • the mismatch of the voltage angle (in radian) and the voltage angle (in per unit) at each bus of the grid (average and max value)

Reproduce the results

You can run the example by running the script:

cd benchmarks
python compare_lightsim2grid_pypowsybl.py --case $CASE_NAME

For example:

cd benchmarks
python compare_lightsim2grid_pypowsybl.py --case ieee9

Load-flow parameters

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:

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 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 – see remove_outer_loops(), which it wraps.

If lightsim2grid’s own distributed-slack implementation is what you want to compare against (rather than a single, fixed slack bus), keep OLF’s DistributedSlack outer loop active and remove every other one with get_pypowsybl_loopfree_distributed_slack_parameters() instead – same idea, but it sets balance_type=PROPORTIONAL_TO_GENERATION_P_MAX, what lightsim2grid’s default distributed slack reproduces.

Important

As you notice from these parameters, a lot of the simulation capacity of pypowsybl are switched off when using lightsim2grid.

Baking outer-loop results into the network

Removing the outer loops from the OLF parameters (above) only helps if the network itself does not need them: it is fine for a network you build already at its final operating point, but if you start from a network whose outer loops would actually do something (a generator that should hit a reactive limit, a tap changer that should move, a distributed slack that should spread over several units, …), solving it loop-free in OLF and in lightsim2grid no longer agree – not because the solvers differ, but because they are solving different problems.

bake_outer_loops closes that gap: run OLF with its outer loops enabled first, then call it to rewrite the network’s input setpoints (tap positions, generator P/Q, slack participation, …) to the values the outer loops converged on, and disable the corresponding regulation. Afterwards the network represents a plain power-flow problem, and a loop-free OLF run or a lightsim2grid run (via init_from_pypowsybl()) should reproduce the same operating point:

import pypowsybl as pp
from lightsim2grid.network import bake_outer_loops, init_from_pypowsybl

network = pp.network.create_ieee14()
pp.loadflow.run_ac(network)  # solve once, with outer loops enabled
bake_outer_loops(network)    # freeze the converged outer-loop state into the inputs

ls_grid = init_from_pypowsybl(network)  # now a plain (loop-free) power-flow problem
lightsim2grid.network.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_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)[source]

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 get_pypowsybl_loopfree_parameters()) or a lightsim2grid run (via 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_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 _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 _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 _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).

  • 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 component are updated (True by default)

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.

Note

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:

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 lightsim2grid.network.ComparisonResult summarising the largest voltage-magnitude and voltage-angle mismatches (plus a per-bus table):

class lightsim2grid.network.ComparisonResult(max_dvm_pu: float, max_dva_deg: float, max_dva_deg_offset_removed: float, table: DataFrame)[source]

Outcome of compare_baked(): how far lightsim2grid and OLF disagree.

max_dvm_pu

Largest absolute voltage-magnitude difference, in per unit, over every bus common to both engines.

Type:

float

max_dva_deg

Largest absolute voltage-angle difference, in degrees (raw).

Type:

float

max_dva_deg_offset_removed

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.

Type:

float

table

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).

Type:

pandas.DataFrame

lightsim2grid.network.compare_baked(network_factory, slack_gen_id: str, line_outages=None, trafo_outages=None, olf_loop_params: Parameters | None = None)[source]

Bake, optionally apply outages, solve in both engines, and compare.

Parameters:
  • network_factory (callable) – Returns 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 (list of str, optional) – IIDM ids to disconnect identically in both engines after baking.

  • 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.

Return type:

ComparisonResult

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 pypowsybl.network.Network, use 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):

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"])
class lightsim2grid.network.LightsimResultNetwork(ls_grid: LSGrid, net: Network)[source]

pypowsybl-Network-shaped view of a solved lightsim2grid LSGrid.

Parameters:
  • ls_grid – a grid built by init_from_pypowsybl(net, ...) and already solved (ac_pf/dc_pf converged).

  • 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.

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 initLSGrid.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 get_hvdc_lines().

  • every DataFrame’s index (id) mirrors the element’s pypowsybl id by 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.

get_2_windings_transformers(attributes: List[str] | None = None) DataFrame[source]

Same columns as get_lines() (this class does not expose tap position / ratio / phase-shift columns).

get_batteries(attributes: List[str] | None = None) DataFrame[source]

See the one-sided column list above get_generators(). No sign flip, same as get_loads().

get_buses(attributes: List[str] | None = None) DataFrame[source]

Columns: v_mag (kV, solved result), v_angle (degree, solved result, offset-aligned to net’s own angle datum – see _build_buses()), voltage_level_id (topology, from net).

get_generators(attributes: List[str] | None = None) DataFrame[source]

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).

get_hvdc_lines(attributes: List[str] | None = None) DataFrame[source]

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).

get_lcc_converter_stations(attributes: List[str] | None = None) DataFrame[source]

Same columns as get_vsc_converter_stations() (this class does not expose power_factor).

get_lines(attributes: List[str] | None = None) DataFrame[source]

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 _reconstruct_fused_branches() for how a fused (near-zero-impedance) line’s flow is recovered where possible, instead of reporting 0.

get_loads(attributes: List[str] | None = None) DataFrame[source]

See the one-sided column list above get_generators(). p/q already match pypowsybl’s convention, no sign flip (see the module docstring).

get_shunt_compensators(attributes: List[str] | None = None) DataFrame[source]

See the one-sided column list above get_generators(). No sign flip, same as get_loads(). Does not expose section count / susceptance columns.

get_static_var_compensators(attributes: List[str] | None = None) DataFrame[source]

See the one-sided column list above 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.

get_vsc_converter_stations(attributes: List[str] | None = None) DataFrame[source]

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.

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

The benchmarks were run on:

  • date: 2026-08-28 16:56 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.3.5

  • 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

  • lightsim2grid extra information:

    • klu_solver_available: True

    • nicslu_solver_available: True

    • cktso_solver_available: True

    • compiled_march_native: True

    • compiled_o3_optim: True

The results were obtained by launching:

python compare_lightsim2grid_pypowsybl.py

By default this runs all 6 cases above in a single process and prints, at the end, the 5 tables below together with the descriptive text that comments on them – computed directly from the numbers measured during that run – ready to copy / paste here. There is no need anymore to run the script once per case and manually place each number in the right table / cell. Pass one or more --case_name (eg --case_name ieee9 ieee14) to only benchmark a subset of the cases.

Precision of lightsim2grid

On average (across all buses) the errors were:

case name

angle (rad)

magnitude (pu)

ieee9

1.82e-08

1.15e-08

ieee14

9.7e-10

1.27e-09

ieee30

1.58e-09

3.55e-09

ieee57

1.63e-07

2.71e-07

ieee118

1.06e-07

3.15e-09

ieee300

9.45e-05

5.71e-05

Maximum error, for all buses:

case name

angle (rad)

magnitude (pu)

ieee9

3.35e-08

2.65e-08

ieee14

2.35e-09

2.92e-09

ieee30

3.23e-09

7.96e-09

ieee57

9.54e-07

1.2e-06

ieee118

2.54e-07

6.92e-08

ieee300

0.000511

0.0018

As we can notice in the tables above, the results match up to the solver precision (set to 1e-06 for lightsim2grid): the largest observed mismatch, across all cases, is 5.11e-04 rad for the voltage angle

and 1.80e-03 pu for the voltage magnitude.

On these grids, lightsim2grid and pypowsybl give the same exact results.

Computation times (1 powerflow)

In this part, we report the time to compute the initial powerflow, right after the initialization of the grid for both lightsim2grid and pypowsybl.

The timings reported here are measured from python using “time.perf_counter()” before and after the computation are performed.

Only the time to perform the powerflow is measured. In particular, the time to read back the data is excluded.

Times are expressed in ms.

case name

lightsim2grid

pypowsybl

ieee9

0.113

6.49

ieee14

0.0807

1.57

ieee30

0.157

1.68

ieee57

0.182

2.2

ieee118

0.289

2.72

ieee300

0.819

5.19

For this initial computation, lightsim2grid is between 6 and 58 times faster than pypowsybl.

Warning

This is not fair for pypowsybl.

Pypowsybl is not optimized only for speed and can simulate much more complex grids with an higher fidelity, which is not reported here.

Computation times (100 powerflows)

In this section, we compare the capacity of lightsim2grid and pypowsybl to perform successive powerflow computation when only the loads are modified.

This comparison is done when using “raw” lightsim2grid / pypowsybl code, without trying to achieve the “best performance”. Some performance gain could be achieved with different optimizations, for example by recycling previous results (avoiding to allocate memory, preventing copy, re use of some matrix strucure, taking advantage of the linear solver and avoid costly call when performing some factorization etc.)

The results in the table bellow are given in ms and report the average time it took to perform the 100 powerflows.

case name

lightsim2grid

pypowsybl

ieee9

0.00864

1.14

ieee14

0.0107

1.13

ieee30

0.0202

1.66

ieee57

0.037

1.82

ieee118

0.0673

2.57

ieee300

0.291

5.04

For successive powerflows, lightsim2grid is between 17 and 131 times faster than pypowsybl.

Computation times security analysis

In this setting, we compare the time it takes to run a “contingency analysis” by simulating, in turn, the disconnection of every lines or transformer on the grid.

The table here is obtained by using contingencyAnalysis module of lightsim2grid and the pypowsybl.security module from pypowsybl.

The table below provides the average time it takes to simulate the effect of 1 contingency in ms. We don’t measure the time taken to compute the flows from the resulting voltages.

case name

lightsim2grid

pypowsybl

ieee9

0.0144

0.893

ieee14

0.0109

0.193

ieee30

0.0163

0.181

ieee57

0.0316

0.186

ieee118

0.048

0.301

ieee300

0.17

1.22

For the contingency analysis, lightsim2grid is between 6 and 62 times faster than pypowsybl.