Power Flow Example
In this notebook we will walk through an example of power flow calculation of power-grid-model
.
The following points are covered
Construction of the model
Run power flow once, and its relevant function arguments
Update (change) of the model
Run power flow in batch calculations, and its relevant function arguments
Error handling
It serves as an example of how to use the Python API. For detailed API documentation, refer to Python API reference and Native Data Interface.
Example Network
We use a simple network with 3 nodes, 1 source, 3 lines, and 2 loads. As shown below:
-----------------------line_8---------------
| |
node_1 ---line_3--- node_2 ----line_5---- node_6
| | |
source_10 sym_load_4 sym_load_7
The 3 nodes are connected in a triangular way by 3 lines.
# some basic imports
import numpy as np
import pandas as pd
from power_grid_model import LoadGenType
from power_grid_model import PowerGridModel, CalculationMethod, CalculationType
from power_grid_model import initialize_array
Input Dataset
We create an input dataset by using the helper function initialize_array
.
The units of all input are standard SI unit without any prefix, i.e. the unit of voltage is volt (V), not kV.
The attributes are of numpy array type.
id
field is a unique field across theinput_data
dictionary. The keys ofinput-data
are predefined.
Please refer to Components for detailed explanation of all component types and their input/output attributes.
# node
node = initialize_array("input", "node", 3)
node["id"] = np.array([1, 2, 6])
node["u_rated"] = [10.5e3, 10.5e3, 10.5e3]
# line
line = initialize_array("input", "line", 3)
line["id"] = [3, 5, 8]
line["from_node"] = [1, 2, 1]
line["to_node"] = [2, 6, 6]
line["from_status"] = [1, 1, 1]
line["to_status"] = [1, 1, 1]
line["r1"] = [0.25, 0.25, 0.25]
line["x1"] = [0.2, 0.2, 0.2]
line["c1"] = [10e-6, 10e-6, 10e-6]
line["tan1"] = [0.0, 0.0, 0.0]
line["i_n"] = [1000, 1000, 1000]
# load
sym_load = initialize_array("input", "sym_load", 2)
sym_load["id"] = [4, 7]
sym_load["node"] = [2, 6]
sym_load["status"] = [1, 1]
sym_load["type"] = [LoadGenType.const_power, LoadGenType.const_power]
sym_load["p_specified"] = [20e6, 10e6]
sym_load["q_specified"] = [5e6, 2e6]
# source
source = initialize_array("input", "source", 1)
source["id"] = [10]
source["node"] = [1]
source["status"] = [1]
source["u_ref"] = [1.0]
# all
input_data = {
"node": node,
"line": line,
"sym_load": sym_load,
"source": source
}
We can print the input dataset by converting the numpy array to dataframe.
print(pd.DataFrame(input_data["sym_load"]))
id node status type p_specified q_specified
0 4 2 1 0 20000000.0 5000000.0
1 7 6 1 0 10000000.0 2000000.0
(Assignment 1)
Validation (optional)
For efficiency reasons, most of the data is not explicitly validated in the power grid model. However, in most cases, a power flow calculation will fail/crash if the data is invalid. Often with a low level error message that is hard to relate to the original objects. Therfore, it is recommended to always validate your data before constructing a PowerGridModel instance.
The simplest and most effective way to validate your data is by using assert_valid_input_data()
which will throw an error if it encounters any invalid data. See Validation Examples for more detailed information on the validation functions.
from power_grid_model.validation import assert_valid_input_data
assert_valid_input_data(input_data=input_data, calculation_type=CalculationType.power_flow)
(Assignment 2)
Construction
The construction of the model is just calling the constructor of PowerGridModel
. The default system_frequency
is 50.0 Hz.
model = PowerGridModel(input_data)
# model = PowerGridModel(input_data, system_frequency=60.0) # if you have another system frequency
(Assignment 3)
One-time Power Flow Calculation
You can call the method calculate_power_flow
to do a one-time calculation based on the current network data in the model. In this case you should not specify the argument update_data
as it is used for batch calculation.
The following command executes a one-time power flow calculation with (they are also the default values for those arguments)
Symmetric calculation
Newton-Raphson method
Error tolerance: 1e-8
Maximum number of iteration: 20
output_data = model.calculate_power_flow(
symmetric=True,
error_tolerance=1e-8,
max_iterations=20,
calculation_method=CalculationMethod.newton_raphson)
Result Dataset
We can also print a result dataset of node and line by converting the array to dataframe.
print("------node result------")
print(pd.DataFrame(output_data["node"]))
print("------line result------")
print(pd.DataFrame(output_data["line"]))
------node result------
id energized u_pu u u_angle p q
0 1 1 0.998988 10489.375043 -0.003039 3.121451e+07 6.991358e+06
1 2 1 0.952126 9997.325181 -0.026031 -2.000000e+07 -5.000000e+06
2 6 1 0.962096 10102.012975 -0.021895 -1.000000e+07 -2.000000e+06
------line result------
id energized loading p_from q_from i_from \
0 3 1 0.985666 1.736010e+07 4.072097e+06 981.460041
1 5 1 0.205940 -3.365614e+06 -1.178649e+06 205.939917
2 8 1 0.783206 1.385441e+07 2.919262e+06 779.311446
s_from p_to q_to i_to s_to
0 1.783129e+07 -1.663439e+07 -3.821351e+06 985.666324 1.706768e+07
1 3.566030e+06 3.396558e+06 8.861080e+05 200.617323 3.510241e+06
2 1.415863e+07 -1.339656e+07 -2.886108e+06 783.206396 1.370392e+07
Select component types in the result dataset
By default power-grid-model
calculates the result attributes for all components. If you do not need all the component types in your results, for example if you are only interested in node
, you can specify the list of the component types you want in the result dataset in the argument output_component_types
.
The following code calculate the power flow and only generate the output attributes for node
.
output_data = model.calculate_power_flow(
symmetric=True,
error_tolerance=1e-8,
max_iterations=20,
calculation_method=CalculationMethod.newton_raphson,
output_component_types=["node"])
print("List of component types in result dataset")
print(list(output_data.keys()))
print("------node result------")
print(pd.DataFrame(output_data["node"]))
List of component types in result dataset
['node']
------node result------
id energized u_pu u u_angle p q
0 1 1 0.998988 10489.375043 -0.003039 3.121451e+07 6.991358e+06
1 2 1 0.952126 9997.325181 -0.026031 -2.000000e+07 -5.000000e+06
2 6 1 0.962096 10102.012975 -0.021895 -1.000000e+07 -2.000000e+06
Calculation Method
There are four calculation methods available it is defined as enumrations:
Iterative methods: These Iterative methods converge to the true solution. You specify the
error_tolerance
andmax_iterations
as the iteration stopping condition. If the maximum number of iteration is reached and the error is still higher than the error tolerance, an exception will be thrown.CalculationMethod.newton_raphson
: traditional Newton-Raphson method.CalculationMethod.iterative_current
: a novel jacobi like method.
Linear methods: linearization approximation methods. In this case the
error_tolerance
andmax_iterations
are not relevant here. They are also garanteed to have a solution.CalculationMethod.linear
: It treats all loads as constant impedances.CalculationMethod.linear_current
: It is essentially a single iteration of Iterative current.
Following command calculates the power flow with linear method. Note the difference of result for node compared to the results from Newton-Raphson method.
output_data = model.calculate_power_flow(symmetric=True, calculation_method=CalculationMethod.linear)
print("------node result------")
print(pd.DataFrame(output_data["node"]))
------node result------
id energized u_pu u u_angle p q
0 1 1 0.999087 10490.414234 -0.002789 2.862536e+07 6.264884e+06
1 2 1 0.956325 10041.412991 -0.023873 -1.829115e+07 -4.572788e+06
2 6 1 0.965236 10134.973932 -0.020204 -9.316798e+06 -1.863360e+06
(Assignment 4)
Update Model
One can mutate the model by providing a dictionary of update data. This is similar to creating an input dataset. The only difference is to initialize the array with the update
key.
Refer to Components for detailed explanation of all mutable attributes per component type.
NOTE: the initialized the array contains default NULL values for everything. If you do not need to update an attribute, you do not have to specify it. In the C++ core, the default NULL value will be treated as “not updating” for this attribute.
Create Update Dataset
The following code creates an update dataset which changes the two loads and switches Line #3 off at the from side.
update_sym_load = initialize_array("update", "sym_load", 2)
update_sym_load["id"] = [4, 7] # same ID
update_sym_load["p_specified"] = [30e6, 15e6] # change active power
# leave reactive power the same, no need to specify
update_line = initialize_array("update", "line", 1)
update_line["id"] = [3] # change line ID 3
update_line["from_status"] = [0] # switch off at from side
# leave to-side swiching status the same, no need to specify
update_data = {
"sym_load": update_sym_load,
"line": update_line
}
Validation (optional)
from power_grid_model.validation import assert_valid_batch_data
assert_valid_batch_data(input_data=input_data, update_data=update_data, calculation_type=CalculationType.power_flow)
Call Update Method
Call the update function on a copy of the model. Note that you don’t need to make a copy in general, this is only done to keep the original model intact.
model_2 = model.copy()
model_2.update(update_data=update_data)
Re-calculate Power Flow
We calculate power flow again with all default arguments. And print the line results. See the difference of the updated data. Line #3 has zero power flow at the from-side and very small power flow at to-side (due to capacitance).
output_data = model_2.calculate_power_flow()
print("------line result------")
print(pd.DataFrame(output_data["line"]))
------line result------
id energized loading p_from q_from i_from \
0 3 1 0.014030 0.000000e+00 0.000000e+00 0.000000
1 5 1 2.268159 -3.000004e+07 -4.812057e+06 2268.158655
2 8 1 3.249928 5.676867e+07 1.571464e+07 3245.829906
s_from p_to q_to i_to s_to
0 0.000000e+00 3.691991e+01 -1.879430e+05 14.030122 1.879430e+05
1 3.038352e+07 3.385470e+07 7.678867e+06 2265.269969 3.471463e+07
2 5.890358e+07 -4.885470e+07 -9.678867e+06 3249.927645 4.980424e+07
Batch Calculation
We can use the same method calculate_power_flow
to calculate a number of scenarios in one go. To do this, you need to supply an update_data
argument. This argument contains a dictionary of 2D update arrays (one array per component type).
The model uses the current data as the base scenario. For each individual calculation, the model applies each mutation to the base scenario and calculates the power flow.
NOTE: after the batch calculation, the original model will be kept unchanged. Internally the program copies the original model to multiple batch models for the calculation.
Independent Batch Dataset
There are two ways to specify the mutations.
For each scenario only specify the objects that are changed in this scenario.
For each scenario specify all objects that are changed in one or more scenarios.
The latter is called independent batch dataset. Because all relevant objects are specified in each batch, different choices regarding performance optimization may be made in either case.
In general, the following is advised:
Use the non-independent batch dataset approach whenever few parameters change per scenario, but the batch samples many different components, e.g. during N-1 tests.
Use the independent batch dataset approach when a dense sampling of the parameter space is desired for relatively a few different components, e.g. during time series power flow calculation
See also performance guide for the latest recommendations.
Examples
Below we show three examples.
Time Series Profile
The following code creates a load profile with 10 timestamps for the two loads. The two loads are always present for all mutation scenarios.
load_profile = initialize_array(
"update", "sym_load", (10, 2)
) # note the shape of the array, 10 scenarios, 2 objects (loads)
# below is an assignment of shape (1, 2) array to shape (10, 2) array
# the numpy broadcasting rule ensures that the same object ids are repeated 10 times
# therefore the two objects are present for all the scenarios
load_profile["id"] = [[4, 7]]
# this is a scale of load from 0% to 100%
# the array is an (10, 2) shape, each row is a scenario, each column is an object
load_profile["p_specified"] = [[30e6, 15e6]] * np.linspace(0, 1, 10).reshape(-1, 1)
time_series_mutation = {"sym_load": load_profile}
assert_valid_batch_data(input_data=input_data, update_data=time_series_mutation, calculation_type=CalculationType.power_flow)
We can calculate the time series and print the current of the lines.
output_data = model.calculate_power_flow(update_data=time_series_mutation)
print(output_data["line"]["i_from"])
[[ 193.06162675 64.92360593 137.59086941]
[ 248.65360093 72.28087746 185.86691646]
[ 368.12834615 90.73999329 285.46275837]
[ 510.20016036 115.51167381 401.15982574]
[ 662.04311447 143.73983633 523.57045909]
[ 819.63118685 174.08971222 649.95104138]
[ 981.46004118 205.93991655 779.31144601]
[1146.90787963 238.98921914 911.25418138]
[1315.7236725 273.08968816 1045.6266953 ]
[1487.83778526 308.17354058 1182.39546494]]
Accessing batch data
It may be a bit unintuitive to read the output_data
or update_data
of a component directly as they are a dictionary of 3 dimension data, ie. \(ids \times batches \times attributes\). Remember that the output_data
or update_data
are a dictionary of numpy structured arrays. Hence the component should be indexed first. The index that follows can be indexed with numpy structured arrays
To read the result of a single batch, lets say 1st batch,
print(pd.DataFrame(output_data["line"][0]))
id energized loading p_from q_from i_from \
0 3 1 0.212031 29511.239220 3.508895e+06 193.061627
1 5 1 0.064924 -1267.160636 -1.172098e+06 64.923606
2 8 1 0.156578 19805.345039 2.500724e+06 137.590869
s_from p_to q_to i_to s_to
0 3.509019e+06 1267.160636 -3.827902e+06 212.030847 3.827902e+06
1 1.172099e+06 3574.919119 8.320006e+05 46.000611 8.320083e+05
2 2.500802e+06 -3574.919119 -2.832001e+06 156.577603 2.832003e+06
Or maybe we wish to find result of a single component, (eg. 1st line) in all batches
print(output_data["line"]["i_from"][:,0])
[ 193.06162675 248.65360093 368.12834615 510.20016036 662.04311447
819.63118685 981.46004118 1146.90787963 1315.7236725 1487.83778526]
(Assignment 5)
N-1 Scenario while specifying all objects in each scenario
The following code creates a N-1 scenario for all three lines. There are 3 scenarios, in each scenario, the from/to status of one line is switched off. However, all three lines are present in all mutation dataset. This means that for each scenario you will update two lines with the same switching status.
Specifying all objects can bring computational advantages.
line_profile = initialize_array("update", "line", (3, 3)) # 3 scenarios, 3 objects (lines)
# below the same broadcasting trick
line_profile["id"] = [[3, 5, 8]]
# fully specify the status of all lines, even it is the same as the base scenario
line_profile["from_status"] = [
[0, 1, 1],
[1, 0, 1],
[1, 1, 0]
]
line_profile["to_status"] = [
[0, 1, 1],
[1, 0, 1],
[1, 1, 0]
]
n_min_1_mutation_update_all = {"line": line_profile}
assert_valid_batch_data(input_data=input_data, update_data=n_min_1_mutation_update_all, calculation_type=CalculationType.power_flow)
We can calculate the N-1 and print the current of the lines. It is clear that per scenario one line is disabled.
output_data = model.calculate_power_flow(update_data=n_min_1_mutation_update_all)
print(output_data["line"]["i_from"])
[[ 0. 1352.02947002 1962.69857764]
[1199.97577809 0. 573.32693369]
[1877.3560102 634.81512055 0. ]]
N-1 Scenario where only the changed objects are specified
The following code creates a N-1 scenario for all three lines. There are 3 scenarios, in each scenario, the from/to status of one line is switched off. In this dataset we only specify one line per mutation.
line_profile = initialize_array("update", "line", (3, 1)) # 3 scenarios, 1 object mutation per scenario
# for each mutation, only one object is specified
line_profile["id"] = [[3], [5], [8]]
# specify only the changed status (switch off) of one line
line_profile["from_status"] = [[0], [0], [0]]
line_profile["to_status"] = [[0], [0], [0]]
n_min_1_mutation_update_specific = {"line": line_profile}
assert_valid_batch_data(input_data=input_data, update_data=n_min_1_mutation_update_specific, calculation_type=CalculationType.power_flow)
We can calculate the N-1 and print the current of the lines. Note the results are the same as the independent N-1.
output_data = model.calculate_power_flow(update_data=n_min_1_mutation_update_specific)
print(output_data["line"]["i_from"])
[[ 0. 1352.02947002 1962.69857764]
[1199.97577809 0. 573.32693369]
[1877.3560102 634.81512055 0. ]]
Caching Topology
If a batch scenario does not change the switching status of branches and sources, the model will re-use the pre-built internal graph/matrices for each calculation. Time-series load profile calculation is a typical use case. This can bring performance benefits.
If a batch scenario changes the switching status of branches and sources, the topology changes and no caching can be done for that scenario. N-1 check is a typical use case.
See the Performance Guide for tips and tricks regarding performance.
Parallel Computing
The batch calculation supports shared memory multi-threading parallel computing. The common internal states and variables are shared as much as possible to save memory usage and avoid copy. In the C++ implementation std::shared_ptr
of const
variable is used to share the resources between threads (which will not mutate the shared data).
You can set threading
parameter to enable/disable parallel computing.
threading=-1
, use sequential computing (default)threading=0
, use number of threads available from the machine hardware (recommended)threading>0
, set the number of threads you want to use
(Assignment 6)
Error Handling
The error handeling of power-grid-model
is using exceptions in all cases.
Raise Exception
If there is an error in the ctypes wrapper, a relevant exception will be raised (e.g. KeyError
). If there is an error in the C++ core, the C++ exception will be translated into a PowerGridError
in Python. Below we show some examples for the errors in the construction, update, and calculation.
Construction
We try to construct a grid with a line connecting two nodes with different rated voltages. An error is raised in the construction.
from power_grid_model.errors import PowerGridError
# node
node_error = initialize_array("input", "node", 2)
node_error["id"] = [1, 2]
node_error["u_rated"] = [10.5e3, 150.0e3] # different rated voltages
# line
line_error = initialize_array("input", "line", 1)
line_error["id"] = [3]
line_error["from_node"] = [1]
line_error["to_node"] = [2]
line_error["from_status"] = [1]
line_error["to_status"] = [1]
line_error["r1"] = [0.25]
line_error["x1"] = [0.2]
line_error["c1"] = [10e-6]
line_error["tan1"] = [0.0]
line_error["i_n"] = [1000]
try:
PowerGridModel(
{
"node": node_error,
"line": line_error
}
)
except PowerGridError as e:
print(e)
Conflicting voltage for line 3
voltage at from node 1 is 10500.000000
voltage at to node 2 is 150000.000000
Try validate_input_data() or validate_batch_data() to validate your data.
Update
We try to update the model with a non-existing line id.
line_update_error = initialize_array("update", "line", 1)
line_update_error["id"] = [12345] # non-existing
line_update_error["from_status"] = [1]
try:
model.update(update_data={"line": line_update_error})
except PowerGridError as e:
print(e)
The id cannot be found: 12345
Try validate_input_data() or validate_batch_data() to validate your data.
Error in Calculation
Following command calculates the power flow with Newton-Raphson method that fails to converge, because the error tolerance is too low. It throws a PowerGridError
try:
model.calculate_power_flow(
symmetric=True,
error_tolerance=1e-20,
max_iterations=20,
calculation_method=CalculationMethod.newton_raphson)
except PowerGridError as e:
print(e)
Iteration failed to converge after 20 iterations! Max deviation: 0.000000, error tolerance: 0.000000.
Try validate_input_data() or validate_batch_data() to validate your data.
Error in Batch Calculation
Because parallel computing is allowed in batch calculation. The program will be terminated if an exception is thrown from the threads. Therefore, all the C++ exceptions are caught within the thread, and the error messages are re-thrown in the main thread and propagate as PowerGridBatchError
in the Python side.
There could happen that only some of the scenarios fail in a batch calculation and others succeed. You can choose to specify the argument continue_on_batch_error
(default False
) if an error should be thrown in this case or let the program continue with partially valid results.
We modify the time-series load profile to add some errors. We add an unknown id
for scenario 3, and a too large power in scenario 7.
time_series_mutation["sym_load"]["id"][3] = 1000 # unknown id
time_series_mutation["sym_load"]["p_specified"][7] = 1e100 # large power
# we run the batch calculation with default, it will raise error
from power_grid_model.errors import PowerGridBatchError
try:
output_data = model.calculate_power_flow(
update_data=time_series_mutation,
)
except PowerGridBatchError as e:
print(e)
print(f"Failed scenarios: {e.failed_scenarios}")
print(f"Succeeded scenarios: {e.succeeded_scenarios }")
print(f"Error messages: {e.error_messages }")
There are errors in the batch calculation.
Try validate_input_data() or validate_batch_data() to validate your data.
Failed scenarios: [3 7]
Succeeded scenarios: [0 1 2 4 5 6 8 9]
Error messages: ['The id cannot be found: 1000\n', 'Iteration failed to converge after 20 iterations! Max deviation: 43691065954360128432752954469939668333328459052496602949289781148532517437499693334528.000000, error tolerance: 0.000000.\n']
# we run the batch calculation with continue_on_batch_error=True,
# it will return the results with partially valid data
output_data = model.calculate_power_flow(
update_data=time_series_mutation,
continue_on_batch_error=True
)
# print node data for u_pu, note that the data is rubbish for scenario 3 and 7
print("Node data with invalid results")
print(output_data["node"]["u_pu"])
# we can print the data with only succeeded scenarios
e = model.batch_error
print("Node data with only valid results")
print(output_data["node"]["u_pu"][e.succeeded_scenarios])
Node data with invalid results
[[0.99940117 0.99268579 0.99452137]
[0.99934769 0.98622639 0.98935286]
[0.99928838 0.97965401 0.98409554]
[0. 0. 0. ]
[0.99915138 0.96614948 0.97329879]
[0.99907317 0.95920586 0.96775071]
[0.9989881 0.95212621 0.96209647]
[0. 0. 0. ]
[0.99879613 0.93753005 0.95044796]
[0.9986885 0.92999747 0.94444167]]
Node data with only valid results
[[0.99940117 0.99268579 0.99452137]
[0.99934769 0.98622639 0.98935286]
[0.99928838 0.97965401 0.98409554]
[0.99915138 0.96614948 0.97329879]
[0.99907317 0.95920586 0.96775071]
[0.9989881 0.95212621 0.96209647]
[0.99879613 0.93753005 0.95044796]
[0.9986885 0.92999747 0.94444167]]