From cf9ec821cda200d2955cf244329b0542b8ee8d2a Mon Sep 17 00:00:00 2001 From: "AzureAD\\PanagiotisXenos" Date: Thu, 19 Dec 2024 12:54:05 +0200 Subject: [PATCH 01/15] update check for tap phase shifter for pandapower v3.0 Signed-off-by: AzureAD\PanagiotisXenos --- .../gridmodel/from_pandapower/_aux_add_trafo.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py b/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py index 1ed2d46..56dd4ff 100644 --- a/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py +++ b/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py @@ -61,9 +61,15 @@ def _aux_add_trafo(converter, model, pp_net, pp_to_ls): warnings.warn("There were some Nan in the pp_net.trafo[\"tap_side\"], they have been replaced by \"hv\"") is_tap_hv_side[~np.isfinite(is_tap_hv_side)] = True - if np.any(pp_net.trafo["tap_phase_shifter"].values): - raise RuntimeError("ideal phase shifter are not modeled. Please remove all trafo with " - "pp_net.trafo[\"tap_phase_shifter\"] set to True.") + if int(pp.__version__.split(".")[0]) < 3: + if np.any(pp_net.trafo["tap_phase_shifter"].values): + raise RuntimeError("Ideal phase shifters are not modeled. Please remove all trafos with " + "pp_net.trafo[\"tap_phase_shifter\"] set to True.") + else: + if np.any(pp_net.trafo["tap_changer_type"].values == "Ideal") or \ + np.any(pp_net.trafo3w["tap_changer_type"].values == "Ideal"): + raise RuntimeError("Ideal phase shifters are not modeled. Please remove all 2-winding or 3-winding trafos " + "with \"tap_changer_type\" set to \"Ideal\".") tap_angles_ = 1.0 * pp_net.trafo["tap_step_degree"].values if np.any(~np.isfinite(tap_angles_)): From 94f5e1679cc067dd84a85ca86a0c756b946e7a1a Mon Sep 17 00:00:00 2001 From: "AzureAD\\PanagiotisXenos" Date: Thu, 19 Dec 2024 13:08:16 +0200 Subject: [PATCH 02/15] update check for tap phase shifter for pandapower v3.0 Signed-off-by: AzureAD\PanagiotisXenos --- .../gridmodel/from_pandapower/_aux_add_trafo.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py b/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py index 56dd4ff..19da77b 100644 --- a/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py +++ b/lightsim2grid/gridmodel/from_pandapower/_aux_add_trafo.py @@ -61,14 +61,17 @@ def _aux_add_trafo(converter, model, pp_net, pp_to_ls): warnings.warn("There were some Nan in the pp_net.trafo[\"tap_side\"], they have been replaced by \"hv\"") is_tap_hv_side[~np.isfinite(is_tap_hv_side)] = True - if int(pp.__version__.split(".")[0]) < 3: + if "tap_phase_shifter" in pp_net.trafo: if np.any(pp_net.trafo["tap_phase_shifter"].values): raise RuntimeError("Ideal phase shifters are not modeled. Please remove all trafos with " "pp_net.trafo[\"tap_phase_shifter\"] set to True.") - else: - if np.any(pp_net.trafo["tap_changer_type"].values == "Ideal") or \ - np.any(pp_net.trafo3w["tap_changer_type"].values == "Ideal"): - raise RuntimeError("Ideal phase shifters are not modeled. Please remove all 2-winding or 3-winding trafos " + elif "tap_changer_type" in pp_net.trafo: + if np.any(pp_net.trafo["tap_changer_type"].values == "Ideal"): + raise RuntimeError("Ideal phase shifters are not modeled. Please remove all 2-winding trafos " + "with \"tap_changer_type\" set to \"Ideal\".") + elif "tap_changer_type" in pp_net.trafo3w: + if np.any(pp_net.trafo3w["tap_changer_type"].values == "Ideal"): + raise RuntimeError("Ideal phase shifters are not modeled. Please remove all 3-winding trafos " "with \"tap_changer_type\" set to \"Ideal\".") tap_angles_ = 1.0 * pp_net.trafo["tap_step_degree"].values From 729670bd157d22117f92a43235af415bbbf7a434 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Sun, 22 Dec 2024 20:51:07 +0100 Subject: [PATCH 03/15] update the text and table to be consistent with one another [skip ci] Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_solvers.py | 24 +++++ docs/benchmarks.rst | 172 +++++++++++++++++++------------- 2 files changed, 124 insertions(+), 72 deletions(-) diff --git a/benchmarks/benchmark_solvers.py b/benchmarks/benchmark_solvers.py index 1c96cf3..e8608cc 100644 --- a/benchmarks/benchmark_solvers.py +++ b/benchmarks/benchmark_solvers.py @@ -11,6 +11,7 @@ import warnings import pandas as pd from grid2op import make +from grid2op.Backend import PandaPowerBackend from grid2op.Agent import DoNothingAgent from grid2op.Chronics import ChangeNothing import re @@ -105,7 +106,14 @@ def main(max_ts, if re.match("^.*\\.json$", env_name_input) is None: # i provided an environment name env_pp = make(env_name_input, param=param, test=test, + backend=PandaPowerBackend(lightsim2grid=False, with_numba=True), data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + env_pp_no_numba = make(env_name_input, param=param, test=test, + backend=PandaPowerBackend(lightsim2grid=False, with_numba=False), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + env_pp_ls_numba = make(env_name_input, param=param, test=test, + backend=PandaPowerBackend(lightsim2grid=True, with_numba=True), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) env_lightsim = make(env_name_input, backend=LightSimBackend(), param=param, test=test, data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) else: @@ -126,6 +134,16 @@ def main(max_ts, nb_ts_pp, time_pp, aor_pp, gen_p_pp, gen_q_pp = run_env(env_pp, max_ts, agent, chron_id=0, env_seed=0) pp_comp_time = env_pp.backend.comp_time pp_time_pf = env_pp._time_powerflow + + tmp_no_numba = run_env(env_pp_no_numba, max_ts, agent, chron_id=0, env_seed=0) + nb_ts_pp_no_numba, time_pp_no_numba, aor_pp_no_numba, gen_p_pp_no_numba, gen_q_pp_no_numba = tmp_no_numba + pp_no_numba_comp_time = env_pp_no_numba.backend.comp_time + pp_no_numba_time_pf = env_pp_no_numba._time_powerflow + + tmp_ls_numba = run_env(env_pp_ls_numba, max_ts, agent, chron_id=0, env_seed=0) + nb_ts_pp_ls_numba, time_pp_ls_numba, aor_pp_ls_numba, gen_p_ls_numba, gen_q_ls_numba = tmp_ls_numba + pp_ls_numba_comp_time = env_pp_ls_numba.backend.comp_time + pp_ls_numba_time_pf = env_pp_ls_numba._time_powerflow wst = True # print extra info in the run_env function solver_types = env_lightsim.backend.available_solvers @@ -174,6 +192,12 @@ def main(max_ts, tab.append(["PP", f"{nb_ts_pp/time_pp:.2e}", f"{1000.*pp_time_pf/nb_ts_pp:.2e}", f"{1000.*pp_comp_time/nb_ts_pp:.2e}"]) + tab.append(["PP (no numba)", f"{nb_ts_pp_no_numba/time_pp_no_numba:.2e}", + f"{1000.*pp_no_numba_time_pf/nb_ts_pp_no_numba:.2e}", + f"{1000.*pp_no_numba_comp_time/nb_ts_pp_no_numba:.2e}"]) + tab.append(["PP (with lightsim)", f"{nb_ts_pp_ls_numba/time_pp_ls_numba:.2e}", + f"{1000.*pp_ls_numba_time_pf/nb_ts_pp_ls_numba:.2e}", + f"{1000.*pp_ls_numba_comp_time/nb_ts_pp_ls_numba:.2e}"]) for key in this_order: if key not in res_times: diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index b1a35d3..d268bbe 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -16,16 +16,16 @@ compared with pandapower when using grid2op. All of them has been run on a computer with a the following characteristics: -- date: 2024-03-25 17:53 CET -- system: Linux 5.15.0-56-generic -- OS: ubuntu 20.04 -- processor: Intel(R) Core(TM) i7-6820HQ CPU @ 2.70GHz -- python version: 3.10.13.final.0 (64 bit) -- numpy version: 1.23.5 -- pandas version: 2.2.1 -- pandapower version: 2.13.1 -- grid2op version: 1.10.1 -- lightsim2grid version: 0.8.1 +- date: 2024-12-22 18:05 CET +- system: Linux 6.5.0-1024-oem +- OS: ubuntu 22.04 +- processor: 13th Gen Intel(R) Core(TM) i7-13700H +- python version: 3.12.8.final.0 (64 bit) +- numpy version: 1.26.4 +- pandas version: 2.2.3 +- pandapower version: 2.14.10 +- grid2op version: 1.10.5 +- lightsim2grid version: 0.10.0 - lightsim2grid extra information: - klu_solver_available: True @@ -52,6 +52,10 @@ and powerflow algorithm (*eg* "Newton Raphson", or "Fast Decoupled")): - **PP**: PandaPowerBackend (default grid2op backend) which is the reference in our benchmarks (uses the numba acceleration). It is our reference solver. +- **PP (no numba)** : is still the `PandaPowerBackend` but it does not use the numba accelaration (see + pandapower documentation for more information) +- **PP (with lightsim)**: is again the `PandaPowerBackend` which uses lightsim2grid to compute the powerflow (drr + pandapower documentation for more information) - **GS** (Gauss Seidel): the grid2op backend based on lightsim2grid that uses the "Gauss Seidel" solver to compute the powerflows. - **GS synch** (Gauss Seidel synch version): the grid2op backend based on lightsim2grid that uses a @@ -141,78 +145,95 @@ First on an environment based on the IEEE case 14 grid: ==================== ====================== =================================== ============================ case14_sandbox grid2op speed (it/s) grid2op 'backend.runpf' time (ms) solver powerflow time (ms) ==================== ====================== =================================== ============================ -PP 46.3 18.4 6.57 -GS 757 0.474 0.378 -GS synch 769 0.445 0.348 -NR single (SLU) 960 0.184 0.0831 -NR (SLU) 952 0.189 0.0819 -NR single (KLU) 1030 0.12 0.0221 -NR (KLU) 1030 0.118 0.0202 -NR single (NICSLU *) 1020 0.121 0.022 -NR (NICSLU *) 1020 0.119 0.02 -NR single (CKTSO *) 1020 0.119 0.0211 -NR (CKTSO *) 989 0.121 0.0192 -FDPF XB (SLU) 1010 0.13 0.032 -FDPF BX (SLU) 1010 0.143 0.0451 -FDPF XB (KLU) 1020 0.124 0.0263 -FDPF BX (KLU) 1010 0.134 0.0377 -FDPF XB (NICSLU *) 1010 0.126 0.0267 -FDPF BX (NICSLU *) 1020 0.134 0.0383 -FDPF XB (CKTSO *) 1010 0.125 0.0268 -FDPF BX (CKTSO *) 1000 0.136 0.0381 +PP 138 5.98 2.66 +PP (no numba) 90.2 9.77 6.29 +PP (with lightsim) 130 6.39 1.44 +GS 1470 0.314 0.266 +GS synch 1410 0.348 0.3 +NR single (SLU) 2200 0.0844 0.0346 +NR (SLU) 2200 0.0849 0.0353 +NR single (KLU) 2390 0.0581 0.0101 +NR (KLU) 2380 0.058 0.0098 +NR single (NICSLU *) 2400 0.0579 0.01 +NR (NICSLU *) 2380 0.0579 0.00976 +NR single (CKTSO *) 2410 0.0573 0.00949 +NR (CKTSO *) 2380 0.0575 0.00926 +FDPF XB (SLU) 2360 0.063 0.0152 +FDPF BX (SLU) 2330 0.0697 0.0221 +FDPF XB (KLU) 2370 0.0603 0.0125 +FDPF BX (KLU) 2350 0.0662 0.0185 +FDPF XB (NICSLU *) 2370 0.0603 0.0125 +FDPF BX (NICSLU *) 2370 0.0659 0.0185 +FDPF XB (CKTSO *) 2390 0.0601 0.0126 +FDPF BX (CKTSO *) 2360 0.0662 0.0185 ==================== ====================== =================================== ============================ -From a grid2op perspective, lightsim2grid allows to compute up to ~1200 steps each second on the case 14 and -"only" 70 for the default PandaPower Backend, leading to a speed up of **~17** in this case -(lightsim2grid is ~17 times faster than `Pandapower`). For such a small environment, there is no sensible -difference in using `KLU` linear solver compared to using the SparseLU solver of Eigen (1120 vs 1200 iterations on the reported -runs, might slightly vary across runs). `KLU` and `NICSLU` achieve almost identical performances. + +From a grid2op perspective, lightsim2grid allows to compute up to ~2400 steps each second (column `grid2op speed`, rows `NR XXX`) on the case 14 and +"only" ~140 for the default PandaPower Backend (column `grid2op speed`, row `PP`), leading to a speed up of **~17** (2400 / 140) in this case +(lightsim2grid Backend is ~17 times faster than pandapower Backend when comparing grid2op speed). + +For such a small environment, there is no sensible +difference in using `KLU` linear solver (rows `NR single (KLU)` or `NR (KLU)`) compared to using the SparseLU solver of Eigen +(rows `NR single (SLU)` or `NR (SLU)`) +(2200 vs 2380 iterations on the reported runs, might slightly vary across runs). + +`KLU`, `NICSLU` and `CKTSO` achieve almost identical performances, at least we think the observed differences are within error margins. + +There are also very little differences between non distributed slack (`NR Single` rows) and distributed slack (`NR` rows) for all of the +linear solvers. + +Finally, the "fast decoupled" methods also leads to equivalent performances for almost all linear solvers. Then on an environment based on the IEEE case 118: ===================== ====================== =================================== ============================ neurips_2020_track2 grid2op speed (it/s) grid2op 'backend.runpf' time (ms) solver powerflow time (ms) ===================== ====================== =================================== ============================ -PP 41.5 20.7 8.6 -GS 3.74 266 266 -GS synch 35.8 26.9 26.8 -NR single (SLU) 536 0.897 0.767 -NR (SLU) 505 0.959 0.818 -NR single (KLU) 811 0.268 0.144 -NR (KLU) 820 0.256 0.131 -NR single (NICSLU *) 813 0.259 0.134 -NR (NICSLU *) 827 0.243 0.118 -NR single (CKTSO *) 814 0.257 0.131 -NR (CKTSO *) 829 0.24 0.116 -FDPF XB (SLU) 762 0.352 0.232 -FDPF BX (SLU) 749 0.373 0.252 -FDPF XB (KLU) 786 0.307 0.188 -FDPF BX (KLU) 776 0.327 0.206 -FDPF XB (NICSLU *) 786 0.308 0.188 -FDPF BX (NICSLU *) 771 0.324 0.204 -FDPF XB (CKTSO *) 784 0.309 0.19 -FDPF BX (CKTSO *) 773 0.329 0.209 +PP 115 7.38 3.84 +PP (no numba) 78.1 11.4 7.85 +PP (with lightsim) 114 7.42 1.85 +GS 7.13 140 139 +GS synch 43.1 22.6 22.6 +NR single (SLU) 1070 0.491 0.419 +NR (SLU) 956 0.513 0.44 +NR single (KLU) 1840 0.131 0.0663 +NR (KLU) 1850 0.128 0.0631 +NR single (NICSLU *) 1850 0.129 0.0636 +NR (NICSLU *) 1870 0.124 0.0589 +NR single (CKTSO *) 1850 0.126 0.061 +NR (CKTSO *) 1870 0.121 0.0561 +FDPF XB (SLU) 1720 0.179 0.117 +FDPF BX (SLU) 1650 0.198 0.135 +FDPF XB (KLU) 1780 0.159 0.0965 +FDPF BX (KLU) 1730 0.176 0.114 +FDPF XB (NICSLU *) 1760 0.16 0.0969 +FDPF BX (NICSLU *) 1720 0.175 0.112 +FDPF XB (CKTSO *) 1770 0.159 0.0972 +FDPF BX (CKTSO *) 1720 0.175 0.112 ===================== ====================== =================================== ============================ -For an environment based on the IEEE 118, the speed up in using lightsim + KLU (LS+KLU) is **~24** time faster than -using the default `PandaPower` backend (~950 it/s vs ~40). +For an environment based on the IEEE 118, the speed up in using lightsim + KLU (LS+KLU) is **~17** time faster than +using the default `PandaPower` backend (~1900 it/s vs ~120 for pandapower with numba). + +The speed up of lightsim + SparseLU (`1070` it / s) is a bit lower (than using KLU, CKTSO or NICSLU), but it is still **~9** +times faster than using the default backend. -The speed up of lightsim + SparseLU (`0.11`) is a bit lower, but it is still **~16** -times faster than using the default backend [the `LS+KLU` solver is ~5-6 times faster than the `LS+SLU` solver -(`0.11` ms per powerflow for `L2+KLU` compared to `0.6` ms for `LS+SLU`), but it only translates to `LS+KLU` -providing ~40-50% more -iterations per second in the total program (`950` vs `640`) mainly because grid2op itself takes some times to modify the -grid and performs all the check it does.] For this testcase once again there is no noticeable difference between -`NICSLU` and `KLU`. +For this environment the `LS+KLU` solver (solver powerflow time) is ~5-6 times faster than the `LS+SLU` solver +(`0.419` ms per powerflow for `LS+SLU` compared to `0.0631` ms for `LS+KLU`), but it only translates to `LS+KLU` +providing ~70% more iterations per second in the total program (`1070` vs `1850`) mainly because grid2op itself takes some times to modify the +grid and performs some consistency cheks. For this testcase once again there is no noticeable difference between +`NICSLU`, `CKTSO` and `KLU`. If we look now only at the time to compute one powerflow (and don't take into account the time to load the data, to initialize the solver, to modify the grid, read back the results, to perform the other update in the -grid2op environment etc. -- column "solver powerflow time (ms)") we can notice that it takes on average (over 1000 different states) approximately **0.12ms** -to compute a powerflow with the LightSimBackend (if using the `KLU` linear solver) compared to the **5.6 ms** when using -the PandaPowerBackend (speed up of **~46** times) +grid2op environment etc. -- *ie* looking at the column "`solver powerflow time (ms)`") +we can notice that it takes on average (over 1000 different states) approximately **0.0631ms** +to compute a powerflow with the LightSimBackend (if using the `KLU` linear solver) compared to the **3.84 ms** when using +the `PandaPowerBackend` (with numba, but without lightsim2grid) (speed up of **~60** times) -**NB** pandapower performances heavily depends on the pandas version used, we used here a version of pandas which -we found gave the best performances on our machine. +**NB** pandapower performances heavily depends on the pandas version used, we used here the latest avaialble version +at the time of the benchmark. .. note:: The "solver powerflow time" reported for pandapower is obtained by summing, over the 1000 powerflow performed the `pandapower_backend._grid["_ppc"]["et"]` (the "estimated time" of the pandapower newton raphson computation @@ -223,9 +244,16 @@ we found gave the best performances on our machine. uniquely (time inside the `basesolver.compute_pf()` function. In particular it do not count the time to initialize the vector V with the DC approximation) +More information +~~~~~~~~~~~~~~~~~ + +TODO + Differences ~~~~~~~~~~~~~~~~~~~ -Using the same command, we report the maximum value of the differences between different quantities: +Using the same command, we report the maximum value of the differences when compared with the reference +implementation which in this case is pandapower (this explain why pandapower always have a difference of 0.) for different +output values : - `aor` : the current flow (in Amps) at the origin side of each powerline - `gen_p` : the generators active production values @@ -286,13 +314,13 @@ NR (NICSLU *) 6.1e-05 0 9.54e NR single (CKTSO *) 6.1e-05 0 9.54e-07 NR (CKTSO *) 6.1e-05 0 9.54e-07 FDPF XB (SLU) 6.1e-05 1.91e-06 1.53e-05 -FDPF BX (SLU) 6.1e-05 1.91e-06 7.63e-06 +FDPF BX (SLU) 6.1e-05 0 9.54e-07 FDPF XB (KLU) 6.1e-05 1.91e-06 1.53e-05 -FDPF BX (KLU) 6.1e-05 1.91e-06 7.63e-06 +FDPF BX (KLU) 6.1e-05 0 9.54e-07 FDPF XB (NICSLU *) 6.1e-05 1.91e-06 1.53e-05 -FDPF BX (NICSLU *) 6.1e-05 1.91e-06 7.63e-06 +FDPF BX (NICSLU *) 6.1e-05 0 9.54e-07 FDPF XB (CKTSO *) 6.1e-05 1.91e-06 1.53e-05 -FDPF BX (CKTSO *) 6.1e-05 1.91e-06 7.63e-06 +FDPF BX (CKTSO *) 6.1e-05 0 9.54e-07 ================================= ============== ============== ================ As we can see on all the tables above, the difference when using lightsim and pandapower is rather From 25ed259daa2b2da724a5f52458be14275233057f Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 23 Dec 2024 21:16:13 +0100 Subject: [PATCH 04/15] improve the text of the benchmark [skip ci] Signed-off-by: DONNOT Benjamin --- docs/benchmarks.rst | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index d268bbe..29b7bb7 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -247,7 +247,19 @@ at the time of the benchmark. More information ~~~~~~~~~~~~~~~~~ -TODO +For all the benchmarks, the stopping criteria (of the solver, for each steps) is based on the maximum discrepency (at each bus) of the Kirchhoff Current Law's. +The solver stops if this `tolerance` is under `1e-8` "per unit". + +For all "steps", the model is intialized with the "DC" (direct current) approximation before the AC powerflow is run. In these benchmark, only the timings +of the AC powerflow is reported. + +These benchmarks mimic a typical behaviour in grid2op where in most cases the "agent" does nothing: between two consecutive steps, +there is no modification of the topology. Only the injection (active and reactive power consumed and active generation as well as target voltage +setpoint of generators are modified between two steps). The topology does not change, the tap of the transformers stay the same etc. + +Finally, as opposed to pandapower Backend, lightsim2grid Backend is to "recycle" partially some of its comptuation. Concretely this means, in this +case, that the Ybus matrix is not recomputed at each steps (but computed only at the first one) for example. + Differences ~~~~~~~~~~~~~~~~~~~ @@ -331,6 +343,10 @@ When using Newton Raphson solvers, the difference in absolute values when using with using PandaPowerBackend is neglectible: less than 1e-06 in all cases (and 0.00 when comparing the flows on the powerline for both environments). +.. note:: + The differences reported here are in comparison with pandapower. This is why there is 0. to + all the columns corresponding to the `PP (ref)` row. + Other benchmark ---------------- From 1f8d74c1b62fe443d0ea896d807237aad399f315 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 27 Dec 2024 14:49:57 +0100 Subject: [PATCH 05/15] adding benchmarking code for DC solver [skip ci] Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_dc_solvers.py | 273 +++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 benchmarks/benchmark_dc_solvers.py diff --git a/benchmarks/benchmark_dc_solvers.py b/benchmarks/benchmark_dc_solvers.py new file mode 100644 index 0000000..ff1bec5 --- /dev/null +++ b/benchmarks/benchmark_dc_solvers.py @@ -0,0 +1,273 @@ +# Copyright (c) 2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid a implements a c++ backend targeting the Grid2Op platform. + +import numpy as np +import os +import warnings +import pandas as pd +from grid2op import make +from grid2op.Backend import PandaPowerBackend +from grid2op.Agent import DoNothingAgent +from grid2op.Chronics import ChangeNothing +import re + +from lightsim2grid import solver +try: + from grid2op.Chronics import GridStateFromFileWithForecastsWithoutMaintenance as GridStateFromFile +except ImportError: + print("Be carefull: there might be maintenance") + from grid2op.Chronics import GridStateFromFile + +from grid2op.Parameters import Parameters +import lightsim2grid +from lightsim2grid.lightSimBackend import LightSimBackend +from utils_benchmark import print_res, run_env, str2bool, get_env_name_displayed, print_configuration +TABULATE_AVAIL = False +try: + from tabulate import tabulate + TABULATE_AVAIL = True +except ImportError: + print("The tabulate package is not installed. Some output might not work properly") + +MAX_TS = 1000 +ENV_NAME = "rte_case14_realistic" +DONT_SAVE = "__DONT_SAVE" +NICSLU_LICENSE_AVAIL = os.path.exists("./nicslu.lic") and os.path.isfile("./nicslu.lic") + +solver_names = {# lightsim2grid.SolverType.GaussSeidel: "GS", + # lightsim2grid.SolverType.GaussSeidelSynch: "GS synch", + # lightsim2grid.SolverType.SparseLU: "NR (SLU)", + # lightsim2grid.SolverType.KLU: "NR (KLU)", + # lightsim2grid.SolverType.NICSLU: "NR (NICSLU *)", + # lightsim2grid.SolverType.CKTSO: "NR (CKTSO *)", + # lightsim2grid.SolverType.SparseLUSingleSlack: "NR single (SLU)", + # lightsim2grid.SolverType.KLUSingleSlack: "NR single (KLU)", + # lightsim2grid.SolverType.NICSLUSingleSlack: "NR single (NICSLU *)", + # lightsim2grid.SolverType.CKTSOSingleSlack: "NR single (CKTSO *)", + # lightsim2grid.SolverType.FDPF_XB_SparseLU: "FDPF XB (SLU)", + # lightsim2grid.SolverType.FDPF_BX_SparseLU: "FDPF BX (SLU)", + # lightsim2grid.SolverType.FDPF_XB_KLU: "FDPF XB (KLU)", + # lightsim2grid.SolverType.FDPF_BX_KLU: "FDPF BX (KLU)", + # lightsim2grid.SolverType.FDPF_XB_NICSLU: "FDPF XB (NICSLU *)", + # lightsim2grid.SolverType.FDPF_BX_NICSLU: "FDPF BX (NICSLU *)", + # lightsim2grid.SolverType.FDPF_XB_CKTSO: "FDPF XB (CKTSO *)", + # lightsim2grid.SolverType.FDPF_BX_CKTSO: "FDPF BX (CKTSO *)", + lightsim2grid.SolverType.DC: "DC", + lightsim2grid.SolverType.KLUDC: "DC (KLU)", + lightsim2grid.SolverType.NICSLUDC: "DC (NICSLU *)", + lightsim2grid.SolverType.CKTSODC: "DC (CKTSO *)" + } +solver_gs = {} +solver_fdpf = {} +res_times = {} + +order_solver_print = [ + lightsim2grid.SolverType.DC, + lightsim2grid.SolverType.KLUDC, + lightsim2grid.SolverType.NICSLUDC, + lightsim2grid.SolverType.CKTSODC, + +] + + +def main(max_ts, + env_name_input, + test=True, + no_gs=False, + no_gs_synch=False, + no_pp=False, + save_results=DONT_SAVE): + param = Parameters() + param.init_from_dict({"NO_OVERFLOW_DISCONNECTION": True, "ENV_DC": True, "FORECAST_DC": True}) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + if re.match("^.*\\.json$", env_name_input) is None: + # i provided an environment name + env_pp = make(env_name_input, param=param, test=test, + backend=PandaPowerBackend(lightsim2grid=False, with_numba=True), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + env_pp_no_numba = make(env_name_input, param=param, test=test, + backend=PandaPowerBackend(lightsim2grid=False, with_numba=False), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + env_pp_ls_numba = make(env_name_input, param=param, test=test, + backend=PandaPowerBackend(lightsim2grid=True, with_numba=True), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + env_lightsim = make(env_name_input, backend=LightSimBackend(), param=param, test=test, + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + else: + # I provided an environment path + env_pp = make("blank", param=param, test=True, + data_feeding_kwargs={"gridvalueClass": ChangeNothing}, + grid_path=env_name_input + ) + env_lightsim = make("blank", param=param, test=True, + backend=LightSimBackend(), + data_feeding_kwargs={"gridvalueClass": ChangeNothing}, + grid_path=env_name_input) + _, env_name_input = os.path.split(env_name_input) + + agent = DoNothingAgent(action_space=env_pp.action_space) + if no_pp is False: + print("Start using Pandapower") + nb_ts_pp, time_pp, aor_pp, gen_p_pp, gen_q_pp = run_env(env_pp, max_ts, agent, chron_id=0, env_seed=0) + pp_comp_time = env_pp.backend.comp_time + pp_time_pf = env_pp._time_powerflow + + tmp_no_numba = run_env(env_pp_no_numba, max_ts, agent, chron_id=0, env_seed=0) + nb_ts_pp_no_numba, time_pp_no_numba, aor_pp_no_numba, gen_p_pp_no_numba, gen_q_pp_no_numba = tmp_no_numba + pp_no_numba_comp_time = env_pp_no_numba.backend.comp_time + pp_no_numba_time_pf = env_pp_no_numba._time_powerflow + + tmp_ls_numba = run_env(env_pp_ls_numba, max_ts, agent, chron_id=0, env_seed=0) + nb_ts_pp_ls_numba, time_pp_ls_numba, aor_pp_ls_numba, gen_p_ls_numba, gen_q_ls_numba = tmp_ls_numba + pp_ls_numba_comp_time = env_pp_ls_numba.backend.comp_time + pp_ls_numba_time_pf = env_pp_ls_numba._time_powerflow + + wst = True # print extra info in the run_env function + solver_types = env_lightsim.backend.available_solvers + + for solver_type in solver_types: + if solver_type not in solver_names: + continue + print(f"Start using {solver_type}") + env_lightsim.backend.set_solver_type(solver_type) + if solver_type in solver_gs: + # gauss seidel sovler => more iterations + env_lightsim.backend.set_solver_max_iter(10000) + if lightsim2grid.SolverType.GaussSeidel == solver_type and no_gs: + # I don't study the gauss seidel solver + continue + elif lightsim2grid.SolverType.GaussSeidelSynch == solver_type and no_gs_synch: + # I don't study the gauss seidel synch solver + continue + elif solver_type in solver_fdpf: + # gauss seidel sovler => more iterations + env_lightsim.backend.set_solver_max_iter(30) + else: + # NR based solver => less iterations + env_lightsim.backend.set_solver_max_iter(10) + nb_ts_gs, time_gs, aor_gs, gen_p_gs, gen_q_gs = run_env(env_lightsim, max_ts, agent, chron_id=0, + with_type_solver=wst, env_seed=0) + gs_comp_time = env_lightsim.backend.comp_time + gs_time_pf = env_lightsim._time_powerflow + res_times[solver_type] = (solver_names[solver_type], + nb_ts_gs, time_gs, aor_gs, gen_p_gs, + gen_q_gs, gs_comp_time, gs_time_pf) + + # NOW PRINT THE RESULTS + print("Configuration:") + config_str = print_configuration() + if save_results != DONT_SAVE: + with open(save_results+"config_info.txt", "w", encoding="utf-8") as f: + f.write(config_str) + # order on which the solvers will be + this_order = [el for el in res_times.keys() if el not in order_solver_print] + order_solver_print + + env_name = get_env_name_displayed(env_name_input) + hds = [f"{env_name}", f"grid2op speed (it/s)", f"grid2op 'backend.runpf' time (ms)", f"solver powerflow time (ms)"] + tab = [] + if no_pp is False: + tab.append(["PP DC", f"{nb_ts_pp/time_pp:.2e}", + f"{1000.*pp_time_pf/nb_ts_pp:.2e}", + f"{1000.*pp_comp_time/nb_ts_pp:.2e}"]) + tab.append(["PP DC (no numba)", f"{nb_ts_pp_no_numba/time_pp_no_numba:.2e}", + f"{1000.*pp_no_numba_time_pf/nb_ts_pp_no_numba:.2e}", + f"{1000.*pp_no_numba_comp_time/nb_ts_pp_no_numba:.2e}"]) + tab.append(["PP DC (with lightsim)", f"{nb_ts_pp_ls_numba/time_pp_ls_numba:.2e}", + f"{1000.*pp_ls_numba_time_pf/nb_ts_pp_ls_numba:.2e}", + f"{1000.*pp_ls_numba_comp_time/nb_ts_pp_ls_numba:.2e}"]) + + for key in this_order: + if key not in res_times: + continue + solver_name, nb_ts_gs, time_gs, aor_gs, gen_p_gs, gen_q_gs, gs_comp_time, gs_time_pf = res_times[key] + tab.append([solver_name, + f"{nb_ts_gs/time_gs:.2e}", + f"{1000.*gs_time_pf/nb_ts_gs:.2e}", + f"{1000.*gs_comp_time/nb_ts_gs:.2e}"]) + + if TABULATE_AVAIL: + res_use_with_grid2op_1 = tabulate(tab, headers=hds, tablefmt="rst") + print(res_use_with_grid2op_1) + else: + print(tab) + + if save_results != DONT_SAVE: + dt = pd.DataFrame(tab, columns=hds) + dt.to_csv(save_results+"speed.csv", index=False, header=True, sep=";") + print() + + if TABULATE_AVAIL: + res_github_readme = tabulate(tab, headers=hds, tablefmt="github") + print(res_github_readme) + else: + print(tab) + print() + + if no_pp is False: + hds = [f"{env_name} ({nb_ts_pp} iter)", f"Δ aor (amps)", f"Δ gen_p (MW)", f"Δ gen_q (MVAr)"] + tab = [["PP (ref)", "0.00", "0.00", "0.00"]] + + for key in this_order: + if key not in res_times: + continue + solver_name, nb_ts_gs, time_gs, aor_gs, gen_p_gs, gen_q_gs, gs_comp_time, gs_time_pf = res_times[key] + tab.append([solver_name, + f"{np.max(np.abs(aor_gs - aor_pp)):.2e}", + f"{np.max(np.abs(gen_p_gs - gen_p_pp)):.2e}", + f"{np.max(np.abs(gen_q_gs - gen_q_pp)):.2e}"]) + + if TABULATE_AVAIL: + res_use_with_grid2op_2 = tabulate(tab, headers=hds, tablefmt="rst") + print(res_use_with_grid2op_2) + else: + print(tab) + + if save_results != DONT_SAVE: + dt = pd.DataFrame(tab, columns=hds) + dt.to_csv(save_results+"diff.csv", index=False, header=True, sep=";") + print() + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description='Benchmark of lightsim with a "do nothing" agent ' + '(compare multiple lightsim solvers with default grid2op backend ' + 'PandaPower)') + parser.add_argument('--env_name', default=ENV_NAME, type=str, + help='Environment name to be used for the benchmark.') + parser.add_argument('--number', type=int, default=MAX_TS, + help='Maximum number of time steps for which the benchmark will be run.') + parser.add_argument('--no_test', type=str2bool, nargs='?', + const=True, default=False, + help='Do not use \"test=True\" keyword argument when building the grid2op environments' + ' for the benchmark (default False: use \"test=True\" environment)') + parser.add_argument('--no_gs_synch', type=str2bool, nargs='?', + const=True, default=False, + help='Do not benchmark gauss seidel (synch) method (default: evaluate it)') + parser.add_argument('--no_gs', type=str2bool, nargs='?', + const=True, default=False, + help='Do not benchmark gauss seidel (regular) method (default: evaluate it)') + parser.add_argument('--no_pp', type=str2bool, nargs='?', + const=True, default=False, + help='Do not benchmark pandapower method (default: evaluate it)') + parser.add_argument("--save_results", default=DONT_SAVE, type=str, + help='Name of the file in which you want to save the result table') + args = parser.parse_args() + + max_ts = int(args.number) + env_name = str(args.env_name) + test_env = not args.no_test + main(max_ts, + env_name, + test_env, + no_gs=args.no_gs, + no_gs_synch=args.no_gs_synch, + no_pp=args.no_pp, + save_results=args.save_results) From 617d6edcfa7f129b3a8c4c39366f9bb02d4ddaf6 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jan 2025 17:42:35 +0100 Subject: [PATCH 06/15] improving the text for the benchmarks [skip ci] Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_solvers.py | 58 ++++++++- benchmarks/req_benchmarks.txt | 6 + benchmarks/utils_benchmark.py | 12 +- docs/benchmarks.rst | 37 ++++-- docs/benchmarks_dive.rst | 213 ++++++++++++++++++++++++++++++++ docs/benchmarks_grid_sizes.rst | 9 +- docs/index.rst | 12 +- 7 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 benchmarks/req_benchmarks.txt create mode 100644 docs/benchmarks_dive.rst diff --git a/benchmarks/benchmark_solvers.py b/benchmarks/benchmark_solvers.py index e8608cc..ff869d2 100644 --- a/benchmarks/benchmark_solvers.py +++ b/benchmarks/benchmark_solvers.py @@ -23,6 +23,13 @@ print("Be carefull: there might be maintenance") from grid2op.Chronics import GridStateFromFile +try: + from pypowsybl2grid import PyPowSyBlBackend + pypow_error = None +except ImportError as exc_: + pypow_error = exc_ + print("Backend based on pypowsybl will not be benchmarked") + from grid2op.Parameters import Parameters import lightsim2grid from lightsim2grid.lightSimBackend import LightSimBackend @@ -116,12 +123,32 @@ def main(max_ts, data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) env_lightsim = make(env_name_input, backend=LightSimBackend(), param=param, test=test, data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + if pypow_error is None: + env_pypow = make(env_name_input, param=param, test=test, + backend=PyPowSyBlBackend(), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) else: # I provided an environment path env_pp = make("blank", param=param, test=True, data_feeding_kwargs={"gridvalueClass": ChangeNothing}, - grid_path=env_name_input + grid_path=env_name_input, + backend=PandaPowerBackend(lightsim2grid=False, with_numba=True) ) + env_pp_no_numba = make("blank", param=param, test=True, + data_feeding_kwargs={"gridvalueClass": ChangeNothing}, + grid_path=env_name_input, + backend=PandaPowerBackend(lightsim2grid=False, with_numba=False) + ) + env_pp_ls_numba = make("blank", param=param, test=True, + data_feeding_kwargs={"gridvalueClass": ChangeNothing}, + grid_path=env_name_input, + backend=PandaPowerBackend(lightsim2grid=True, with_numba=True) + ) + if pypow_error is None: + env_pypow = make("blank", param=param, test=True, + data_feeding_kwargs={"gridvalueClass": ChangeNothing}, + grid_path=env_name_input, + backend=PyPowSyBlBackend()) env_lightsim = make("blank", param=param, test=True, backend=LightSimBackend(), data_feeding_kwargs={"gridvalueClass": ChangeNothing}, @@ -134,17 +161,35 @@ def main(max_ts, nb_ts_pp, time_pp, aor_pp, gen_p_pp, gen_q_pp = run_env(env_pp, max_ts, agent, chron_id=0, env_seed=0) pp_comp_time = env_pp.backend.comp_time pp_time_pf = env_pp._time_powerflow + if hasattr(env_pp, "_time_step"): + # for oldest grid2op version where this was not stored + time_pp = env_pp._time_step tmp_no_numba = run_env(env_pp_no_numba, max_ts, agent, chron_id=0, env_seed=0) nb_ts_pp_no_numba, time_pp_no_numba, aor_pp_no_numba, gen_p_pp_no_numba, gen_q_pp_no_numba = tmp_no_numba pp_no_numba_comp_time = env_pp_no_numba.backend.comp_time pp_no_numba_time_pf = env_pp_no_numba._time_powerflow + if hasattr(env_pp_no_numba, "_time_step"): + # for oldest grid2op version where this was not stored + time_pp_no_numba = env_pp_no_numba._time_step tmp_ls_numba = run_env(env_pp_ls_numba, max_ts, agent, chron_id=0, env_seed=0) nb_ts_pp_ls_numba, time_pp_ls_numba, aor_pp_ls_numba, gen_p_ls_numba, gen_q_ls_numba = tmp_ls_numba pp_ls_numba_comp_time = env_pp_ls_numba.backend.comp_time pp_ls_numba_time_pf = env_pp_ls_numba._time_powerflow + if hasattr(env_pp_ls_numba, "_time_step"): + # for oldest grid2op version where this was not stored + time_pp_ls_numba = env_pp_ls_numba._time_step + if pypow_error is None: + # also benchmark pypowsybl backend + nb_ts_pypow, time_pypow, aor_pypow, gen_p_pypow, gen_q_pypow = run_env(env_pypow, max_ts, agent, chron_id=0, env_seed=0) + pypow_comp_time = env_pypow.backend.comp_time + pypow_time_pf = env_pypow._time_powerflow + if hasattr(env_pypow, "_time_step"): + # for oldest grid2op version where this was not stored + time_pypow = env_pypow._time_step + wst = True # print extra info in the run_env function solver_types = env_lightsim.backend.available_solvers @@ -172,13 +217,16 @@ def main(max_ts, with_type_solver=wst, env_seed=0) gs_comp_time = env_lightsim.backend.comp_time gs_time_pf = env_lightsim._time_powerflow + if hasattr(env_lightsim, "_time_step"): + # for oldest grid2op version where this was not stored + time_gs = env_lightsim._time_step res_times[solver_type] = (solver_names[solver_type], nb_ts_gs, time_gs, aor_gs, gen_p_gs, gen_q_gs, gs_comp_time, gs_time_pf) # NOW PRINT THE RESULTS print("Configuration:") - config_str = print_configuration() + config_str = print_configuration(pypow_error) if save_results != DONT_SAVE: with open(save_results+"config_info.txt", "w", encoding="utf-8") as f: f.write(config_str) @@ -198,7 +246,11 @@ def main(max_ts, tab.append(["PP (with lightsim)", f"{nb_ts_pp_ls_numba/time_pp_ls_numba:.2e}", f"{1000.*pp_ls_numba_time_pf/nb_ts_pp_ls_numba:.2e}", f"{1000.*pp_ls_numba_comp_time/nb_ts_pp_ls_numba:.2e}"]) - + if pypow_error is None: + tab.append(["pypowsybl", f"{nb_ts_pypow/time_pypow:.2e}", + f"{1000.*pypow_time_pf/nb_ts_pypow:.2e}", + f"{1000.*pypow_comp_time/nb_ts_pypow:.2e}"]) + for key in this_order: if key not in res_times: continue diff --git a/benchmarks/req_benchmarks.txt b/benchmarks/req_benchmarks.txt new file mode 100644 index 0000000..5cac5dc --- /dev/null +++ b/benchmarks/req_benchmarks.txt @@ -0,0 +1,6 @@ +numba +tabulate +py-cpuinfo +grid2op +distro +matplotlib \ No newline at end of file diff --git a/benchmarks/utils_benchmark.py b/benchmarks/utils_benchmark.py index 75edec7..88281b1 100644 --- a/benchmarks/utils_benchmark.py +++ b/benchmarks/utils_benchmark.py @@ -127,7 +127,7 @@ def str2bool(v): raise argparse.ArgumentTypeError('Boolean value expected.') -def print_configuration(): +def print_configuration(pypow_error=True): res = [] print() tmp = f"- date: {datetime.datetime.now():%Y-%m-%d %H:%M %z} {time.localtime().tm_zone}" @@ -185,6 +185,16 @@ def print_configuration(): tmp = (f"- pandapower version: {pp.__version__}") res.append(tmp) print(tmp) + if pypow_error is None: + import pypowsybl as pypo + tmp = (f"- pywposybl version: {pypo.__version__}") + res.append(tmp) + print(tmp) + import pypowsybl2grid + tmp = (f"- pypowsybl2grid version: {pypowsybl2grid.__version__}") + res.append(tmp) + print(tmp) + tmp = (f"- grid2op version: {grid2op.__version__}") res.append(tmp) print(tmp) diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 29b7bb7..7bb7e65 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -1,3 +1,4 @@ +.. _benchmark-solvers: Benchmarks (solvers) ====================== @@ -35,13 +36,24 @@ All of them has been run on a computer with a the following characteristics: - compiled_o3_optim: True -To run the benchmark `cd` in the [benchmark](./benchmarks) folder and type: +To run the benchmark `cd` in the [benchmark](./benchmarks) folder and install the dependencies +(we suppose here that you have already installed lightsim2grid): + +.. code-block:: bash + pip install -r req_benchmarks.txt + +This will install the required packages to run the benchmark smoothly (most notably `grid2op` and `numba`) +and then you can start the benchmark with the following commands: .. code-block:: bash python3 benchmark_solvers.py --env_name l2rpn_case14_sandbox --no_test --number 1000 python3 benchmark_solvers.py --env_name l2rpn_neurips_2020_track2_small --no_test --number 1000 +.. note:: + The first time you execute them, some data might be downloaded. These data comes from the grid2op + package and contains the time series (how each load and generation behaves) for different steps. + (results may vary depending on the hard drive, the ram etc. and are presented here for illustration only) (we remind that these simulations correspond to simulation on one core of the CPU. Of course it is possible to @@ -56,6 +68,7 @@ and powerflow algorithm (*eg* "Newton Raphson", or "Fast Decoupled")): pandapower documentation for more information) - **PP (with lightsim)**: is again the `PandaPowerBackend` which uses lightsim2grid to compute the powerflow (drr pandapower documentation for more information) +- **pypowsybl**: benchmarks the "pypowsybl2grid" backend based on the python implementation of the powsybl framework. - **GS** (Gauss Seidel): the grid2op backend based on lightsim2grid that uses the "Gauss Seidel" solver to compute the powerflows. - **GS synch** (Gauss Seidel synch version): the grid2op backend based on lightsim2grid that uses a @@ -101,10 +114,10 @@ and powerflow algorithm (*eg* "Newton Raphson", or "Fast Decoupled")): - **FDPF BX (CKTSO *)** (Fast Decoupled Powerflow, BX variant - with CKTSO linear solver) same as `FDPF BX (SLU)` but using CKTSO instead of SparseLU -**NB** all backend above are implemented in lightsim2grid. +**NB** all backends above (except pandapower) are implemented in lightsim2grid. **NB** solver with \* are available provided that lightsim2grid is installed from source and following the instructions -in the documentation. +in the documentation. Some license might be needed. All benchmarks where done with all the customization (for speed, *eg* `-O3` and `-march=native` for linux). See the readme for more information. @@ -122,16 +135,17 @@ In this first subsection we compare the computation times: the observations etc.). It is reported in "iteration per second" (`it/s`) and represents the number of grid2op "step" that can be computed per second. -- **grid2op 'backend.runpf' time** corresponds to the time the solver take +- **grid2op 'backend.runpf' time** corresponds to the time the grid2op backend take to perform a powerflow - as seen from grid2op (counting the resolution time and some time to check + as seen from grid2op (counting the resolution time of the powerflow solver and some time to check the validity of the results but not the time to update the grid nor the grid2op environment), for lightsim2grid it includes the time to read back the data from c++ to python. It is reported in milli seconds (ms). - **solver powerflow time** corresponds only to the time spent in the solver itself. It does not take into - account any of the checking, nor the transfer of the data python side etc. + account any of the checking, nor the transfer of the data python side, nor the + computation of the initial state (done through DC approximation) etc. It is reported in milli seconds (ms) as well. There are two major differences between **grid2op 'backend.runpf' time** and **solver powerflow time**. In **grid2op 'backend.runpf' time** @@ -251,14 +265,19 @@ For all the benchmarks, the stopping criteria (of the solver, for each steps) is The solver stops if this `tolerance` is under `1e-8` "per unit". For all "steps", the model is intialized with the "DC" (direct current) approximation before the AC powerflow is run. In these benchmark, only the timings -of the AC powerflow is reported. +of the AC powerflow is reported in the column "`solver powerflow time (ms)`". This time is however included in the other columns. These benchmarks mimic a typical behaviour in grid2op where in most cases the "agent" does nothing: between two consecutive steps, there is no modification of the topology. Only the injection (active and reactive power consumed and active generation as well as target voltage setpoint of generators are modified between two steps). The topology does not change, the tap of the transformers stay the same etc. -Finally, as opposed to pandapower Backend, lightsim2grid Backend is to "recycle" partially some of its comptuation. Concretely this means, in this -case, that the Ybus matrix is not recomputed at each steps (but computed only at the first one) for example. +Finally, as opposed to pandapower Backend, lightsim2grid Backend is able to "recycle" partially some of its comptuation. Concretely this means, in this +case, that the Ybus matrix is not recomputed at each steps (but computed only at the first one) for example. This can lead to some time savings in these +cases. + +.. warning:: + For more information about what is actually done and the wordings used in this section, + you can consult the page :ref:`benchmark-deep-dive` Differences diff --git a/docs/benchmarks_dive.rst b/docs/benchmarks_dive.rst new file mode 100644 index 0000000..56f67e0 --- /dev/null +++ b/docs/benchmarks_dive.rst @@ -0,0 +1,213 @@ +.. _benchmark-deep-dive: + +Deep dive into the benchmarking of lightsim2grid +================================================================== + +At various occasion, we benchmark lightsim2grid with other available solvers. + +In most cases, we benchmark them using the `grid2op` package. In this section we briefly explain what happens and how to interpret the different figures and tables. + +Grid2op in a nutshell +---------------------- + +This is a super basic overview of grid2op. Only the necessary to understand what is benchmarked is explained here. Please consult grid2op documentation at https://grid2op.readthedocs.io/ for more information. + +When grid2op is used, a "grid2op environment" is run for a given number of steps (288 or 1000) usually with an "agent" that does nothing. From grid2op point of view this means: + +1. loading the time series and initializing the solver to the initial state +2. for each of the steps (following steps repeated 288 or 1000 times): + + a. process the action of the agent (if the action is "do nothing" then nothing is really done here) + b. retrieve the next value for all generators and load (from the "time series") + c. "compile" everything into a "state" of the solver (state before solving for the Kirchhoff Current Laws - **KCL**) + d. pass this information to the backend + e. inform the backend to run a powerflow (solve the Kirchhoff Current Laws - **KCL**) + f. check for possible terminal condition (isolated loads or generators etc.) + g. pass the results back to python and format them into a grid2op "observation" +3. stop the computation either if some terminal condition are reached (*eg* divergence of the powerflow) or if the time series have been all computed + +.. note:: + This is a simplification of the possibility of grid2op. For example, by default grid2op can do multiple loops between steps 2e. and 2f. above. + + And once arrived at step 3. the process starts again at step 1. with a call to `env.reset` + +Unless told otherwise, the `grid2op times`, `grid2op speed`, `step duration` and alike reports the average on the 288 (or 1000) points 2. above. +This includes lots of computations (happening through grid2op) to read the data, compile them into a something that can be digested by the grid2op "backend" +and read back the results. + +The time to compute the first and last steps (1. and 3. respectively) are never considered for these benchmarks. +Feel free to let us know (in the grid2op package) if this is of any interest to you. + +All the other time reported are other divisions of the step 2e. above, which is detailed in the next section. + + +Grid2op Backend in a nutshell +------------------------------- + +This section will detail the steps 2e. of the overview of the previous section, because it can, in turn, be decomposed into different steps. + +The goal of this 2e. steps is to find the solution to the KCL for each buses of the grid. This is done thanks to a "solver". +This solver is "wrapped" to grid2op with what is called a "grid2op backend". This is why there are 2 columns dedicated to this +"step 2e." in the page :ref:`benchmark-solver` for example, the column `grid2op 'backend.runpf' time (ms)` counts all the computation +happening in steps 2e. (and depending on the implementation - this is the case with lightsim2grid- even step 2f. and 2g.) whereas the +column `solver powerflow time (ms)` only counts thet time spent in the "solver" for the AC powerflow. + +What is happening in step 2e. (basically in the `backend.runpf` function) is: + +1. before some basic initial steps (*eg* the connexity of the grid) +2. find some initial solution for the complex voltage at each bus + (this is done with the Direct Current approximation in pandapower and lightsim2grid) +3. start the resolution of the KCL in AC (for pandapower and lightsim2grid in most benchmarks) +4. derives all the others quantities from that (active and reactive flows on each line, + current flow on each line, reactive power absorbed / produced for each generators, voltage angle and magnitude + at each side of each powerline etc.) +5. pass all the data from the underlying data structure (*eg* "convert" from Eigen -a c++ library- vectors to numpy array if you use lightsim2grid) +6. check for possible terminal conditions that would stop the grid2op episode + +Not all backends are forced to behave this way. For example, some backend might be initialized without first running a DC powerflow. +Similarly, steps 5. and 6. above are not mandatory and can be done elsewhere in the code. + +For lightsim2grid and pandapower (default implementation) all these steps are performed inside the `backend.runpf` function which makes them +comparable to this regard. + +At this stage, we know that `grid2op 'backend.runpf' time (ms)` corresponds to all the time spent in 2e including all +the time to perform 2e1, 2e2, 2e3, 2e4, 2e5 and 2e6. + +For pandapower and lightsim2grid, `solver powerflow time (ms)` does not report time spent on 2e3 (point 3. above: as you might +remember this is a zoom into the 2e. steps of grid2op) but only a part of it (explained in the next, and last, section) + +.. note:: + As of grid2op 1.11.0 (still in development when this page is being written), some check will disappear from pandapower and from lightsim2grid, especially + things checked at 2e6 (point 6 above) + + +Some vocabulary is still needed to explain some concepts of these benchmarks, especially the different solver used or the "recycling" term. For this, +we need to dive deeper into the step 3 above (so diving deeper into 2e3 if we label things from the grid2op perspective). + +Physical solver in a nutshell +------------------------------ + +All of the solvers (to solve these type of problems) that we are aware of actually have themselves two (or more) "layers". + +There is the "external layer" that can be accessed and modified more or less easily. When these "physical solvers" perform powerflows (so the step 2e3, +*ie* solving the KCL in AC), they will often : + +a. perform some check on the external layer / data model +b. convert this "external model" / "data model" into something that can be processed by an algorithm +c. run this algorithm +d. convert back the results into the "external layer" / "data model" so that user can easily access it + + +Now we can properly explain what is reported on the column `solver powerflow time (ms)` : + +- for pandapower and lightsim2grid, it gives only the time spent in 2e3c (thus removing all the time spent in a, b and d). More precisely: + + - in lightsim2grid backend this is retrieved with `env.backend._grid.get_computation_time()` + - in pandapower backend it is obtained with `env.backend._grid["_ppc"]["et"]` +- for pypowsybl unfortunately, we do not have that much detail at hand. So the time reportd in `solver powerflow time (ms)` will + include all steps 2e3a, 2e3b, 2e3c and 2e3d. + +In the pages :ref:`benchmark-solver` and :ref:`benchmark-grid-size` the concept of `recycling` is used without a proper definition. With this view +of the physical solver, we can start to explain it. A first type of "recycling" is to reuse previous data when the conversion between a. and b. happens. +This can saves a lot of time. + +For example, this can mean that: + +- if the topology is not changed, then the `Ybus` matrix will not be recomputed +- if the injection is not changed, then the `Sbus` vector will not be recomputed +- memory will not be deallocated / reallocated between b -> c or between c -> d if possible (to save sometimes expensive system calls) +- some checks will not be done if the underlying data are not modified (skipping partially or totally step a) +- the algorithm (see next section) itself can "know" what part of its data can be reuse avoiding even further unnecessary computations + (that is the "recycle property" can be also forwarded to the solver) +- etc. + +.. note:: + The split of a "physical solver" into these 4 steps holds for different "solvers", for example: + + - `pandapower`, where the "external layer" / "data model" consists of the dataframes reprensenting the grid + (*eg* `pp_net.line`, `pp_net.load`, etc. when for the tables that can be modified or + `pp_net.res_line`, `pp_net.res_load`, etc. for read-only attribute) + - `pypowsybl` where the "external layer" also consists of dataframes, accessible with `pp_grid.get_lines()` or `pp_grid.get_loads()` + and the data within this model can be modified with `pp_grid.update_lines` or `pp_grid.update_loads` + - `lightsim2grid` where the "gridmodel" data can be inspected with *eg* `gridmodel.get_lines()` or `gridmodel.get_loads()` And + modified with `gridmodel.update_loads_p`, `gridmodel.update_loads_q` or `gridmodel.update_topology` + + +.. note:: + The stopping criteria of the algorithm might slightly differ depending on the "physical solver" used. We made sure that the + same stopping criteria is used for pandapower and lightsim2grid but it might differ for other solver. + +Algorithm in a nutshell +--------------------------- + +This is the last step useful to understand the benchmarks performed (at time of writing) in lightsim2grid and it aims at +understanding the last part of the `recycling` mecanism as well as questions like "why is there so much different rows in the solver benchmarks ?" + +There are different algorithms to solve the AC KCL (the operation performed at step `2e3c`) among which: + +- Gauss Seidel +- Newton Raphson +- Fast Decoupled + +When we benchmark lightsim2grid in the page :ref:`benchmark-solver` all 3 algorithms are tested, and for the page +:ref:`benchmark-grid-sizebenchmark-grid-size` only Newton-Raphson algorithm is used (if you are interested in more, please +let us know, no problem at all). + +Pandapower +++++++++++++ + +When pandapower is benchmarked, only the Newton-Raphson algorithm is used, we will not detail the exact implementatoin of pandapower. Its implementation +the python scipy package to perform the linear algebra operations needed. + +Pypowsybl +++++++++++++ + +When pypowsybl is benchmarked, only the Newton-Raphson algorithm is used. It internally uses some java implementation relying on powsybl framework and +open-loadflow (for the default parameters) powerflow. + +Let us know if you are interested with more detail and more algorithm (powsybl can do much more than what is exposed here). + +Lightsim2grid +++++++++++++++ + +In the benchmarks, lightsim2grid counts the most reported algorithms. In this section we detail a "concisely" the bahviour all some of them. + +Gauss Seidel +~~~~~~~~~~~~~ + +Lightsim2grid comes with two different Gauss-Seidel algorithms. They are not very efficient for the kind of problem at hand, so +we will not spend lot of time discussing them here. + + +Fast decoupled and Newton Raphson +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These two types of algorithms comes each with different variants (we will not enter into too their detail here): + +- Newton-Raphson with a single slack bus +- Newton-Raphson with a distributed slack bus +- Fast Decoupled 'BX' +- Fast Decoupled 'XB' + +And for each of these algorithm, at some point linear systems in the form of "Ax = b" (A and b known, solve for x, A being a sparse matrix) +needs to be solved repeatedly. They can be decomposed in different steps (as always in this page +without entering into detail and with lots of simplifications): + +1. perform some initial checks (to make sure data are consistent) +2. initialize the linear solver (require to allocate some memory, create some vectors, etc.) +3. repeat : + 1. check if maximum number of allowed iterations is reached (divergence) if so, stop + 2. check if the stopping criteria are met (convergence) if so, stop + 3. update a linear system based on the value of complex voltages at each buses + 4. solve the new linear system + 5. update the complex voltages at each buses + +.. note:: + For Fast Decoupled method, actually two different types of update are made, this would be equivalent to having + first do "3 and 4" and then do "3' and 4'" before finally updating the complex vector at step 5 + +.. note:: + The checks 1. and 2. might actually happen at the end of the loop (so after 5. in this case) depending on the algorithm + but this does not change the message. + +TODO recycling !!! \ No newline at end of file diff --git a/docs/benchmarks_grid_sizes.rst b/docs/benchmarks_grid_sizes.rst index dd890aa..52896c8 100644 --- a/docs/benchmarks_grid_sizes.rst +++ b/docs/benchmarks_grid_sizes.rst @@ -1,3 +1,4 @@ +.. _benchmark-grid-size: Benchmarks (grid size) ====================== @@ -7,7 +8,7 @@ The code to run these benchmarks are given with this package int the [benchmark] TODO DOC in progress -If you are interested in other type of benchmark, let us know ! +If you are interested in other type of benchmarks, let us know ! TL;DR ------- @@ -100,7 +101,7 @@ make use of all the available cores, which would increase the number of steps th Computation time using grid2op ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This benchmark in doing repeat calls to `env.step(do_nothing)` (usually 288) for a given environment build +This benchmark in doing repeat calls to `env.step(do_nothing)` (usually 288 or 1000) for a given environment build on a grid coming from data available in pandapower. Then we compare different measurments: @@ -129,6 +130,10 @@ Then we compare different measurments: - `time in 'pf algo' (ms / pf)` gives the time spent in the algorithm that computes the AC powerflow only +.. warning:: + For more information about what is actually done and the wordings used in this section, + you can consult the page :ref:`benchmark-deep-dive` + The results are given in two tables: - the first one corresponds to the default settings were lightsim2grid is allowed to "recycle" previous diff --git a/docs/index.rst b/docs/index.rst index aae3a5e..f9c183c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,12 +28,20 @@ As from version 0.5.3: quickstart disclaimer use_with_grid2op - benchmarks - benchmarks_grid_sizes use_solver rewards physical_law_checker +Benchmarkings +---------------- + +.. toctree:: + :maxdepth: 2 + :caption: Benchmarks + + benchmarks + benchmarks_grid_sizes + benchmarks_dive Technical Documentation (work in progress) ------------------------------------------- From 8dc0fc2425a983843dbb0b27277fde6355bd55fc Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 6 Jan 2025 20:44:37 +0100 Subject: [PATCH 07/15] fix benchmark text and add pypowsybl [skip ci] Signed-off-by: DONNOT Benjamin --- docs/benchmarks.rst | 152 +++++++++++++++++++++++++------------------- 1 file changed, 88 insertions(+), 64 deletions(-) diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 7bb7e65..08e0cbe 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -122,6 +122,11 @@ in the documentation. Some license might be needed. All benchmarks where done with all the customization (for speed, *eg* `-O3` and `-march=native` for linux). See the readme for more information. +.. warning:: + At time of writing only a development version of the powsybl backend was available. We will update these figures when + the first version will be available. + + Computation time ~~~~~~~~~~~~~~~~~~~ @@ -159,104 +164,123 @@ First on an environment based on the IEEE case 14 grid: ==================== ====================== =================================== ============================ case14_sandbox grid2op speed (it/s) grid2op 'backend.runpf' time (ms) solver powerflow time (ms) ==================== ====================== =================================== ============================ -PP 138 5.98 2.66 -PP (no numba) 90.2 9.77 6.29 -PP (with lightsim) 130 6.39 1.44 -GS 1470 0.314 0.266 -GS synch 1410 0.348 0.3 -NR single (SLU) 2200 0.0844 0.0346 -NR (SLU) 2200 0.0849 0.0353 -NR single (KLU) 2390 0.0581 0.0101 -NR (KLU) 2380 0.058 0.0098 -NR single (NICSLU *) 2400 0.0579 0.01 -NR (NICSLU *) 2380 0.0579 0.00976 -NR single (CKTSO *) 2410 0.0573 0.00949 -NR (CKTSO *) 2380 0.0575 0.00926 -FDPF XB (SLU) 2360 0.063 0.0152 -FDPF BX (SLU) 2330 0.0697 0.0221 -FDPF XB (KLU) 2370 0.0603 0.0125 -FDPF BX (KLU) 2350 0.0662 0.0185 -FDPF XB (NICSLU *) 2370 0.0603 0.0125 -FDPF BX (NICSLU *) 2370 0.0659 0.0185 -FDPF XB (CKTSO *) 2390 0.0601 0.0126 -FDPF BX (CKTSO *) 2360 0.0662 0.0185 +PP 127 6.47 2.86 +PP (no numba) 85.6 10.3 6.68 +PP (with lightsim) 121 7 1.47 +pypowsybl 760 0.895 0.87 +GS 1570 0.313 0.263 +GS synch 1480 0.352 0.302 +NR single (SLU) 2380 0.0868 0.0346 +NR (SLU) 2370 0.0881 0.0355 +NR single (KLU) 2580 0.0606 0.0102 +NR (KLU) 2580 0.0604 0.00977 +NR single (NICSLU *) 2590 0.0607 0.0102 +NR (NICSLU *) 2590 0.0604 0.00977 +NR single (CKTSO *) 2610 0.0597 0.00962 +NR (CKTSO *) 2630 0.0589 0.00917 +FDPF XB (SLU) 2550 0.066 0.0155 +FDPF BX (SLU) 2510 0.0727 0.0224 +FDPF XB (KLU) 2580 0.0631 0.0127 +FDPF BX (KLU) 2540 0.069 0.0188 +FDPF XB (NICSLU *) 2590 0.0626 0.0127 +FDPF BX (NICSLU *) 2550 0.0688 0.0187 +FDPF XB (CKTSO *) 2610 0.0623 0.0128 +FDPF BX (CKTSO *) 2420 0.0727 0.0198 ==================== ====================== =================================== ============================ +From a grid2op perspective, lightsim2grid allows to compute up to ~2600 steps each second (column `grid2op speed`, rows `NR XXX`) on the case 14 and +"only" ~120 for the default PandaPower Backend (column `grid2op speed`, row `PP`), leading to a speed up of **~21** (2600 / 120) in this case +(lightsim2grid Backend is ~21 times faster than pandapower Backend when comparing grid2op speed). -From a grid2op perspective, lightsim2grid allows to compute up to ~2400 steps each second (column `grid2op speed`, rows `NR XXX`) on the case 14 and -"only" ~140 for the default PandaPower Backend (column `grid2op speed`, row `PP`), leading to a speed up of **~17** (2400 / 140) in this case -(lightsim2grid Backend is ~17 times faster than pandapower Backend when comparing grid2op speed). +When compared to powsybl (with the pypowsybl backend), lightsim2grid (with newton raphson) is around 4 times faster (760 vs 2600). For such a small environment, there is no sensible difference in using `KLU` linear solver (rows `NR single (KLU)` or `NR (KLU)`) compared to using the SparseLU solver of Eigen (rows `NR single (SLU)` or `NR (SLU)`) -(2200 vs 2380 iterations on the reported runs, might slightly vary across runs). +(2380 vs 2610 iterations on the reported runs, might slightly vary across runs). `KLU`, `NICSLU` and `CKTSO` achieve almost identical performances, at least we think the observed differences are within error margins. There are also very little differences between non distributed slack (`NR Single` rows) and distributed slack (`NR` rows) for all of the linear solvers. -Finally, the "fast decoupled" methods also leads to equivalent performances for almost all linear solvers. +Finally, the "fast decoupled" methods also leads to equivalent performances for almost all linear solvers and are slightly +less performant than the Newton Raphson one. + +For this small environment, for lightsim2grid backend (and if we don't take into account the "agent time"), the computation time +is vastly dominated by factor external to the powerflow solver. Indeed, doing a 'env.step' (column `grid2op speed (it/s)`) +takes 0.38ms (`1. / 2600. * 1000.`) on average and on this 380 ns (or 0.38ms), only +9.7 ns are spent in the backend. Meaning that 370 ns are spent in the grid2op extra layer in this case (97% of the computation time +- `=370 / 380`- is external to the powerflow solver) Then on an environment based on the IEEE case 118: ===================== ====================== =================================== ============================ neurips_2020_track2 grid2op speed (it/s) grid2op 'backend.runpf' time (ms) solver powerflow time (ms) ===================== ====================== =================================== ============================ -PP 115 7.38 3.84 -PP (no numba) 78.1 11.4 7.85 -PP (with lightsim) 114 7.42 1.85 -GS 7.13 140 139 -GS synch 43.1 22.6 22.6 -NR single (SLU) 1070 0.491 0.419 -NR (SLU) 956 0.513 0.44 -NR single (KLU) 1840 0.131 0.0663 -NR (KLU) 1850 0.128 0.0631 -NR single (NICSLU *) 1850 0.129 0.0636 -NR (NICSLU *) 1870 0.124 0.0589 -NR single (CKTSO *) 1850 0.126 0.061 -NR (CKTSO *) 1870 0.121 0.0561 -FDPF XB (SLU) 1720 0.179 0.117 -FDPF BX (SLU) 1650 0.198 0.135 -FDPF XB (KLU) 1780 0.159 0.0965 -FDPF BX (KLU) 1730 0.176 0.114 -FDPF XB (NICSLU *) 1760 0.16 0.0969 -FDPF BX (NICSLU *) 1720 0.175 0.112 -FDPF XB (CKTSO *) 1770 0.159 0.0972 -FDPF BX (CKTSO *) 1720 0.175 0.112 +PP 108 7.8 4.1 +PP (no numba) 73.3 12.3 8.53 +PP (with lightsim) 103 8.32 1.95 +pypowsybl 304 2.78 2.72 +GS 7.22 138 138 +GS synch 43.5 22.6 22.5 +NR single (SLU) 1110 0.497 0.421 +NR (SLU) 1080 0.517 0.44 +NR single (KLU) 1950 0.135 0.0661 +NR (KLU) 1960 0.132 0.0627 +NR single (NICSLU *) 1960 0.131 0.0627 +NR (NICSLU *) 1960 0.128 0.0598 +NR single (CKTSO *) 1970 0.129 0.0607 +NR (CKTSO *) 1980 0.125 0.0562 +FDPF XB (SLU) 1810 0.182 0.116 +FDPF BX (SLU) 1740 0.201 0.135 +FDPF XB (KLU) 1890 0.161 0.0961 +FDPF BX (KLU) 1830 0.175 0.11 +FDPF XB (NICSLU *) 1880 0.16 0.0953 +FDPF BX (NICSLU *) 1820 0.176 0.111 +FDPF XB (CKTSO *) 1870 0.161 0.0957 +FDPF BX (CKTSO *) 1810 0.178 0.112 ===================== ====================== =================================== ============================ For an environment based on the IEEE 118, the speed up in using lightsim + KLU (LS+KLU) is **~17** time faster than -using the default `PandaPower` backend (~1900 it/s vs ~120 for pandapower with numba). +using the default `PandaPower` backend (~1900 it/s vs ~110 for pandapower with numba). -The speed up of lightsim + SparseLU (`1070` it / s) is a bit lower (than using KLU, CKTSO or NICSLU), but it is still **~9** -times faster than using the default backend. +When compared to powsybl (with the pypowsybl backend), lightsim2grid (with newton raphson) is around **7** times faster (300 vs 1900). -For this environment the `LS+KLU` solver (solver powerflow time) is ~5-6 times faster than the `LS+SLU` solver -(`0.419` ms per powerflow for `LS+SLU` compared to `0.0631` ms for `LS+KLU`), but it only translates to `LS+KLU` -providing ~70% more iterations per second in the total program (`1070` vs `1850`) mainly because grid2op itself takes some times to modify the -grid and performs some consistency cheks. For this testcase once again there is no noticeable difference between -`NICSLU`, `CKTSO` and `KLU`. +The speed up of lightsim + SparseLU (`1100` it / s) is a bit lower (than using KLU, CKTSO or NICSLU), but it is still **~10** +times faster than using the default backend (1100 / 110). + +For this environment the `LS+KLU` solver (solver powerflow time) is ~6-7 times faster than the `LS+SLU` solver +(`0.421` ms per powerflow for `LS+SLU` compared to `0.0627` ms for `LS+KLU`), but it only translates to `LS+KLU` +providing ~70% more iterations per second in the total program (`1100` vs `1950`) mainly because grid2op itself takes some times to modify the +grid and performs some consistency cheks. + +For this test case once again there is no noticeable difference between `NICSLU`, `CKTSO` and `KLU`. If we look now only at the time to compute one powerflow (and don't take into account the time to load the data, to initialize the solver, to modify the grid, read back the results, to perform the other update in the grid2op environment etc. -- *ie* looking at the column "`solver powerflow time (ms)`") -we can notice that it takes on average (over 1000 different states) approximately **0.0631ms** -to compute a powerflow with the LightSimBackend (if using the `KLU` linear solver) compared to the **3.84 ms** when using -the `PandaPowerBackend` (with numba, but without lightsim2grid) (speed up of **~60** times) +we can notice that it takes on average (over 1000 different states) approximately **0.0627** +to compute a powerflow with the LightSimBackend (if using the `KLU` linear solver) compared to the **4.1 ms** when using +the `PandaPowerBackend` (with numba, but without lightsim2grid) (speed up of **~65** times) -**NB** pandapower performances heavily depends on the pandas version used, we used here the latest avaialble version -at the time of the benchmark. +For this small environment, once again, for lightsim2grid backend (and if we don't take into account the "agent time"), the computation time +is vastly dominated by factor external to the powerflow solver. Indeed, doing a 'env.step' (column `grid2op speed (it/s)`) +takes 0.53ms (`1. / 1900. * 1000.`) on average and on this 530 ns (or 0.53ms), only +63 ns are spent in the backend. Meaning that 470 ns are spent in the grid2op extra layer in this case (~90% of the computation time +- `=470 / 530`- is external to the powerflow solver) .. note:: The "solver powerflow time" reported for pandapower is obtained by summing, over the 1000 powerflow performed - the `pandapower_backend._grid["_ppc"]["et"]` (the "estimated time" of the pandapower newton raphson computation - with the numba accelaration enabled) + the `pandapower_backend._grid["_ppc"]["et"]` (the "estimated time" of the pandapower newton raphson computation) - For the lightsim backend, the "solver powerflow time" corresponds to the sum of the results of + For the lightsim backend, the "solver powerflow time" corresponds to the average of the results of `gridmodel.get_computation_time()` function that, for each powerflow, returns the time spent in the solver uniquely (time inside the `basesolver.compute_pf()` function. In particular it do not count the time - to initialize the vector V with the DC approximation) + to initialize the vector `V` with the DC approximation nor the time taken to convert the lightsim2grid external modeling + to something that can be processed by the powerflow algorithm). This is a behaviour similar to the one of pandapower. + + For pypowsybl backend, the column `solver powerflow time (ms)` also counts the time to convert the data from the + iidm modeling to the data model used by the "Open Load Flow" powerflow solver. More information ~~~~~~~~~~~~~~~~~ From a25a433cf34673f427cf626ab3ec206d37313630 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jan 2025 15:06:47 +0100 Subject: [PATCH 08/15] improve the benchmark file [skip ci] Signed-off-by: DONNOT Benjamin --- docs/benchmarks.rst | 46 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 08e0cbe..fdefa4f 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -10,21 +10,60 @@ TODO DOC in progress If you are interested in other type of benchmark, let us know ! +.. note:: + Benchmarks performed here does not reflect performance of your usage of grid2op. In particular, there is no "agent", + the time to "reset" the environment is not taken into account etc. + + The objective is to assess the performance of lightsim2grid (compared to pandapower and pypowsybl) when used as + a grid2op backend. Hence all timings exclude part of the code not related with this goal. + +TL;DR +--------- + +The default pandapower backend is the slowest one. + +Pypowsybl is, for this benchmark, always 3-4 times faster than pandapower (*ie* it allows to perform +3 times more grid2op iteration in the same amount of time). + +Lightsim2grid is ~20 times faster than pandapower (it allows to perform 20 times more interation +for the same amount of time). + +When diving into lightsim2grid, the faster resolution method is "Newton-Raphson" (with or without distributed slack), +closely followed by the "Fast Decoupled Powerflow" method. + +Concerning the linear solver used for the lightsim2grid backends, it has little to no impact if you use KLU, NICSLU or +CKTSO. To avoid license issues, we then recommend to use the KLU linear solver (if you compiled lightsim2grid from source, +we strongly recommend you to perform its installation too). + +The default linear solver in Eigen (called here Sparse LU) is much slower than KLU and you should avoid to use it if possible +(it's probably better to take some more time to build lightsim2grid with KLU). +But if you don't have the choice, SparseLU still give better performance than pypowsybl or pandapower. + +We do not recommend to use Gauss Seidel method (much slower than everything else). + +.. note:: + More time have been spent to optimize Newton-Raphson algorithm in lightsim2grid than to optimize Gauss Seidel or + Fast Decoupled method. The results here should be extrapolate outside of lightsim2grid usage. + + At time of writing only a prelimary version of the backend based on pypowsybl was avaialble. + Using a grid2op environment ---------------------------- + In this section we perform some benchmark of a `do nothing` agent to test the raw performance of lightsim2grid -compared with pandapower when using grid2op. +compared with pandapower and pypowsybl when using grid2op. All of them has been run on a computer with a the following characteristics: -- date: 2024-12-22 18:05 CET - system: Linux 6.5.0-1024-oem - OS: ubuntu 22.04 - processor: 13th Gen Intel(R) Core(TM) i7-13700H -- python version: 3.12.8.final.0 (64 bit) +- python version: 3.9.21.final.0 (64 bit) - numpy version: 1.26.4 - pandas version: 2.2.3 - pandapower version: 2.14.10 +- pywposybl version: 1.9.0.dev1 +- pypowsybl2grid version: 0.1.0 - grid2op version: 1.10.5 - lightsim2grid version: 0.10.0 - lightsim2grid extra information: @@ -35,7 +74,6 @@ All of them has been run on a computer with a the following characteristics: - compiled_march_native: True - compiled_o3_optim: True - To run the benchmark `cd` in the [benchmark](./benchmarks) folder and install the dependencies (we suppose here that you have already installed lightsim2grid): From 1a0097dcdd00cca94c771aa8681bf76405b496f3 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 7 Jan 2025 17:43:53 +0100 Subject: [PATCH 09/15] still improving the lightsim2grid docs on benchmark, almost there [skip ci] Signed-off-by: DONNOT Benjamin --- benchmarks/utils_benchmark.py | 7 +-- docs/benchmarks.rst | 4 +- docs/benchmarks_dive.rst | 86 +++++++++++++++++++++++----- docs/benchmarks_grid_sizes.rst | 4 +- docs/img/benchmarks_explanation.jpg | Bin 0 -> 130757 bytes docs/img/grid2op_layer.jpg | Bin 0 -> 22383 bytes 6 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 docs/img/benchmarks_explanation.jpg create mode 100644 docs/img/grid2op_layer.jpg diff --git a/benchmarks/utils_benchmark.py b/benchmarks/utils_benchmark.py index 88281b1..cdd72fb 100644 --- a/benchmarks/utils_benchmark.py +++ b/benchmarks/utils_benchmark.py @@ -12,6 +12,7 @@ from tqdm import tqdm import argparse import datetime +import importlib from grid2op.Environment import MultiMixEnvironment import pdb @@ -186,12 +187,10 @@ def print_configuration(pypow_error=True): res.append(tmp) print(tmp) if pypow_error is None: - import pypowsybl as pypo - tmp = (f"- pywposybl version: {pypo.__version__}") + tmp = (f"- pywposybl version: {importlib.metadata.version('pypowsybl')}") res.append(tmp) print(tmp) - import pypowsybl2grid - tmp = (f"- pypowsybl2grid version: {pypowsybl2grid.__version__}") + tmp = (f"- pypowsybl2grid version: {importlib.metadata.version('pypowsybl2grid')}") res.append(tmp) print(tmp) diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index fdefa4f..60cf64b 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -97,7 +97,7 @@ and then you can start the benchmark with the following commands: (we remind that these simulations correspond to simulation on one core of the CPU. Of course it is possible to make use of all the available cores, which would increase the number of steps that can be performed) -We compare up to 19 different "solvers" (combination of "linear solver used" (*eg* Eigen, KLU, CKTSO, NICSLU) +We compare up to 20 different "solvers" (combination of "linear solver used" (*eg* Eigen, KLU, CKTSO, NICSLU) and powerflow algorithm (*eg* "Newton Raphson", or "Fast Decoupled")): - **PP**: PandaPowerBackend (default grid2op backend) which is the reference in our benchmarks (uses the numba @@ -168,7 +168,7 @@ See the readme for more information. Computation time ~~~~~~~~~~~~~~~~~~~ -In this first subsection we compare the computation times: +In this first subsection we compare the computation times (detailed explanation on page :ref:`benchmark-deep-dive`): - **grid2op speed** from a grid2op point of view (this include the time to compute the powerflow, plus the time to modify diff --git a/docs/benchmarks_dive.rst b/docs/benchmarks_dive.rst index 56f67e0..afce858 100644 --- a/docs/benchmarks_dive.rst +++ b/docs/benchmarks_dive.rst @@ -1,3 +1,6 @@ +.. |grid2op_layer| image:: ./img/grid2op_layer.jpg +.. |benchmarks_explanation| image:: ./img/benchmarks_explanation.jpg + .. _benchmark-deep-dive: Deep dive into the benchmarking of lightsim2grid @@ -7,6 +10,53 @@ At various occasion, we benchmark lightsim2grid with other available solvers. In most cases, we benchmark them using the `grid2op` package. In this section we briefly explain what happens and how to interpret the different figures and tables. +TL;DR +------ + +Grid2op can be really shematically summarized by: + +|grid2op_layer| + +In our benchmarks (in lightsim2grid) we only consider the "2. perform the steps" phase, the other two steps are not monitored at all +because they are irrelevant to the benchmarking of lightsim2grid. + +Then we report the average (accross the 288 or 1000 steps, depending on the benchmark) of the execution time of different quantities +(see table below for more information): + +|benchmarks_explanation| + +For :ref:`benchmark-solvers` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- **grid2op speed (it/s)** : average of the time taken for 1 step accross the "2. for each steps" +- **grid2op 'backend.runpf' time (ms)** , average of the time taken to compute: + + - for pandapower and lightsim2grid: `2e.`, `2f.` and `2g.` + - for pypowsybl: `2e.` and `2f.` +- **solver powerflow time (ms)** : + - for pandapower and lightsim2grid: `2e3c` + - for pypowsybl: `2e3` + +For :ref:`benchmark-grid-size` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In the "TL;DR" section: + +- **time (recycling)** +- **time (no recycling)** +- **time (TimeSerie)** +- **time (ContingencyAnalysis)** + +In the "Computation time using grid2op" section: + +- **avg step duration (ms)** +- **time [DC + AC] (ms / pf)** +- **speed (pf / s)** +- **time in 'gridmodel' (ms / pf)** +- **time in 'pf algo' (ms / pf)** + +toto + Grid2op in a nutshell ---------------------- @@ -48,7 +98,7 @@ This section will detail the steps 2e. of the overview of the previous section, The goal of this 2e. steps is to find the solution to the KCL for each buses of the grid. This is done thanks to a "solver". This solver is "wrapped" to grid2op with what is called a "grid2op backend". This is why there are 2 columns dedicated to this -"step 2e." in the page :ref:`benchmark-solver` for example, the column `grid2op 'backend.runpf' time (ms)` counts all the computation +"step 2e." in the page :ref:`benchmark-solvers` for example, the column `grid2op 'backend.runpf' time (ms)` counts all the computation happening in steps 2e. (and depending on the implementation - this is the case with lightsim2grid- even step 2f. and 2g.) whereas the column `solver powerflow time (ms)` only counts thet time spent in the "solver" for the AC powerflow. @@ -57,12 +107,12 @@ What is happening in step 2e. (basically in the `backend.runpf` function) is: 1. before some basic initial steps (*eg* the connexity of the grid) 2. find some initial solution for the complex voltage at each bus (this is done with the Direct Current approximation in pandapower and lightsim2grid) -3. start the resolution of the KCL in AC (for pandapower and lightsim2grid in most benchmarks) -4. derives all the others quantities from that (active and reactive flows on each line, +3. start the resolution of the KCL in AC (for pandapower and lightsim2grid in most benchmarks) and + derives all the others quantities from that (active and reactive flows on each line, current flow on each line, reactive power absorbed / produced for each generators, voltage angle and magnitude at each side of each powerline etc.) -5. pass all the data from the underlying data structure (*eg* "convert" from Eigen -a c++ library- vectors to numpy array if you use lightsim2grid) -6. check for possible terminal conditions that would stop the grid2op episode +4. pass all the data from the underlying data structure (*eg* "convert" from Eigen -a c++ library- vectors to numpy array if you use lightsim2grid) +5. check for possible terminal conditions that would stop the grid2op episode Not all backends are forced to behave this way. For example, some backend might be initialized without first running a DC powerflow. Similarly, steps 5. and 6. above are not mandatory and can be done elsewhere in the code. @@ -71,7 +121,7 @@ For lightsim2grid and pandapower (default implementation) all these steps are pe comparable to this regard. At this stage, we know that `grid2op 'backend.runpf' time (ms)` corresponds to all the time spent in 2e including all -the time to perform 2e1, 2e2, 2e3, 2e4, 2e5 and 2e6. +the time to perform 2e1, 2e2, 2e3, 2e4 and 2e5. For pandapower and lightsim2grid, `solver powerflow time (ms)` does not report time spent on 2e3 (point 3. above: as you might remember this is a zoom into the 2e. steps of grid2op) but only a part of it (explained in the next, and last, section) @@ -107,7 +157,7 @@ Now we can properly explain what is reported on the column `solver powerflow tim - for pypowsybl unfortunately, we do not have that much detail at hand. So the time reportd in `solver powerflow time (ms)` will include all steps 2e3a, 2e3b, 2e3c and 2e3d. -In the pages :ref:`benchmark-solver` and :ref:`benchmark-grid-size` the concept of `recycling` is used without a proper definition. With this view +In the pages :ref:`benchmark-solvers` and :ref:`benchmark-grid-size` the concept of `recycling` is used without a proper definition. With this view of the physical solver, we can start to explain it. A first type of "recycling" is to reuse previous data when the conversion between a. and b. happens. This can saves a lot of time. @@ -149,8 +199,8 @@ There are different algorithms to solve the AC KCL (the operation performed at s - Newton Raphson - Fast Decoupled -When we benchmark lightsim2grid in the page :ref:`benchmark-solver` all 3 algorithms are tested, and for the page -:ref:`benchmark-grid-sizebenchmark-grid-size` only Newton-Raphson algorithm is used (if you are interested in more, please +When we benchmark lightsim2grid in the page :ref:`benchmark-solvers` all 3 algorithms are tested, and for the page +:ref:`benchmark-grid-size` only Newton-Raphson algorithm is used (if you are interested in more, please let us know, no problem at all). Pandapower @@ -194,12 +244,12 @@ needs to be solved repeatedly. They can be decomposed in different steps (as alw without entering into detail and with lots of simplifications): 1. perform some initial checks (to make sure data are consistent) -2. initialize the linear solver (require to allocate some memory, create some vectors, etc.) +2. initialize the linear solver (require to allocate some memory, create some vectors, copy some data around) 3. repeat : 1. check if maximum number of allowed iterations is reached (divergence) if so, stop 2. check if the stopping criteria are met (convergence) if so, stop - 3. update a linear system based on the value of complex voltages at each buses - 4. solve the new linear system + 3. update the linear system(s) based on the value of complex voltages at each buses + 4. solve the new linear system(s) 5. update the complex voltages at each buses .. note:: @@ -210,4 +260,14 @@ without entering into detail and with lots of simplifications): The checks 1. and 2. might actually happen at the end of the loop (so after 5. in this case) depending on the algorithm but this does not change the message. -TODO recycling !!! \ No newline at end of file +In lightsim2grid, at time of writing, you can use different linear solver to solve the linear systems at step 3.4 above. This is why you +have different options like "FDPF XB (SLU)" and "FDPF XB (KLU)": the same algorithm is used (Fast Decoupled Powerflow, XB variant) but +it internally uses respectively SLU and KLU to solver the linar system at step 3. + +This is why there are 16 rows in the tables at section :ref:`benchmark-solvers` : 4 different +powerflow algorithms each combined with 4 different linear solvers. + +Finally, in lightsim2grid the "recycling" takes also place at this steps. For example, the "intial steps" (1.) or the "initializing" (2.) of the linear +solver might not be performed if not needed. Similarly, steps 3.3 and 3.4 might be faster if "recycling" is allowed. For example, for the +KLU linear solver, the first system solved takes longer than the others. Hence, if the solver can be reused the "slower first iteration" is +done only once, leading to a greater throughput overall. diff --git a/docs/benchmarks_grid_sizes.rst b/docs/benchmarks_grid_sizes.rst index 52896c8..9877393 100644 --- a/docs/benchmarks_grid_sizes.rst +++ b/docs/benchmarks_grid_sizes.rst @@ -37,8 +37,8 @@ and the timings here are averaged accross all the powerflows performed) For detailed explanation about each column as well as the hardware used, please refer to the section below, but in summary: -- benchmark were run on python 3.12 with an old laptop (i7-6820HQ CPU @ 2.70GHz from 2015) see section :ref:`bench_grid_size_hardware` - for more information +- benchmark were run on python 3.12 with an old laptop (i7-6820HQ CPU @ 2.70GHz from 2015) (see section :ref:`bench_grid_size_hardware` + and page :ref:`benchmark-deep-dive` for more information about the exact definition of the timers ): - `time (recycling)` indicates the average time it took to run 1 powerflow (with consecutive run of 288 powerflows) while allowing lighsim2grid to re use some basic previous computation from one powerflow to another. This is the most consommations usecase in grid2op for example (default behaviour). See :ref:`bench_grid_size_glop` for more information diff --git a/docs/img/benchmarks_explanation.jpg b/docs/img/benchmarks_explanation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..60cc834ee52c814ba083ee6a328df94f769be4ec GIT binary patch literal 130757 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 z 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:+Z|(E*dcCIW%2e*5B0r>A+Shv+ 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 literal 0 HcmV?d00001 diff --git a/docs/img/grid2op_layer.jpg b/docs/img/grid2op_layer.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4f335376180147e52f6ccd00bab9e59c61c62533 GIT binary patch literal 22383 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 literal 0 HcmV?d00001 From af701dc71ae0d115dfaa02a94f289042ce62207c Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 8 Jan 2025 14:20:01 +0100 Subject: [PATCH 10/15] adding a function to compute a dc powerflow that can be used from pandapower, refactoring the 'newtonpf' function, improve doc Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_grid_size.py | 4 +- benchmarks/benchmark_solvers.py | 6 +- docs/benchmarks.rst | 170 +++++++++--------- docs/benchmarks_dive.rst | 35 ++-- docs/benchmarks_grid_sizes.rst | 8 +- lightsim2grid/newtonpf/__init__.py | 4 +- lightsim2grid/pandapower_compat/__init__.py | 12 ++ .../pandapower_compat/dcpf/__init__.py | 11 ++ lightsim2grid/pandapower_compat/dcpf/_dcpf.py | 65 +++++++ .../pandapower_compat/newtonpf/__init__.py | 11 ++ .../newtonpf/_newtonpf.py} | 27 +-- 11 files changed, 233 insertions(+), 120 deletions(-) create mode 100644 lightsim2grid/pandapower_compat/__init__.py create mode 100644 lightsim2grid/pandapower_compat/dcpf/__init__.py create mode 100644 lightsim2grid/pandapower_compat/dcpf/_dcpf.py create mode 100644 lightsim2grid/pandapower_compat/newtonpf/__init__.py rename lightsim2grid/{newtonpf/newtonpf.py => pandapower_compat/newtonpf/_newtonpf.py} (93%) diff --git a/benchmarks/benchmark_grid_size.py b/benchmarks/benchmark_grid_size.py index 5269b3f..880f8e3 100644 --- a/benchmarks/benchmark_grid_size.py +++ b/benchmarks/benchmark_grid_size.py @@ -422,8 +422,8 @@ def run_grid2op_env(env_lightsim, case, reset_solver, "avg step duration (ms)", "time [DC + AC] (ms / pf)", "speed (pf / s)", - "time in 'gridmodel' (ms / pf)", - "time in 'pf algo' (ms / pf)", + "time in 'solver' (ms / pf)", + "time in 'algo' (ms / pf)", ], tablefmt="rst") print(res_use_with_grid2op_2) diff --git a/benchmarks/benchmark_solvers.py b/benchmarks/benchmark_solvers.py index ff869d2..d5bc84c 100644 --- a/benchmarks/benchmark_solvers.py +++ b/benchmarks/benchmark_solvers.py @@ -234,7 +234,7 @@ def main(max_ts, this_order = [el for el in res_times.keys() if el not in order_solver_print] + order_solver_print env_name = get_env_name_displayed(env_name_input) - hds = [f"{env_name}", f"grid2op speed (it/s)", f"grid2op 'backend.runpf' time (ms)", f"solver powerflow time (ms)"] + hds = [f"{env_name}", f"grid2op speed (it/s)", f"grid2op 'backend.runpf' time (ms)", f"time in 'algo' (ms / pf)"] tab = [] if no_pp is False: tab.append(["PP", f"{nb_ts_pp/time_pp:.2e}", @@ -278,10 +278,10 @@ def main(max_ts, print(tab) print() + hds = [f"{env_name} ({nb_ts_pp} iter)", f"Δ aor (amps)", f"Δ gen_p (MW)", f"Δ gen_q (MVAr)"] if no_pp is False: - hds = [f"{env_name} ({nb_ts_pp} iter)", f"Δ aor (amps)", f"Δ gen_p (MW)", f"Δ gen_q (MVAr)"] tab = [["PP (ref)", "0.00", "0.00", "0.00"]] - + for key in this_order: if key not in res_times: continue diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 60cf64b..1f7280c 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -43,9 +43,10 @@ We do not recommend to use Gauss Seidel method (much slower than everything else .. note:: More time have been spent to optimize Newton-Raphson algorithm in lightsim2grid than to optimize Gauss Seidel or - Fast Decoupled method. The results here should be extrapolate outside of lightsim2grid usage. + Fast Decoupled method. The results here should not be extrapolate outside of lightsim2grid usage. - At time of writing only a prelimary version of the backend based on pypowsybl was avaialble. + At time of writing only a prelimary version of the backend based on pypowsybl was avaialble, speed improvement + might be achieved for the official release. Using a grid2op environment ---------------------------- @@ -199,98 +200,104 @@ stricly greater, for all benchmarks, than **solver powerflow time** (the closer First on an environment based on the IEEE case 14 grid: -==================== ====================== =================================== ============================ -case14_sandbox grid2op speed (it/s) grid2op 'backend.runpf' time (ms) solver powerflow time (ms) -==================== ====================== =================================== ============================ -PP 127 6.47 2.86 -PP (no numba) 85.6 10.3 6.68 -PP (with lightsim) 121 7 1.47 -pypowsybl 760 0.895 0.87 -GS 1570 0.313 0.263 -GS synch 1480 0.352 0.302 -NR single (SLU) 2380 0.0868 0.0346 -NR (SLU) 2370 0.0881 0.0355 -NR single (KLU) 2580 0.0606 0.0102 -NR (KLU) 2580 0.0604 0.00977 -NR single (NICSLU *) 2590 0.0607 0.0102 -NR (NICSLU *) 2590 0.0604 0.00977 -NR single (CKTSO *) 2610 0.0597 0.00962 -NR (CKTSO *) 2630 0.0589 0.00917 -FDPF XB (SLU) 2550 0.066 0.0155 -FDPF BX (SLU) 2510 0.0727 0.0224 -FDPF XB (KLU) 2580 0.0631 0.0127 -FDPF BX (KLU) 2540 0.069 0.0188 -FDPF XB (NICSLU *) 2590 0.0626 0.0127 -FDPF BX (NICSLU *) 2550 0.0688 0.0187 -FDPF XB (CKTSO *) 2610 0.0623 0.0128 -FDPF BX (CKTSO *) 2420 0.0727 0.0198 -==================== ====================== =================================== ============================ - -From a grid2op perspective, lightsim2grid allows to compute up to ~2600 steps each second (column `grid2op speed`, rows `NR XXX`) on the case 14 and -"only" ~120 for the default PandaPower Backend (column `grid2op speed`, row `PP`), leading to a speed up of **~21** (2600 / 120) in this case -(lightsim2grid Backend is ~21 times faster than pandapower Backend when comparing grid2op speed). - -When compared to powsybl (with the pypowsybl backend), lightsim2grid (with newton raphson) is around 4 times faster (760 vs 2600). +==================== ====================== =================================== ========================== +case14_sandbox grid2op speed (it/s) grid2op 'backend.runpf' time (ms) time in 'algo' (ms / pf) +==================== ====================== =================================== ========================== +PP 130 6.37 2.83 +PP (no numba) 86.3 10.2 6.69 +PP (with lightsim) 121 6.97 1.47 +pypowsybl 722 0.996 0.94 +GS 1590 0.311 0.261 +GS synch 1500 0.348 0.299 +NR single (SLU) 2420 0.0847 0.0338 +NR (SLU) 2400 0.0867 0.0353 +NR single (KLU) 2650 0.059 0.00993 +NR (KLU) 2640 0.0591 0.00959 +NR single (NICSLU *) 2630 0.0596 0.0101 +NR (NICSLU *) 2640 0.0592 0.00973 +NR single (CKTSO *) 2640 0.0589 0.00957 +NR (CKTSO *) 2640 0.0586 0.00915 +FDPF XB (SLU) 2610 0.0642 0.0152 +FDPF BX (SLU) 2560 0.0714 0.0222 +FDPF XB (KLU) 2640 0.0613 0.0125 +FDPF BX (KLU) 2590 0.0675 0.0186 +FDPF XB (NICSLU *) 2630 0.0613 0.0124 +FDPF BX (NICSLU *) 2570 0.0679 0.0186 +FDPF XB (CKTSO *) 2630 0.0616 0.0127 +FDPF BX (CKTSO *) 2580 0.0681 0.0189 +==================== ====================== =================================== ========================== + + +From a grid2op perspective, lightsim2grid allows to compute up to ~2600 steps each second (column `grid2op speed`, rows `NR XXX`) +on the case 14 and +"only" ~130 for the default PandaPower Backend (column `grid2op speed`, row `PP`), leading to a speed up of +**~20** (2600 / 130) in this case +(lightsim2grid Backend is ~20 times faster than pandapower Backend when comparing grid2op speed). + +When compared to powsybl (with the pypowsybl backend), lightsim2grid (with newton raphson) is around 4 times faster (720 vs 2600). For such a small environment, there is no sensible difference in using `KLU` linear solver (rows `NR single (KLU)` or `NR (KLU)`) compared to using the SparseLU solver of Eigen (rows `NR single (SLU)` or `NR (SLU)`) -(2380 vs 2610 iterations on the reported runs, might slightly vary across runs). +(2420 vs 2650 iterations on the reported runs, might slightly vary across runs). -`KLU`, `NICSLU` and `CKTSO` achieve almost identical performances, at least we think the observed differences are within error margins. +Linear solvers `KLU`, `NICSLU` and `CKTSO` achieve almost identical performances, +at least we think the observed differences are within error margins. -There are also very little differences between non distributed slack (`NR Single` rows) and distributed slack (`NR` rows) for all of the -linear solvers. +There are also very little differences between non distributed slack (`NR Single` rows) +and distributed slack (`NR` rows) for all of the tested linear solvers. Finally, the "fast decoupled" methods also leads to equivalent performances for almost all linear solvers and are slightly less performant than the Newton Raphson one. For this small environment, for lightsim2grid backend (and if we don't take into account the "agent time"), the computation time is vastly dominated by factor external to the powerflow solver. Indeed, doing a 'env.step' (column `grid2op speed (it/s)`) -takes 0.38ms (`1. / 2600. * 1000.`) on average and on this 380 ns (or 0.38ms), only -9.7 ns are spent in the backend. Meaning that 370 ns are spent in the grid2op extra layer in this case (97% of the computation time +takes 0.38ms (`1. / 2640. * 1000.`) on average and on this 380 ns (or 0.38ms), only +9.5 ns are spent in the backend. Meaning that 370 ns are spent in the grid2op extra layer or +in the backend implementation in this case (97% of the computation time - `=370 / 380`- is external to the powerflow solver) Then on an environment based on the IEEE case 118: -===================== ====================== =================================== ============================ -neurips_2020_track2 grid2op speed (it/s) grid2op 'backend.runpf' time (ms) solver powerflow time (ms) -===================== ====================== =================================== ============================ -PP 108 7.8 4.1 -PP (no numba) 73.3 12.3 8.53 -PP (with lightsim) 103 8.32 1.95 -pypowsybl 304 2.78 2.72 -GS 7.22 138 138 -GS synch 43.5 22.6 22.5 -NR single (SLU) 1110 0.497 0.421 -NR (SLU) 1080 0.517 0.44 -NR single (KLU) 1950 0.135 0.0661 -NR (KLU) 1960 0.132 0.0627 -NR single (NICSLU *) 1960 0.131 0.0627 -NR (NICSLU *) 1960 0.128 0.0598 -NR single (CKTSO *) 1970 0.129 0.0607 -NR (CKTSO *) 1980 0.125 0.0562 -FDPF XB (SLU) 1810 0.182 0.116 -FDPF BX (SLU) 1740 0.201 0.135 -FDPF XB (KLU) 1890 0.161 0.0961 -FDPF BX (KLU) 1830 0.175 0.11 -FDPF XB (NICSLU *) 1880 0.16 0.0953 -FDPF BX (NICSLU *) 1820 0.176 0.111 -FDPF XB (CKTSO *) 1870 0.161 0.0957 -FDPF BX (CKTSO *) 1810 0.178 0.112 -===================== ====================== =================================== ============================ - -For an environment based on the IEEE 118, the speed up in using lightsim + KLU (LS+KLU) is **~17** time faster than -using the default `PandaPower` backend (~1900 it/s vs ~110 for pandapower with numba). - -When compared to powsybl (with the pypowsybl backend), lightsim2grid (with newton raphson) is around **7** times faster (300 vs 1900). +===================== ====================== =================================== ========================== +neurips_2020_track2 grid2op speed (it/s) grid2op 'backend.runpf' time (ms) time in 'algo' (ms / pf) +===================== ====================== =================================== ========================== +PP 108 7.76 4.07 +PP (no numba) 73.7 12.2 8.47 +PP (with lightsim) 104 8.27 1.93 +pypowsybl 275 3.18 3.09 +GS 7.23 138 138 +GS synch 43.8 22.5 22.4 +NR single (SLU) 1120 0.496 0.42 +NR (SLU) 1090 0.516 0.439 +NR single (KLU) 1960 0.137 0.0679 +NR (KLU) 1980 0.132 0.0628 +NR single (NICSLU *) 1970 0.133 0.0647 +NR (NICSLU *) 1990 0.129 0.0608 +NR single (CKTSO *) 1990 0.129 0.0608 +NR (CKTSO *) 2000 0.126 0.0571 +FDPF XB (SLU) 1810 0.183 0.117 +FDPF BX (SLU) 1750 0.202 0.135 +FDPF XB (KLU) 1890 0.163 0.0967 +FDPF BX (KLU) 1830 0.178 0.112 +FDPF XB (NICSLU *) 1890 0.162 0.0965 +FDPF BX (NICSLU *) 1830 0.178 0.112 +FDPF XB (CKTSO *) 1890 0.162 0.0965 +FDPF BX (CKTSO *) 1830 0.178 0.112 +===================== ====================== =================================== ========================== + +For an environment based on the IEEE 118, the speed up in using lightsim + KLU (LS+KLU) is **~18** time faster than +using the default `PandaPower` backend (~2000 it/s vs ~110 for pandapower with numba). + +When compared to powsybl (with the pypowsybl backend), lightsim2grid (with newton raphson) is around **7** times faster (280 vs 2000). The speed up of lightsim + SparseLU (`1100` it / s) is a bit lower (than using KLU, CKTSO or NICSLU), but it is still **~10** times faster than using the default backend (1100 / 110). -For this environment the `LS+KLU` solver (solver powerflow time) is ~6-7 times faster than the `LS+SLU` solver -(`0.421` ms per powerflow for `LS+SLU` compared to `0.0627` ms for `LS+KLU`), but it only translates to `LS+KLU` -providing ~70% more iterations per second in the total program (`1100` vs `1950`) mainly because grid2op itself takes some times to modify the +For this environment the `NR (KLU)` solver (solver powerflow time) is ~6-7 times faster than the `NR (SLU)` solver +(`0.439` ms per powerflow for `NR (SLU)` compared to `0.0628` ms for `NR (KLU)`), but it only translates to `NR (KLU)` +providing ~70% more iterations per second in the total program (`1100` vs `1980`) +mainly because grid2op itself takes some times to modify the grid and performs some consistency cheks. For this test case once again there is no noticeable difference between `NICSLU`, `CKTSO` and `KLU`. @@ -298,15 +305,18 @@ For this test case once again there is no noticeable difference between `NICSLU` If we look now only at the time to compute one powerflow (and don't take into account the time to load the data, to initialize the solver, to modify the grid, read back the results, to perform the other update in the grid2op environment etc. -- *ie* looking at the column "`solver powerflow time (ms)`") -we can notice that it takes on average (over 1000 different states) approximately **0.0627** -to compute a powerflow with the LightSimBackend (if using the `KLU` linear solver) compared to the **4.1 ms** when using +we can notice that it takes on average (over 1000 different states) approximately **0.0628** +to compute a powerflow with the LightSimBackend (if using the Newton-Raphson algorithm with the `KLU` linear solver) +compared to the **4.1 ms** when using the `PandaPowerBackend` (with numba, but without lightsim2grid) (speed up of **~65** times) -For this small environment, once again, for lightsim2grid backend (and if we don't take into account the "agent time"), the computation time +For this small environment, once again, for lightsim2grid backend (and if we don't take into account the "agent time"), +the computation time is vastly dominated by factor external to the powerflow solver. Indeed, doing a 'env.step' (column `grid2op speed (it/s)`) -takes 0.53ms (`1. / 1900. * 1000.`) on average and on this 530 ns (or 0.53ms), only -63 ns are spent in the backend. Meaning that 470 ns are spent in the grid2op extra layer in this case (~90% of the computation time -- `=470 / 530`- is external to the powerflow solver) +takes 0.50ms (`1. / 2000. * 1000.`) on average and on this 500 ns (or 0.50ms), only +63 ns are spent in the powerflow algorithm. Meaning that 440 ns are spent +in the grid2op extra layer or in the grid2op backend in this case (~90% of the computation time +- `=440 / 500`- is external to the powerflow solver) .. note:: The "solver powerflow time" reported for pandapower is obtained by summing, over the 1000 powerflow performed the `pandapower_backend._grid["_ppc"]["et"]` (the "estimated time" of the pandapower newton raphson computation) diff --git a/docs/benchmarks_dive.rst b/docs/benchmarks_dive.rst index afce858..a18e5da 100644 --- a/docs/benchmarks_dive.rst +++ b/docs/benchmarks_dive.rst @@ -29,31 +29,31 @@ For :ref:`benchmark-solvers` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - **grid2op speed (it/s)** : average of the time taken for 1 step accross the "2. for each steps" -- **grid2op 'backend.runpf' time (ms)** , average of the time taken to compute: +- **grid2op 'backend.runpf' time (ms)** , average of the time taken to compute `2e.`, `2f.` and `2g.` +- **time in 'algo' (ms / pf)** : - - for pandapower and lightsim2grid: `2e.`, `2f.` and `2g.` - - for pypowsybl: `2e.` and `2f.` -- **solver powerflow time (ms)** : - for pandapower and lightsim2grid: `2e3c` - - for pypowsybl: `2e3` + - for pypowsybl: `2e2` and `2e3` For :ref:`benchmark-grid-size` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In the "TL;DR" section: +Results in the "TL;DR" section: -- **time (recycling)** -- **time (no recycling)** -- **time (TimeSerie)** -- **time (ContingencyAnalysis)** +- **time (recycling)** : average time taken over the 288 runs of `2e1`, `2e2` and `2e3` when recycling is allowed + (for some steps `2e1` or `2e2` might be reused from previous computation and 2e3 is also faster) +- **time (no recycling)** : average time taken over the 288 runs of `2e1`, `2e2` and `2e3` when no recycling is allowed + (`2e1` and `2e2` are always computed and `2e3` cannot reuse previous memory allocation or previous states etc., it is then slower) +- **time (TimeSerie)**: equivalent to `2e3` but when using the `TimeSerie` lightsim2grid module +- **time (ContingencyAnalysis)**: equivalent to `2e3` but when using the `ContingencyAnalysis` lightsim2grid module. -In the "Computation time using grid2op" section: +Tesults in the "Computation time using grid2op" section: -- **avg step duration (ms)** -- **time [DC + AC] (ms / pf)** -- **speed (pf / s)** -- **time in 'gridmodel' (ms / pf)** -- **time in 'pf algo' (ms / pf)** +- **avg step duration (ms)**: average time to perform a step in grid2op, so to compute all `2` +- **time [DC + AC] (ms / pf)** : average time taken over the 288 runs of `2e1`, `2e2` and `2e3` +- **speed (pf / s)** : inverse of the above (1. / time [DC + AC]) +- **time in 'solver' (ms / pf)**: average time spent in `2e3` +- **time in 'algo' (ms / pf)**: average time spent in `2e3c` toto @@ -145,7 +145,8 @@ There is the "external layer" that can be accessed and modified more or less eas a. perform some check on the external layer / data model b. convert this "external model" / "data model" into something that can be processed by an algorithm c. run this algorithm -d. convert back the results into the "external layer" / "data model" so that user can easily access it +d. convert back the results from itnernal data to the "external layer" / "data model" so that user can easily access it. This + steps also include the computation of reactive value of generators, slack distribution, current flows on each powerline etc. Now we can properly explain what is reported on the column `solver powerflow time (ms)` : diff --git a/docs/benchmarks_grid_sizes.rst b/docs/benchmarks_grid_sizes.rst index 9877393..0210899 100644 --- a/docs/benchmarks_grid_sizes.rst +++ b/docs/benchmarks_grid_sizes.rst @@ -121,14 +121,14 @@ Then we compare different measurments: - post processing the internal data (which includes *eg* the flows on the lines in amps, the reactive value produced / absorbed by each generator etc.) -- `time in 'gridmodel' (ms / pf)` gives the time it takes to only perform the AC powerflow: +- `time in 'solver' (ms / pf)` gives the time it takes to only perform the AC powerflow: - converting the provided data into valid matrix / vector to run an AC powerflow - computing the AC Powerflow - post processing the internal data (which includes *eg* the flows on the lines in amps, the reactive value produced / absorbed by each generator etc.) -- `time in 'pf algo' (ms / pf)` gives the time spent in the algorithm that computes the AC powerflow only +- `time in 'algo' (ms / pf)` gives the time spent in the algorithm that computes the AC powerflow only .. warning:: For more information about what is actually done and the wordings used in this section, @@ -157,7 +157,7 @@ the other. Results using grid2op.steps (288 consecutive steps, only measuring 'dc pf [init] + ac pf') (recyling allowed, default) ================ =============== ======================== ========================== ================ =============================== ============================= -grid size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'gridmodel' (ms / pf) time in 'pf algo' (ms / pf) +grid size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'solver' (ms / pf) time in 'algo' (ms / pf) ================ =============== ======================== ========================== ================ =============================== ============================= case14 14 0.758799 0.0597669 16731.7 0.0303807 0.0250171 case118 118 0.913219 0.211025 4738.78 0.167014 0.149728 @@ -176,7 +176,7 @@ case9241pegase 9241 46.1182 37. Results using grid2op.steps (288 consecutive steps, only measuring 'dc pf [init] + ac pf') (**no recycling allowed**, non default) ================ =============== ======================== ========================== ================ =============================== ============================= -grid size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'gridmodel' (ms / pf) time in 'pf algo' (ms / pf) +grid size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'solver' (ms / pf) time in 'algo' (ms / pf) ================ =============== ======================== ========================== ================ =============================== ============================= case14 14 0.777772 0.119986 8334.27 0.0688201 0.0567457 case118 118 1.26015 0.531649 1880.94 0.383771 0.343062 diff --git a/lightsim2grid/newtonpf/__init__.py b/lightsim2grid/newtonpf/__init__.py index 31a97ce..bfd4c04 100644 --- a/lightsim2grid/newtonpf/__init__.py +++ b/lightsim2grid/newtonpf/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020, RTE (https://www.rte-france.com) +# Copyright (c) 2020-2025, RTE (https://www.rte-france.com) # See AUTHORS.txt # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, @@ -8,4 +8,4 @@ __all__ = ["newtonpf", "newtonpf_new", "newtonpf_old"] -from lightsim2grid.newtonpf.newtonpf import newtonpf, newtonpf_new, newtonpf_old +from lightsim2grid.pandapower_compat import newtonpf, newtonpf_new, newtonpf_old diff --git a/lightsim2grid/pandapower_compat/__init__.py b/lightsim2grid/pandapower_compat/__init__.py new file mode 100644 index 0000000..8e49b2e --- /dev/null +++ b/lightsim2grid/pandapower_compat/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +__all__ = ["newtonpf", "newtonpf_new", "newtonpf_old", "dcpf"] + +from lightsim2grid.pandapower_compat.newtonpf import newtonpf, newtonpf_new, newtonpf_old +from lightsim2grid.pandapower_compat.dcpf import dcpf diff --git a/lightsim2grid/pandapower_compat/dcpf/__init__.py b/lightsim2grid/pandapower_compat/dcpf/__init__.py new file mode 100644 index 0000000..c17d047 --- /dev/null +++ b/lightsim2grid/pandapower_compat/dcpf/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +__all__ = ["dcpf"] + +from lightsim2grid.pandapower_compat.dcpf._dcpf import dcpf diff --git a/lightsim2grid/pandapower_compat/dcpf/_dcpf.py b/lightsim2grid/pandapower_compat/dcpf/_dcpf.py new file mode 100644 index 0000000..34abfe6 --- /dev/null +++ b/lightsim2grid/pandapower_compat/dcpf/_dcpf.py @@ -0,0 +1,65 @@ +# Copyright (c) 2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +import numpy as np +from scipy import sparse + +from lightsim2grid.solver import DCSolver + +try: + from lightsim2grid.solver import KLUDCSolver + KLU_solver_available = True +except ImportError: + KLU_solver_available = False + + +def _isolate_slack_ids(Sbus, pv, pq): + # extract the slack bus + ref = set(np.arange(Sbus.shape[0])) - set(pv) - set(pq) + ref = np.array(list(ref)) + # build the slack weights + slack_weights = np.zeros(Sbus.shape[0]) + slack_weights[ref] = 1.0 / ref.shape[0] + return ref, slack_weights + + +def _get_valid_solver(options, Bbus): + # initialize the solver + # TODO have that in options maybe (can use GaussSeidel, and NR with KLU -faster- or SparseLU) + solver = KLUDCSolver() if KLU_solver_available else DCSolver() + + if not sparse.isspmatrix_csc(Bbus): + Bbus = sparse.csc_matrix(Bbus) + + if not Bbus.has_canonical_format: + Bbus.sum_duplicates() + if not Bbus.has_canonical_format: + raise RuntimeError("Your matrix should be in a canonical format. See " + "https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csc_matrix.has_canonical_format.html" + " for more information.") + + return solver + + +def dcpf(B, Pbus, Va0, ref, pv, pq): + """ + Implementation (using lightsim2grid) of the `dcpf` function of pandapower + that allows to perform DC powerflow. + + It is meant to be used from inside pandapower and not directly. + """ + # initialize the solver and perform some sanity checks + solver = _get_valid_solver(None, B) + + # do the newton raphson algorithm + slack_weights = np.ones(len(ref), dtype=float) + slack_weights /= slack_weights.sum() + solver.solve(B, np.cos(Va0) + 1j * np.sin(Va0), Pbus, ref, slack_weights, pv, pq, 1, 1e-8) + # extract the results + Va = solver.get_Va() + return Va diff --git a/lightsim2grid/pandapower_compat/newtonpf/__init__.py b/lightsim2grid/pandapower_compat/newtonpf/__init__.py new file mode 100644 index 0000000..f0f4d36 --- /dev/null +++ b/lightsim2grid/pandapower_compat/newtonpf/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. + +__all__ = ["newtonpf", "newtonpf_new", "newtonpf_old"] + +from lightsim2grid.pandapower_compat.newtonpf._newtonpf import newtonpf, newtonpf_new, newtonpf_old diff --git a/lightsim2grid/newtonpf/newtonpf.py b/lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py similarity index 93% rename from lightsim2grid/newtonpf/newtonpf.py rename to lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py index aa84f68..c1d47e4 100644 --- a/lightsim2grid/newtonpf/newtonpf.py +++ b/lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py @@ -7,18 +7,20 @@ # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. import warnings +from packaging import version +from importlib.metadata import version as version_metadata import numpy as np from scipy import sparse -from lightsim2grid_cpp import SparseLUSolver, SparseLUSolverSingleSlack +from lightsim2grid.solver import SparseLUSolver, SparseLUSolverSingleSlack try: - from lightsim2grid_cpp import KLUSolver, KLUSolverSingleSlack + from lightsim2grid.solver import KLUSolver, KLUSolverSingleSlack KLU_solver_available = True except ImportError: KLU_solver_available = False -_PP_VERSION_MAX = "2.7.0" +_PP_VERSION_MAX = version.parse("2.7.0") def _isolate_slack_ids(Sbus, pv, pq): @@ -43,10 +45,11 @@ def _get_valid_solver(options, Ybus): Ybus = sparse.csc_matrix(Ybus) if not Ybus.has_canonical_format: - raise RuntimeError("Your matrix should be in a canonical format. See " - "https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csc_matrix.has_canonical_format.html" - " for more information.") - + Ybus.sum_duplicates() + if not Ybus.has_canonical_format: + raise RuntimeError("Your matrix should be in a canonical format. See " + "https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csc_matrix.has_canonical_format.html" + " for more information.") return solver @@ -73,17 +76,17 @@ def newtonpf(*args, **kwargs): .. code-block:: - from lightsim2grid.newtonpf import newtonpf + from lightsim2grid.pandapower_compat import newtonpf - # when pandapower version <= 2.7.0 + # with pandapower version <= 2.7.0 V, converged, iterations, J, Vm_it, Va_it = newtonpf(Ybus, Sbus, V0, pv, pq, ppci, options) - # when pandapower version > 2.7.0 + # with pandapower version > 2.7.0 V, converged, iterations, J, Vm_it, Va_it = newtonpf(Ybus, Sbus, V0, ref, pv, pq, ppci, options) """ - import pandapower as pp - if pp.__version__ <= _PP_VERSION_MAX: + + if version.parse(version_metadata("pandapower")) <= _PP_VERSION_MAX: try: # should be the old version return newtonpf_old(*args, **kwargs) From 594b5e8d39939898d1d8d4cff930e56ad7dcb2d5 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 8 Jan 2025 14:21:33 +0100 Subject: [PATCH 11/15] fixing unittest import names Signed-off-by: DONNOT Benjamin --- lightsim2grid/tests/test_NewtonPF.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lightsim2grid/tests/test_NewtonPF.py b/lightsim2grid/tests/test_NewtonPF.py index bafa904..1347c05 100644 --- a/lightsim2grid/tests/test_NewtonPF.py +++ b/lightsim2grid/tests/test_NewtonPF.py @@ -9,9 +9,8 @@ import os import unittest import numpy as np -import pdb import zipfile -from lightsim2grid.newtonpf import newtonpf_old as newtonpf +from lightsim2grid.pandapower_compat import newtonpf_old as newtonpf from scipy import sparse From e8bbb8e5e98a0c05a434448c8cbe0ce8e8e2e307 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 9 Jan 2025 11:01:08 +0100 Subject: [PATCH 12/15] adding documentation for DC [skip ci] Signed-off-by: DONNOT Benjamin --- benchmarks/benchmark_dc_solvers.py | 180 ++++++++++++++++++++--------- benchmarks/utils_benchmark.py | 4 +- docs/benchmarks.rst | 2 +- docs/benchmarks_dive.rst | 12 +- docs/index.rst | 1 + 5 files changed, 142 insertions(+), 57 deletions(-) diff --git a/benchmarks/benchmark_dc_solvers.py b/benchmarks/benchmark_dc_solvers.py index ff1bec5..ae1376d 100644 --- a/benchmarks/benchmark_dc_solvers.py +++ b/benchmarks/benchmark_dc_solvers.py @@ -6,26 +6,34 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid a implements a c++ backend targeting the Grid2Op platform. +import time import numpy as np import os import warnings import pandas as pd +import re + from grid2op import make from grid2op.Backend import PandaPowerBackend from grid2op.Agent import DoNothingAgent from grid2op.Chronics import ChangeNothing -import re - -from lightsim2grid import solver +from grid2op.Environment import MultiMixEnvironment try: from grid2op.Chronics import GridStateFromFileWithForecastsWithoutMaintenance as GridStateFromFile except ImportError: print("Be carefull: there might be maintenance") from grid2op.Chronics import GridStateFromFile - from grid2op.Parameters import Parameters +from grid2op.dtypes import dt_float + import lightsim2grid -from lightsim2grid.lightSimBackend import LightSimBackend +from lightsim2grid import solver +from lightsim2grid import LightSimBackend, TimeSerie +try: + from lightsim2grid import ContingencyAnalysis +except ImportError: + from lightsim2grid import SecurityAnalysis as ContingencyAnalysis + from utils_benchmark import print_res, run_env, str2bool, get_env_name_displayed, print_configuration TABULATE_AVAIL = False try: @@ -33,31 +41,20 @@ TABULATE_AVAIL = True except ImportError: print("The tabulate package is not installed. Some output might not work properly") - + +try: + from pypowsybl2grid import PyPowSyBlBackend + pypow_error = None +except ImportError as exc_: + pypow_error = exc_ + print("Backend based on pypowsybl will not be benchmarked") + MAX_TS = 1000 ENV_NAME = "rte_case14_realistic" DONT_SAVE = "__DONT_SAVE" NICSLU_LICENSE_AVAIL = os.path.exists("./nicslu.lic") and os.path.isfile("./nicslu.lic") -solver_names = {# lightsim2grid.SolverType.GaussSeidel: "GS", - # lightsim2grid.SolverType.GaussSeidelSynch: "GS synch", - # lightsim2grid.SolverType.SparseLU: "NR (SLU)", - # lightsim2grid.SolverType.KLU: "NR (KLU)", - # lightsim2grid.SolverType.NICSLU: "NR (NICSLU *)", - # lightsim2grid.SolverType.CKTSO: "NR (CKTSO *)", - # lightsim2grid.SolverType.SparseLUSingleSlack: "NR single (SLU)", - # lightsim2grid.SolverType.KLUSingleSlack: "NR single (KLU)", - # lightsim2grid.SolverType.NICSLUSingleSlack: "NR single (NICSLU *)", - # lightsim2grid.SolverType.CKTSOSingleSlack: "NR single (CKTSO *)", - # lightsim2grid.SolverType.FDPF_XB_SparseLU: "FDPF XB (SLU)", - # lightsim2grid.SolverType.FDPF_BX_SparseLU: "FDPF BX (SLU)", - # lightsim2grid.SolverType.FDPF_XB_KLU: "FDPF XB (KLU)", - # lightsim2grid.SolverType.FDPF_BX_KLU: "FDPF BX (KLU)", - # lightsim2grid.SolverType.FDPF_XB_NICSLU: "FDPF XB (NICSLU *)", - # lightsim2grid.SolverType.FDPF_BX_NICSLU: "FDPF BX (NICSLU *)", - # lightsim2grid.SolverType.FDPF_XB_CKTSO: "FDPF XB (CKTSO *)", - # lightsim2grid.SolverType.FDPF_BX_CKTSO: "FDPF BX (CKTSO *)", - lightsim2grid.SolverType.DC: "DC", +solver_names = {lightsim2grid.SolverType.DC: "DC", lightsim2grid.SolverType.KLUDC: "DC (KLU)", lightsim2grid.SolverType.NICSLUDC: "DC (NICSLU *)", lightsim2grid.SolverType.CKTSODC: "DC (CKTSO *)" @@ -92,14 +89,12 @@ def main(max_ts, env_pp = make(env_name_input, param=param, test=test, backend=PandaPowerBackend(lightsim2grid=False, with_numba=True), data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) - env_pp_no_numba = make(env_name_input, param=param, test=test, - backend=PandaPowerBackend(lightsim2grid=False, with_numba=False), - data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) - env_pp_ls_numba = make(env_name_input, param=param, test=test, - backend=PandaPowerBackend(lightsim2grid=True, with_numba=True), - data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) env_lightsim = make(env_name_input, backend=LightSimBackend(), param=param, test=test, data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) + if pypow_error is None: + env_pypow = make(env_name_input, param=param, test=test, + backend=PyPowSyBlBackend(), + data_feeding_kwargs={"gridvalueClass": GridStateFromFile}) else: # I provided an environment path env_pp = make("blank", param=param, test=True, @@ -110,6 +105,11 @@ def main(max_ts, backend=LightSimBackend(), data_feeding_kwargs={"gridvalueClass": ChangeNothing}, grid_path=env_name_input) + if pypow_error is None: + env_pypow = make("blank", param=param, test=True, + data_feeding_kwargs={"gridvalueClass": ChangeNothing}, + grid_path=env_name_input, + backend=PyPowSyBlBackend()) _, env_name_input = os.path.split(env_name_input) agent = DoNothingAgent(action_space=env_pp.action_space) @@ -118,17 +118,16 @@ def main(max_ts, nb_ts_pp, time_pp, aor_pp, gen_p_pp, gen_q_pp = run_env(env_pp, max_ts, agent, chron_id=0, env_seed=0) pp_comp_time = env_pp.backend.comp_time pp_time_pf = env_pp._time_powerflow - - tmp_no_numba = run_env(env_pp_no_numba, max_ts, agent, chron_id=0, env_seed=0) - nb_ts_pp_no_numba, time_pp_no_numba, aor_pp_no_numba, gen_p_pp_no_numba, gen_q_pp_no_numba = tmp_no_numba - pp_no_numba_comp_time = env_pp_no_numba.backend.comp_time - pp_no_numba_time_pf = env_pp_no_numba._time_powerflow - - tmp_ls_numba = run_env(env_pp_ls_numba, max_ts, agent, chron_id=0, env_seed=0) - nb_ts_pp_ls_numba, time_pp_ls_numba, aor_pp_ls_numba, gen_p_ls_numba, gen_q_ls_numba = tmp_ls_numba - pp_ls_numba_comp_time = env_pp_ls_numba.backend.comp_time - pp_ls_numba_time_pf = env_pp_ls_numba._time_powerflow - + + if pypow_error is None: + # also benchmark pypowsybl backend + nb_ts_pypow, time_pypow, aor_pypow, gen_p_pypow, gen_q_pypow = run_env(env_pypow, max_ts, agent, chron_id=0, env_seed=0) + pypow_comp_time = env_pypow.backend.comp_time + pypow_time_pf = env_pypow._time_powerflow + if hasattr(env_pypow, "_time_step"): + # for oldest grid2op version where this was not stored + time_pypow = env_pypow._time_step + wst = True # print extra info in the run_env function solver_types = env_lightsim.backend.available_solvers @@ -160,6 +159,78 @@ def main(max_ts, nb_ts_gs, time_gs, aor_gs, gen_p_gs, gen_q_gs, gs_comp_time, gs_time_pf) + env_name = get_env_name_displayed(env_name_input) + + real_env_ls = env_lightsim + if isinstance(real_env_ls, MultiMixEnvironment): + # get the first (in alphabetical order) env in case of multimix + real_env_ls = real_env_ls[next(iter(sorted(real_env_ls.keys())))] + + # Perform the computation using TimeSerie + load_p = 1. * real_env_ls.chronics_handler.real_data.data.load_p[:nb_ts_gs] + load_q = 1. * real_env_ls.chronics_handler.real_data.data.load_q[:nb_ts_gs] + prod_p = 1. * real_env_ls.chronics_handler.real_data.data.prod_p[:nb_ts_gs] + time_serie = TimeSerie(real_env_ls) + computer_ts = time_serie.computer + computer_ts.change_solver(lightsim2grid.SolverType.KLUDC) + v_init = real_env_ls.backend.V + status = computer_ts.compute_Vs(prod_p, + np.zeros((nb_ts_gs, 0), dtype=dt_float), + load_p, + load_q, + v_init, + real_env_ls.backend.max_it, + real_env_ls.backend.tol) + time_serie._TimeSerie__computed = True + a_or = time_serie.compute_A() + p_or = time_serie.compute_P() + assert status, f"some powerflow diverge for Time Series for {env_name}: {computer_ts.nb_solved()} " + ts_time = 1e3 * (computer_ts.total_time() + computer_ts.amps_computation_time()) / computer_ts.nb_solved() + ts_algo_time = 1e3 * (computer_ts.solver_time()) / computer_ts.nb_solved() + + # perform the computation using PTDF + obs = real_env_ls.reset() + load_bus = real_env_ls.local_bus_to_global(obs.load_bus, obs.load_to_subid) + gen_bus = real_env_ls.local_bus_to_global(obs.gen_bus, obs.gen_to_subid) + Sbus = np.zeros((nb_ts_gs, real_env_ls.backend._grid.total_bus()), dtype=float) + Sbus[:, load_bus] -= load_p + Sbus[:, gen_bus] += prod_p + T_Sbus = 1. * Sbus.T + + PTDF_ = 1.0 * real_env_ls.backend._grid.get_ptdf() + beg_ = time.perf_counter() + flows = np.dot(PTDF_, T_Sbus).T + end_ = time.perf_counter() + time_only_ptdf, *_ = real_env_ls.backend._grid.get_dc_solver().get_timers_ptdf_lodf() + time_only_ptdf *= 1000. / Sbus.shape[0] + time_flow_ptdf = 1000. * (end_ - beg_) / Sbus.shape[0] + # ts_time = 1e3 * (computer_ts.total_time() + computer_ts.amps_computation_time()) / computer_ts.nb_solved() + # ts_algo_time = 1e3 * (computer_ts.solver_time()) / computer_ts.nb_solved() + + # Perform a securtiy analysis (up to 1000 contingencies) + real_env_ls.reset() + sa = ContingencyAnalysis(real_env_ls) + computer_sa = sa.computer + computer_sa.change_solver(lightsim2grid.SolverType.KLUDC) + for i in range(real_env_ls.n_line): + sa.add_single_contingency(i) + if i >= 1000: + break + p_or_sa, a_or_sa, voltages = sa.get_flows() + sa_time = 1e3 * (computer_sa.total_time() + computer_sa.amps_computation_time()) / computer_sa.nb_solved() + sa_algo_time = 1e3 * (computer_sa.solver_time()) / computer_sa.nb_solved() + + # perform the computation using LODF + init_powerflow = p_or[0] + init_powerflow_diag = np.diag(init_powerflow) + LODF_mat = 1.0 * real_env_ls.backend._grid.get_lodf() + beg_ = time.perf_counter() + por_lodf = init_powerflow + LODF_mat * init_powerflow_diag + end_ = time.perf_counter() + _, time_only_lodf, _ = real_env_ls.backend._grid.get_dc_solver().get_timers_ptdf_lodf() + time_only_lodf *= 1000. / init_powerflow.shape[0] + time_flow_lodf = 1000. * (end_ - beg_) / init_powerflow.shape[0] + # NOW PRINT THE RESULTS print("Configuration:") config_str = print_configuration() @@ -169,19 +240,18 @@ def main(max_ts, # order on which the solvers will be this_order = [el for el in res_times.keys() if el not in order_solver_print] + order_solver_print - env_name = get_env_name_displayed(env_name_input) - hds = [f"{env_name}", f"grid2op speed (it/s)", f"grid2op 'backend.runpf' time (ms)", f"solver powerflow time (ms)"] + hds = [f"{env_name}", f"grid2op speed (it/s)", f"grid2op 'backend.runpf' time (ms / pf)", f"time in 'algo' (ms / pf)"] tab = [] if no_pp is False: tab.append(["PP DC", f"{nb_ts_pp/time_pp:.2e}", f"{1000.*pp_time_pf/nb_ts_pp:.2e}", f"{1000.*pp_comp_time/nb_ts_pp:.2e}"]) - tab.append(["PP DC (no numba)", f"{nb_ts_pp_no_numba/time_pp_no_numba:.2e}", - f"{1000.*pp_no_numba_time_pf/nb_ts_pp_no_numba:.2e}", - f"{1000.*pp_no_numba_comp_time/nb_ts_pp_no_numba:.2e}"]) - tab.append(["PP DC (with lightsim)", f"{nb_ts_pp_ls_numba/time_pp_ls_numba:.2e}", - f"{1000.*pp_ls_numba_time_pf/nb_ts_pp_ls_numba:.2e}", - f"{1000.*pp_ls_numba_comp_time/nb_ts_pp_ls_numba:.2e}"]) + + + if pypow_error is None: + tab.append(["pypowsybl", f"{nb_ts_pypow/time_pypow:.2e}", + f"{1000.*pypow_time_pf/nb_ts_pypow:.2e}", + f"{1000.*pypow_comp_time/nb_ts_pypow:.2e}"]) for key in this_order: if key not in res_times: @@ -190,7 +260,11 @@ def main(max_ts, tab.append([solver_name, f"{nb_ts_gs/time_gs:.2e}", f"{1000.*gs_time_pf/nb_ts_gs:.2e}", - f"{1000.*gs_comp_time/nb_ts_gs:.2e}"]) + f"{1000.*gs_comp_time/nb_ts_gs:.2e}"]) + tab.append(("time serie **", None, ts_time, ts_algo_time)) + tab.append(("PTDF **", None, time_only_ptdf + time_flow_ptdf, time_flow_ptdf)) + tab.append(("contingency analysis ***", None, sa_time, sa_algo_time)) + tab.append(("LODF ***", None, time_only_lodf + time_flow_lodf, time_flow_lodf)) if TABULATE_AVAIL: res_use_with_grid2op_1 = tabulate(tab, headers=hds, tablefmt="rst") @@ -233,8 +307,8 @@ def main(max_ts, dt = pd.DataFrame(tab, columns=hds) dt.to_csv(save_results+"diff.csv", index=False, header=True, sep=";") print() - - + + if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description='Benchmark of lightsim with a "do nothing" agent ' diff --git a/benchmarks/utils_benchmark.py b/benchmarks/utils_benchmark.py index cdd72fb..768bf5c 100644 --- a/benchmarks/utils_benchmark.py +++ b/benchmarks/utils_benchmark.py @@ -82,7 +82,7 @@ def run_env(env, max_ts, agent, chron_id=None, keep_forecast=False, with_type_so if not keep_forecast: env.deactivate_forecast() need_reset = True - + if need_reset: obs = env.reset() else: @@ -187,7 +187,7 @@ def print_configuration(pypow_error=True): res.append(tmp) print(tmp) if pypow_error is None: - tmp = (f"- pywposybl version: {importlib.metadata.version('pypowsybl')}") + tmp = (f"- pypowsybl version: {importlib.metadata.version('pypowsybl')}") res.append(tmp) print(tmp) tmp = (f"- pypowsybl2grid version: {importlib.metadata.version('pypowsybl2grid')}") diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 1f7280c..c625145 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -63,7 +63,7 @@ All of them has been run on a computer with a the following characteristics: - numpy version: 1.26.4 - pandas version: 2.2.3 - pandapower version: 2.14.10 -- pywposybl version: 1.9.0.dev1 +- pypowsybl version: 1.9.0.dev1 - pypowsybl2grid version: 0.1.0 - grid2op version: 1.10.5 - lightsim2grid version: 0.10.0 diff --git a/docs/benchmarks_dive.rst b/docs/benchmarks_dive.rst index a18e5da..1139aed 100644 --- a/docs/benchmarks_dive.rst +++ b/docs/benchmarks_dive.rst @@ -55,7 +55,6 @@ Tesults in the "Computation time using grid2op" section: - **time in 'solver' (ms / pf)**: average time spent in `2e3` - **time in 'algo' (ms / pf)**: average time spent in `2e3c` -toto Grid2op in a nutshell ---------------------- @@ -272,3 +271,14 @@ Finally, in lightsim2grid the "recycling" takes also place at this steps. For ex solver might not be performed if not needed. Similarly, steps 3.3 and 3.4 might be faster if "recycling" is allowed. For example, for the KLU linear solver, the first system solved takes longer than the others. Hence, if the solver can be reused the "slower first iteration" is done only once, leading to a greater throughput overall. + +TimeSerie module +--------------------------- + +TODO doc coming soon + + +ContingencyAnalysis module +---------------------------- + +TODO doc coming soon diff --git a/docs/index.rst b/docs/index.rst index f9c183c..7154a17 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ Benchmarkings :caption: Benchmarks benchmarks + benchmarks_dc benchmarks_grid_sizes benchmarks_dive From d65d0c8239301e739bd32ab116de48ba28c85c85 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 9 Jan 2025 11:04:04 +0100 Subject: [PATCH 13/15] fixing compat for python 3.7 Signed-off-by: DONNOT Benjamin --- .../pandapower_compat/newtonpf/_newtonpf.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py b/lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py index c1d47e4..47a4097 100644 --- a/lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py +++ b/lightsim2grid/pandapower_compat/newtonpf/_newtonpf.py @@ -8,7 +8,11 @@ import warnings from packaging import version -from importlib.metadata import version as version_metadata +try: + from importlib.metadata import version as version_metadata +except ImportError: + # for compat with python 3.7 ... + version_metadata = None import numpy as np from scipy import sparse @@ -85,8 +89,14 @@ def newtonpf(*args, **kwargs): V, converged, iterations, J, Vm_it, Va_it = newtonpf(Ybus, Sbus, V0, ref, pv, pq, ppci, options) """ - - if version.parse(version_metadata("pandapower")) <= _PP_VERSION_MAX: + if version_metadata is not None: + pp_ver = version_metadata("pandapower") + else: + # for compat with python 3.7 + import pandapower as pp + pp_ver = pp.__version__ + + if version.parse(pp_ver) <= _PP_VERSION_MAX: try: # should be the old version return newtonpf_old(*args, **kwargs) From f72f568b29c154921b4467bcc9335333736ddf37 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 4 Feb 2025 16:06:18 +0100 Subject: [PATCH 14/15] still trying to keep only modification for dc benchmark Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 20 ++++ benchmarks/benchmark_grid_size.py | 35 ++++++- docs/benchmarks_grid_sizes.rst | 159 +++++++++++++++--------------- docs/conf.py | 2 +- lightsim2grid/__init__.py | 2 +- setup.py | 4 +- 6 files changed, 136 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f92998c..94eeea3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,26 @@ TODO: in `main.cpp` check the returned policy of pybind11 and also the `py::call TODO: a cpp class that is able to compute (DC powerflow) ContingencyAnalysis and TimeSeries using PTDF and LODF TODO: integration test with pandapower (see `pandapower/contingency/contingency.py` and import `lightsim2grid_installed` and check it's True) +[0.10.2] 2025-01-xx +---------------------- +- [FIXED] an error when changing of bus one of the slack (did not trigger the + recompute of pv bus ids) +- [FIXED] an issue when turning off a generator: it was still declared as "slack" + if it was one. +- [FIXED] could not disconnect a generator when it was a slack bus +- [ADDED] packaging package as a dependency +- [IMPROVED] refactoring of the c++ side container element to reduce + code (for "one end" elements such as loads, generators, static generators and shunts) + +[0.10.1] 2025-01-04 +---------------------------- +- [FIXED] some timings on the benchmarks were not measured at the right time +- [ADDED] more benchmarks especially for DC powerflow +- [ADDED] a `dcpf` function that can replace the pandapower `dcpf` interal function +- [IMPROVED] benchmark on the documentation + (clarity of what is done) +- [IMPROVED] consistency of the names and measured times accross the different benchmarks + [0.10.0] 2024-12-17 ------------------- - [BREAKING] disconnected storage now raises errors if some power is produced / absorbed, when using legacy grid2op version, diff --git a/benchmarks/benchmark_grid_size.py b/benchmarks/benchmark_grid_size.py index 880f8e3..50049d3 100644 --- a/benchmarks/benchmark_grid_size.py +++ b/benchmarks/benchmark_grid_size.py @@ -360,7 +360,7 @@ def run_grid2op_env(env_lightsim, case, reset_solver, env_lightsim.backend.tol) time_serie._TimeSerie__computed = True a_or = time_serie.compute_A() - assert status, f"some powerflow diverge for Time Series for {case_name}: {computer.nb_solved()} " + assert status or computer.nb_solver() == nb_step_pp, f"some powerflow diverge for Time Series for {case_name}: {computer.nb_solved()} " if VERBOSE: # print detailed results if needed @@ -403,7 +403,32 @@ def run_grid2op_env(env_lightsim, case, reset_solver, print_configuration() print(f"Solver used for linear algebra: {linear_solver_used_str}") print() - + + print("TL;DR") + tab_tldr = [] + for i, nm_ in enumerate(case_names_displayed): + tab_tldr.append((nm_, + ts_sizes[i], + 1000. * ls_gridmodel_time[i] / nb_step if ls_gridmodel_time[i] else None, + 1000. * ls_gridmodel_time_reset[i] / nb_step_reset if ls_gridmodel_time_reset[i] else None, + 1000. / ts_speeds[i] if ts_speeds[i] else None, + 1000. / sa_speeds[i] if sa_speeds[i] else None, + )) + if TABULATE_AVAIL: + res_use_with_grid2op_2 = tabulate(tab_tldr, + headers=["grid", + "size (nb bus)", + "time (recycling)", + "time (no recycling)", + "time (`TimeSerie`)", + "time (`ContingencyAnalysis`)", + ], + tablefmt="rst") + print(res_use_with_grid2op_2) + else: + print(tab_tldr) + print() + print("Results using grid2op.steps (288 consecutive steps, only measuring 'dc pf [init] + ac pf') (no recycling allowed, non default)") tab_g2op = [] for i, nm_ in enumerate(case_names_displayed): @@ -417,7 +442,7 @@ def run_grid2op_env(env_lightsim, case, reset_solver, )) if TABULATE_AVAIL: res_use_with_grid2op_2 = tabulate(tab_g2op, - headers=["grid", + headers=["grid name", "size (nb bus)", "avg step duration (ms)", "time [DC + AC] (ms / pf)", @@ -450,8 +475,8 @@ def run_grid2op_env(env_lightsim, case, reset_solver, "avg step duration (ms)", "time [DC + AC] (ms / pf)", "speed (pf / s)", - "time in 'gridmodel' (ms / pf)", - "time in 'pf algo' (ms / pf)", + "time in 'solver' (ms / pf)", + "time in 'algo' (ms / pf)", ], tablefmt="rst") print(res_use_with_grid2op_2) diff --git a/docs/benchmarks_grid_sizes.rst b/docs/benchmarks_grid_sizes.rst index 0210899..8ab9d83 100644 --- a/docs/benchmarks_grid_sizes.rst +++ b/docs/benchmarks_grid_sizes.rst @@ -15,22 +15,23 @@ TL;DR In summary, lightsim2grid (when using KLU linear solver) perfomances are: -================ =============== =================== ===================== ===================== ================================ -grid name size (nb bus) time (recycling) time (no recycling) time (`TimeSerie`) time (`ContingencyAnalysis`) -================ =============== =================== ===================== ===================== ================================ -case14 14 0.0303807 0.0688201 0.00972245 0.0344761 -case118 118 0.167014 0.383771 0.0651537 0.0940448 -case_illinois200 200 0.366178 0.747475 0.152984 0.251852 -case300 300 0.592916 1.21181 0.379875 0.467905 -case1354pegase 1354 3.13735 5.17859 1.58152 2.01299 -case1888rte 1888 4.78187 7.58089 2.08743 2.72081 -case2848rte 2848 7.49326 12.0294 3.22694 4.19178 -case2869pegase 2869 7.06508 12.0486 3.64617 4.62894 -case3120sp 3120 8.54887 13.4784 3.04654 4.64494 -case6495rte 6495 26.4778 37.8204 10.9002 12.5037 -case6515rte 6515 29.8737 42.66 11.38 12.9684 -case9241pegase 9241 36.0544 55.4857 16.6537 20.0572 -================ =============== =================== ===================== ===================== ================================ +================ =============== ================== ===================== ==================== ============================== +grid size (nb bus) time (recycling) time (no recycling) time (`TimeSerie`) time (`ContingencyAnalysis`) +================ =============== ================== ===================== ==================== ============================== +case14 14 0.0130073 0.0343035 0.00457648 0.00986142 +case118 118 0.0727236 0.209858 0.031906 0.0466558 +case_illinois200 200 0.151148 0.363769 0.0633617 0.102013 +case300 300 0.264309 0.59775 0.129651 0.174466 +case1354pegase 1354 1.50257 2.7327 0.826231 1.05762 +case1888rte 1888 2.37322 3.92464 1.06475 1.37497 +case2848rte 2848 3.7093 6.13028 1.62492 2.19232 +case2869pegase 2869 3.6351 6.42046 1.92647 2.3468 +case3120sp 3120 4.13678 6.85112 1.52145 2.32498 +case6495rte 6495 11.4654 17.3329 4.96104 5.75883 +case6515rte 6515 13.1227 18.832 4.77071 5.86091 +case9241pegase 9241 16.9394 27.053 8.11946 9.34644 +================ =============== ================== ===================== ==================== ============================== + All timings reported above are in milliseconds (ms) for one powerflow (in all cases lots of powerflow are carried out, up to a thousands and the timings here are averaged accross all the powerflows performed) @@ -64,16 +65,16 @@ counting 9241 buses). All of them has been run on a computer with a the following characteristics: -- date: 2024-10-18 09:35 CEST -- system: Linux 5.15.0-56-generic -- OS: ubuntu 20.04 -- processor: Intel(R) Core(TM) i7-6820HQ CPU @ 2.70GHz -- python version: 3.12.4.final.0 (64 bit) +- date: 2025-01-09 11:59 CET +- system: Linux 6.5.0-1024-oem +- OS: ubuntu 22.04 +- processor: 13th Gen Intel(R) Core(TM) i7-13700H +- python version: 3.9.21.final.0 (64 bit) - numpy version: 1.26.4 - pandas version: 2.2.3 - pandapower version: 2.14.10 - grid2op version: 1.10.5 -- lightsim2grid version: 0.9.2 +- lightsim2grid version: 0.10.0 - lightsim2grid extra information: - klu_solver_available: True @@ -82,6 +83,7 @@ All of them has been run on a computer with a the following characteristics: - compiled_march_native: True - compiled_o3_optim: True + Solver used for linear algebra: NR single (KLU) @@ -156,41 +158,42 @@ the other. Results using grid2op.steps (288 consecutive steps, only measuring 'dc pf [init] + ac pf') (recyling allowed, default) -================ =============== ======================== ========================== ================ =============================== ============================= +================ =============== ======================== ========================== ================ ============================ ========================== grid size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'solver' (ms / pf) time in 'algo' (ms / pf) -================ =============== ======================== ========================== ================ =============================== ============================= -case14 14 0.758799 0.0597669 16731.7 0.0303807 0.0250171 -case118 118 0.913219 0.211025 4738.78 0.167014 0.149728 -case_illinois200 200 1.18555 0.424583 2355.25 0.366178 0.340139 -case300 300 1.44624 0.661998 1510.58 0.592916 0.557392 -case1354pegase 1354 5.26387 3.37046 296.695 3.13735 2.9635 -case1888rte 1888 6.32057 5.04453 198.234 4.78187 4.58628 -case2848rte 2848 9.52315 7.88586 126.809 7.49326 7.19927 -case2869pegase 2869 10.428 7.51632 133.044 7.06508 6.70432 -case3120sp 3120 10.6149 9.01426 110.935 8.54887 8.24586 -case6495rte 6495 30.5814 27.5533 36.2933 26.4778 25.6759 -case6515rte 6515 34.0398 30.9591 32.3007 29.8737 29.0781 -case9241pegase 9241 46.1182 37.7921 26.4606 36.0544 34.7085 -================ =============== ======================== ========================== ================ =============================== ============================= +================ =============== ======================== ========================== ================ ============================ ========================== +case14 14 0.350895 0.0245781 40686.6 0.0130073 0.0105687 +case118 118 0.438527 0.0921714 10849.4 0.0727236 0.0640808 +case_illinois200 200 0.531842 0.177022 5649 0.151148 0.139818 +case300 300 0.692534 0.298294 3352.4 0.264309 0.247054 +case1354pegase 1354 2.54428 1.61281 620.037 1.50257 1.42742 +case1888rte 1888 3.1374 2.50807 398.713 2.37322 2.27984 +case2848rte 2848 4.66414 3.90836 255.862 3.7093 3.56542 +case2869pegase 2869 5.45635 3.87341 258.171 3.6351 3.4594 +case3120sp 3120 5.16431 4.37043 228.81 4.13678 3.99066 +case6495rte 6495 13.3672 11.9835 83.4479 11.4654 11.1138 +case6515rte 6515 15.0186 13.6416 73.305 13.1227 12.7565 +case9241pegase 9241 22.7308 17.9356 55.755 16.9394 16.294 +================ =============== ======================== ========================== ================ ============================ ========================== + Results using grid2op.steps (288 consecutive steps, only measuring 'dc pf [init] + ac pf') (**no recycling allowed**, non default) -================ =============== ======================== ========================== ================ =============================== ============================= -grid size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'solver' (ms / pf) time in 'algo' (ms / pf) -================ =============== ======================== ========================== ================ =============================== ============================= -case14 14 0.777772 0.119986 8334.27 0.0688201 0.0567457 -case118 118 1.26015 0.531649 1880.94 0.383771 0.343062 -case_illinois200 200 1.77514 0.961583 1039.95 0.747475 0.688786 -case300 300 2.39949 1.52385 656.232 1.21181 1.12254 -case1354pegase 1354 8.08618 6.32786 158.031 5.17859 4.75853 -case1888rte 1888 10.3294 9.00365 111.066 7.58089 7.0991 -case2848rte 2848 16.0491 14.2892 69.9832 12.0294 11.2664 -case2869pegase 2869 17.6752 14.6977 68.0376 12.0486 11.0712 -case3120sp 3120 17.6044 15.9006 62.8906 13.4784 12.7485 -case6495rte 6495 46.697 43.6531 22.9079 37.8204 35.8113 -case6515rte 6515 51.8558 48.7368 20.5184 42.66 40.588 -case9241pegase 9241 74.1648 65.6422 15.2341 55.4857 51.7239 -================ =============== ======================== ========================== ================ =============================== ============================= +================ =============== ======================== ========================== ================ ============================ ========================== +grid name size (nb bus) avg step duration (ms) time [DC + AC] (ms / pf) speed (pf / s) time in 'solver' (ms / pf) time in 'algo' (ms / pf) +================ =============== ======================== ========================== ================ ============================ ========================== +case14 14 0.394679 0.0589122 16974.4 0.0343035 0.028186 +case118 118 0.66635 0.292747 3415.92 0.209858 0.187849 +case_illinois200 200 0.851082 0.476794 2097.34 0.363769 0.336049 +case300 300 1.17444 0.764839 1307.46 0.59775 0.554902 +case1354pegase 1354 4.3213 3.37901 295.945 2.7327 2.52633 +case1888rte 1888 5.38228 4.7376 211.077 3.92464 3.68506 +case2848rte 2848 8.18769 7.40336 135.074 6.13028 5.75152 +case2869pegase 2869 9.52221 7.92512 126.181 6.42046 5.94842 +case3120sp 3120 9.07648 8.25089 121.199 6.85112 6.4863 +case6495rte 6495 21.8053 20.3641 49.1061 17.3329 16.4422 +case6515rte 6515 23.2821 21.8367 45.7945 18.832 17.9478 +case9241pegase 9241 37.3876 32.5509 30.7211 27.053 25.3161 +================ =============== ======================== ========================== ================ ============================ ========================== .. _bench_grid_size_ts: @@ -221,20 +224,21 @@ table in the previous benchmark. ================ =============== ================ ================ grid size (nb bus) time (ms / pf) speed (pf / s) ================ =============== ================ ================ -case14 14 0.00972245 102855 -case118 118 0.0651537 15348.3 -case_illinois200 200 0.152984 6536.64 -case300 300 0.379875 2632.45 -case1354pegase 1354 1.58152 632.305 -case1888rte 1888 2.08743 479.059 -case2848rte 2848 3.22694 309.891 -case2869pegase 2869 3.64617 274.26 -case3120sp 3120 3.04654 328.241 -case6495rte 6495 10.9002 91.7417 -case6515rte 6515 11.38 87.8737 -case9241pegase 9241 16.6537 60.0467 +case14 14 0.00457648 218508 +case118 118 0.031906 31342.1 +case_illinois200 200 0.0633617 15782.4 +case300 300 0.129651 7713.03 +case1354pegase 1354 0.826231 1210.32 +case1888rte 1888 1.06475 939.19 +case2848rte 2848 1.62492 615.415 +case2869pegase 2869 1.92647 519.085 +case3120sp 3120 1.52145 657.267 +case6495rte 6495 4.96104 201.571 +case6515rte 6515 4.77071 209.613 +case9241pegase 9241 8.11946 123.161 ================ =============== ================ ================ + .. _bench_grid_size_ca: Computation time using the lightsim2grid `ContingencyAnalysis` module @@ -258,17 +262,18 @@ only 1000). ================ =============== =================== =================== grid size (nb bus) time (ms / cont.) speed (cont. / s) ================ =============== =================== =================== -case14 14 0.0344761 29005.6 -case118 118 0.0940448 10633.2 -case_illinois200 200 0.251852 3970.58 -case300 300 0.467905 2137.18 -case1354pegase 1354 2.01299 496.774 -case1888rte 1888 2.72081 367.537 -case2848rte 2848 4.19178 238.562 -case2869pegase 2869 4.62894 216.032 -case3120sp 3120 4.64494 215.288 -case6495rte 6495 12.5037 79.9763 -case6515rte 6515 12.9684 77.1104 -case9241pegase 9241 20.0572 49.8575 +case14 14 0.00986142 101405 +case118 118 0.0466558 21433.6 +case_illinois200 200 0.102013 9802.67 +case300 300 0.174466 5731.77 +case1354pegase 1354 1.05762 945.523 +case1888rte 1888 1.37497 727.287 +case2848rte 2848 2.19232 456.137 +case2869pegase 2869 2.3468 426.112 +case3120sp 3120 2.32498 430.111 +case6495rte 6495 5.75883 173.646 +case6515rte 6515 5.86091 170.622 +case9241pegase 9241 9.34644 106.993 ================ =============== =================== =================== + diff --git a/docs/conf.py b/docs/conf.py index 512eedd..7265e23 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'Benjamin DONNOT' # The full version, including alpha/beta/rc tags -release = "0.10.0" +release = "0.10.1" version = '0.10' # -- General configuration --------------------------------------------------- diff --git a/lightsim2grid/__init__.py b/lightsim2grid/__init__.py index f5c5ccd..98497a9 100644 --- a/lightsim2grid/__init__.py +++ b/lightsim2grid/__init__.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of LightSim2grid, LightSim2grid implements a c++ backend targeting the Grid2Op platform. -__version__ = "0.10.0" +__version__ = "0.10.1" __all__ = ["newtonpf", "SolverType", "ErrorType", "solver", "compilation_options"] diff --git a/setup.py b/setup.py index 2cff340..0fa3af4 100644 --- a/setup.py +++ b/setup.py @@ -322,7 +322,8 @@ "pip", "pybind11", "scipy", - "numpy" + "numpy", + "packaging", # "pandapower" if sys.version_info < (3, 10) else "pandapower>=2.8", # "pytest", # for pandapower see https://github.com/e2nIEE/pandapower/issues/1988 ] @@ -363,7 +364,6 @@ "grid2op>=1.6.4", "numba", "pandapower>=2.14.0", # interface changed for pandapower, not backward compatible - "packaging", "pypowsybl" ] } From b56c27a40b6ee4cd989530e43d911c7c7be66cd4 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 4 Feb 2025 20:21:05 +0100 Subject: [PATCH 15/15] forget to change version in setup.py [skip ci] Signed-off-by: DONNOT Benjamin --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2cff340..8c46114 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ from pybind11.setup_helpers import Pybind11Extension, build_ext -__version__ = "0.10.0" +__version__ = "0.10.1" KLU_SOLVER_AVAILABLE = False # Try to link against SuiteSparse (if available)