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.
MultiSlackNRSystemadds the slack-participation unknowns and their weighting directly into the same Newton-Raphson Jacobian as the rest of the problem (seesrc/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
networkinput 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 (viainit_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 bybake_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 (
activePowerControlextensionparticipate=False) slack-distribution participation for generators OLF’s owncheckActivePowerControlwould exclude (see_bake_active_power_control_participation()). Also gated bybake_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:
- max_dva_deg
Largest absolute voltage-angle difference, in degrees (raw).
- Type:
- max_dva_deg_offset_removed
Same as
max_dva_degbut 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:
- 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:
- 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:
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 lightsim2gridLSGrid.- Parameters:
ls_grid – a grid built by
init_from_pypowsybl(net, ...)and already solved (ac_pf/dc_pfconverged).net – the same pypowsybl network passed to that
init()call.
Every
get_*method mirrors itspypo.network.Networknamesake: it accepts an optionalattributeslist 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 theLSGriditself): dangling lines – noget_dangling_linesmethod, including when the grid was built withinit_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 solvedLSGrid– this specific powerflow’s outcome, not the originalnet’s.topology / metadata columns (
bus_id/bus1_id/bus2_id,connected/connected1/connected2,voltage_level_id/voltage_level1_id/voltage_level2_id,is_lcc) reflectLSGrid’s current state, which mirrorsnetonly as long as nothing changed the grid (topology, connectivity, …) afterinit_from_pypowsyblbuilt it – they are not re-read fromneton every call.a handful of columns are read verbatim from the original
netand frozen at construction time, never fromLSGrid: currently onlyconverter_station1_id/converter_station2_idonget_hvdc_lines().every DataFrame’s index (
id) mirrors the element’s pypowsybl id by construction (initLSGrid.init sets every non-bus element’s lightsim2gridnameverbatim to it, see the module docstring), even though it is technically sourced fromLSGrid, not read fromnet.
- 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 asget_loads().
- get_buses(attributes: List[str] | None = None) DataFrame[source]
Columns:
v_mag(kV, solved result),v_angle(degree, solved result, offset-aligned tonet’s own angle datum – see_build_buses()),voltage_level_id(topology, fromnet).
- get_generators(attributes: List[str] | None = None) DataFrame[source]
See the one-sided column list above
get_generators.p/quse 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 currentLSGridstate),converter_station1_id/converter_station2_id(the only columns in this whole class read verbatim from the originalnetand 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 exposepower_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 currentLSGridstate, 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/qalready 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 asget_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 currentLSGridstate, see the class docstring). Does not exposetarget_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.