AC Optimal Power Flow
JuliaGrid utilizes the JuMP package to construct optimal power flow models, allowing users to manipulate these models using the standard functions provided by JuMP. As a result, JuliaGrid supports popular solvers mentioned in the JuMP documentation to solve the optimization problem.
To perform the AC optimal power flow, we first need to have the PowerSystem
type that has been created with the AC model. After that, create the ACOptimalPowerFlow
type to establish the AC optimal power flow framework using the function:
To solve the AC optimal power flow problem and acquire bus voltage magnitudes and angles, and generator active and reactive power outputs, make use of the following function:
After obtaining the AC optimal power flow solution, JuliaGrid offers post-processing analysis functions to calculate powers and currents associated with buses and branches:
Additionally, specialized functions are available for calculating specific types of powers or currents for individual buses, branches, or generators.
Optimal Power Flow Model
To set up the AC optimal power flow, we begin by creating the model. To illustrate this, consider the following:
using JuMP, Ipopt
system = powerSystem()
@bus(minMagnitude = 0.95, maxMagnitude = 1.05)
addBus!(system; label = "Bus 1", type = 3, active = 0.1, angle = -0.1)
addBus!(system; label = "Bus 2", reactive = 0.01, magnitude = 1.1)
@branch(minDiffAngle = -pi, maxDiffAngle = pi, reactance = 0.5, type = 1)
addBranch!(system; label = "Branch 1", from = "Bus 1", to = "Bus 2", maxFromBus = 0.15)
@generator(maxActive = 0.5, minReactive = -0.1, maxReactive = 0.1, status = 0)
addGenerator!(system; label = "Generator 1", bus = "Bus 1", active = 0.4, reactive = 0.2)
addGenerator!(system; label = "Generator 2", bus = "Bus 2", active = 0.2, reactive = 0.1)
cost!(system; label = "Generator 1", active = 2, polynomial = [800.0; 200.0; 80.0])
cost!(system; label = "Generator 2", active = 1, piecewise = [10.8 12.3; 14.7 16.8; 18 18.1])
cost!(system; label = "Generator 1", reactive = 2, polynomial = [2.0])
cost!(system; label = "Generator 2", reactive = 1, piecewise = [2.0 4.0; 6.0 8.0])
acModel!(system)
Next, the acOptimalPowerFlow
function is utilized to formulate the AC optimal power flow problem:
analysis = acOptimalPowerFlow(system, Ipopt.Optimizer)
Optimization Variables
In the AC optimal power flow model, the active and reactive power outputs of the generators are expressed as nonlinear functions of the bus voltage magnitudes and angles. As a result, the variables in this model include the active and reactive power outputs of the generators, as well as the bus voltage magnitudes and angles:
julia> JuMP.all_variables(analysis.method.jump)
8-element Vector{VariableRef}: active[1] active[2] reactive[1] reactive[2] magnitude[1] magnitude[2] angle[1] angle[2]
It is important to note that this is not a comprehensive set of optimization variables. When the cost function is defined as a linear piecewise function comprising multiple segments, as illustrated in the case of the active power output cost for Generator 2
, JuliaGrid automatically generates helper optimization variables named actwise
and reactwise
, and formulates a set of linear constraints to effectively address these cost functions. For the sake of simplicity, we initially assume that Generator 2
is out-of-service. Consequently, the helper variable is not included in the set of optimization variables. However, as we progress through this manual, we will activate the generator, introducing the helper variable and additional constraints to the optimization model.
It is worth emphasizing that in instances where a linear piecewise cost function consists of only a single segment, as demonstrated by the reactive power output cost of Generator 2
, the function is modeled as a standard linear function, obviating the need for additional helper optimization variables.
Please be aware that JuliaGrid maintains references to all variables, which are categorized into six fields:
julia> fieldnames(typeof(analysis.method.variable))
(:active, :reactive, :magnitude, :angle, :actwise, :reactwise)
Variable Names
Users have the option to define custom variable names for printing and writing equations, which can help present them in a more compact form. For example:
analysis = acOptimalPowerFlow(system, Ipopt.Optimizer; magnitude = "V", angle = "θ")
Add Variables
The user has the ability to easily add new variables to the defined AC optimal power flow model by using the @variable
macro from the JuMP package:
JuMP.@variable(analysis.method.jump, newVariable)
We can verify that the new variable is included in the defined model by using the function:
julia> JuMP.is_valid(analysis.method.jump, newVariable)
true
Delete Variables
The variable can be deleted, but this operation is only applicable if the objective function is either affine or quadratic. To achieve this, we can utilize the delete
function provided by the JuMP, as demonstrated below:
JuMP.delete(analysis.method.jump, newVariable)
After deletion, the variable is no longer part of the model:
julia> JuMP.is_valid(analysis.method.jump, newVariable)
false
Constraint Functions
JuliaGrid keeps track of all the references to internally formed constraints in the constraint
field of the ACOptimalPowerFlow
type. These constraints are divided into six fields:
julia> fieldnames(typeof(analysis.method.constraint))
(:slack, :balance, :voltage, :flow, :capability, :piecewise)
We suggest that readers refer to the tutorial on AC Optimal Power Flow for insights into the implementation.
Slack Bus Constraint
The slack
field contains a reference to the equality constraint associated with the fixed bus voltage angle value of the slack bus. This constraint is set within the addBus!
function using the angle
keyword:
julia> print(system.bus.label, analysis.method.constraint.slack.angle)
Bus 1: θ[1] = -0.1
Users have the flexibility to modify this constraint by changing which bus serves as the slack bus and by adjusting the value of the bus angle. This can be achieved using the updateBus!
function, for example:
updateBus!(system, analysis; label = "Bus 1", type = 1)
updateBus!(system, analysis; label = "Bus 2", type = 3, angle = -0.2)
Subsequently, the updated slack constraint can be inspected as follows:
julia> print(system.bus.label, analysis.method.constraint.slack.angle)
Bus 2: θ[2] = -0.2
Bus Power Balance Constraints
The balance
field contains references to the equality constraints associated with the active and reactive power balance equations defined for each bus. These constraints ensure that the total active and reactive power injected by the generators matches the total active and reactive power demanded at each bus.
The constant term in the active power balance equations is determined by the active
keyword within the addBus!
function, which defines the active power demanded at the bus. We can access the references to the active power balance constraints using the following code snippet:
julia> print(system.bus.label, analysis.method.constraint.balance.active)
Bus 1: (-V[1]) * ((V[2] * ((2.0 * sin(θ[1] - θ[2]))))) - 0.1 = 0 Bus 2: (-V[2]) * ((V[1] * ((2.0 * sin(-θ[1] + θ[2]))))) - 0.0 = 0
Similarly, the constant term in the reactive power balance equations is determined by the reactive
keyword within the addBus!
function, which defines the reactive power demanded at the bus. We can access the references to the reactive power balance constraints using the following code snippet:
julia> print(system.bus.label, analysis.method.constraint.balance.reactive)
Bus 1: (-V[1]) * ((2 V[1]) + (V[2] * ( - (2.0 * cos(θ[1] - θ[2]))))) - 0.0 = 0 Bus 2: (-V[2]) * ((2 V[2]) + (V[1] * ( - (2.0 * cos(-θ[1] + θ[2]))))) - 0.01 = 0
During the execution of functions that add or update power system components, these constraints are automatically adjusted to reflect the current configuration of the power system, for example:
updateBus!(system, analysis; label = "Bus 2", active = 0.5)
updateBranch!(system, analysis; label = "Branch 1", reactance = 0.25)
The updated set of active power balance constraints can be examined as follows:
julia> print(system.bus.label, analysis.method.constraint.balance.active)
Bus 1: (-V[1]) * ((V[2] * ((4.0 * sin(θ[1] - θ[2]))))) - 0.1 = 0 Bus 2: (-V[2]) * ((V[1] * ((4.0 * sin(-θ[1] + θ[2]))))) - 0.5 = 0
Bus Voltage Constraints
The voltage
field contains references to the inequality constraints associated with the voltage magnitude and voltage angle difference limits. These constraints ensure that the bus voltage magnitudes and the angle differences between the from-bus and to-bus ends of each branch are within specified limits.
The minimum and maximum bus voltage magnitude limits are set using the minMagnitude
and maxMagnitude
keywords within the addBus!
function. The constraints associated with these limits can be accessed using:
julia> print(system.bus.label, analysis.method.constraint.voltage.magnitude)
Bus 1: V[1] ∈ [0.95, 1.05] Bus 2: V[2] ∈ [0.95, 1.05]
The minimum and maximum voltage angle difference limits between the from-bus and to-bus ends of each branch are set using the minDiffAngle
and maxDiffAngle
keywords within the addBranch!
function. The constraints associated with these limits can be accessed using the following code snippet:
julia> print(system.branch.label, analysis.method.constraint.voltage.angle)
Branch 1: θ[1] - θ[2] ∈ [-3.141592653589793, 3.141592653589793]
Please note that if the limit constraints are set to minDiffAngle = -2π
and maxDiffAngle = 2π
for the corresponding branch, JuliGrid will omit the corresponding inequality constraint.
Additionally, by employing the updateBus!
and updateBranch!
functions, the user has the ability to modify these specific constraints:
updateBus!(system, analysis; label = "Bus 1", minMagnitude = 1.0, maxMagnitude = 1.0)
updateBranch!(system, analysis; label = "Branch 1", minDiffAngle = -1.7, maxDiffAngle = 1.7)
Subsequently, the updated set of constraints can be examined as follows:
julia> print(system.bus.label, analysis.method.constraint.voltage.magnitude)
Bus 1: V[1] = 1 Bus 2: V[2] ∈ [0.95, 1.05]
julia> print(system.branch.label, analysis.method.constraint.voltage.angle)
Branch 1: θ[1] - θ[2] ∈ [-1.7, 1.7]
Branch Flow Constraints
The flow
field refers to inequality constraints that enforce limits on the apparent power flow, active power flow, or current flow magnitude at the from-bus and to-bus ends of each branch. The type of constraint applied is specified using the type
keyword in the addBranch!
function:
type = 1
active power flow,type = 2
apparent power flow,type = 3
apparent power flow with a squared inequality constraint,type = 4
current flow magnitude,type = 5
current flow magnitude with a squared inequality constraint.
Squared versions of constraints typically make the optimization problem numerically more robust. However, they often result in slower convergence compared to their non-squared counterparts used in the constraints.
These limits are specified using the minFromBus
, maxFromBus
, minToBus
and maxToBus
keywords within the addBranch!
function. By default, these limit keywords are associated with apparent power (type = 3
).
However, in the example, we configured it to use active power flow by setting type = 1
. To access the flow constraints of branches at the from-bus end, we can utilize the following code snippet:
julia> print(system.branch.label, analysis.method.constraint.flow.from)
Branch 1: - ((V[1]*V[2]) * ((-4.0 * sin(θ[1] - θ[2])))) ∈ [0, 0.15]
If the branch flow limits are set to minFromBus = 0.0
and maxFromBus = 0.0
for the corresponding branch, JuliGrid will omit the corresponding inequality constraint at the from-bus end of the branch. The same applies to the to-bus end if minToBus = 0.0
and maxToBus = 0.0
are set.
Additionally, by employing the updateBranch!
function, we have the ability to modify these specific constraints:
updateBranch!(system, analysis; label = "Branch 1", minFromBus = -0.15, maxToBus = 0.15)
The updated set of flow constraints can be examined as follows:
julia> print(system.branch.label, analysis.method.constraint.flow.from)
Branch 1: - ((V[1]*V[2]) * ((-4.0 * sin(θ[1] - θ[2])))) ∈ [-0.15, 0.15]
julia> print(system.branch.label, analysis.method.constraint.flow.to)
Branch 1: - ((V[1]*V[2]) * ( - (-4.0 * sin(θ[1] - θ[2])))) ∈ [0, 0.15]
In typical scenarios, minFromBus
is equal to minToBus
, and maxFromBus
is equal to maxToBus
. However, we allow these values to be defined separately for greater flexibility, enabling, among other things, the option to apply constraints on only one side of the branch.
Generator Power Capability Constraints
The capability
field contains references to the inequality constraints associated with the minimum and maximum active and reactive power outputs of the generators.
The constraints associated with the minimum and maximum active power output limits of the generators are defined using the minActive
and maxActive
keywords within the addGenerator!
function. To access the constraints associated with these limits, we can use the following code snippet:
julia> print(system.generator.label, analysis.method.constraint.capability.active)
Generator 1: active[1] = 0 Generator 2: active[2] = 0
Similarly, the constraints associated with the minimum and maximum reactive power output limits of the generators are specified using the minReactive
and maxReactive
keywords within the addGenerator!
function. To access these constraints, we can use the following code snippet:
julia> print(system.generator.label, analysis.method.constraint.capability.reactive)
Generator 1: reactive[1] = 0 Generator 2: reactive[2] = 0
As demonstrated, the active and reactive power outputs of Generator 1
and Generator 2
are currently fixed at zero due to previous actions that set these generators out-of-service. However, we can modify these specific constraints by utilizing the updateGenerator!
function, as shown below:
updateGenerator!(system, analysis; label = "Generator 1", status = 1)
updateGenerator!(system, analysis; label = "Generator 2", status = 1, minActive = 0.1)
Subsequently, the updated set of constraints can be examined as follows:
julia> print(system.generator.label, analysis.method.constraint.capability.active)
Generator 1: active[1] ∈ [0, 0.5] Generator 2: active[2] ∈ [0.1, 0.5]
julia> print(system.generator.label, analysis.method.constraint.capability.reactive)
Generator 1: reactive[1] ∈ [-0.1, 0.1] Generator 2: reactive[2] ∈ [-0.1, 0.1]
This representation may not fully capture the generator's power output behavior due to the tradeoff between active and reactive power outputs. JuliaGrid can incorporate this tradeoff in its optimization model. For more information, see the tutorial on Power Capability Constraints.
Power Piecewise Constraints
In the context of cost modeling, the piecewise
field acts as a reference to the inequality constraints associated with linear piecewise cost functions. These constraints are established using the cost!
function, with active = 1
or reactive = 1
specified when working with linear piecewise cost functions that consist of multiple segments.
In our example, only the active power cost of Generator 2
is modeled as a linear piecewise function with two segments, and JuliaGrid takes care of setting up the appropriate inequality constraints for each segment:
julia> print(system.generator.label, analysis.method.constraint.piecewise.active)
Generator 2: 1.1538461538461542 active[2] - actwise[2] ≤ 0.16153846153846452 Generator 2: 0.3939393939393941 active[2] - actwise[2] ≤ -11.009090909090908
It is worth noting that these constraints can also be automatically updated using the cost!
function. Readers can find more details in the section discussing the objective function.
As mentioned at the beginning, linear piecewise cost functions with multiple segments will also introduce helper variables that are added to the objective function. In this specific example, the helper variable is:
julia> analysis.method.variable.actwise[2]
actwise[2]
Add Constraints
Users can effortlessly introduce additional constraints into the defined AC optimal power flow model by utilizing the addBranch!
or addGenerator!
functions. Specifically, if a user wishes to include a new branch or generator in an already defined PowerSystem
and ACOptimalPowerFlow
type:
addBranch!(system, analysis; label = "Branch 2", from = "Bus 1", to = "Bus 2", reactance = 1)
addGenerator!(system, analysis; label = "Generator 3", bus = "Bus 2", active = 2, status = 1)
This will affect all constraints related to branches and generators, but it will also update balance constraints to configure the optimization model to match the current state of the power system. For example, we can observe the following updated constraints:
julia> print(system.branch.label, analysis.method.constraint.voltage.angle)
Branch 1: θ[1] - θ[2] ∈ [-1.7, 1.7] Branch 2: θ[1] - θ[2] ∈ [-3.141592653589793, 3.141592653589793]
julia> print(system.generator.label, analysis.method.constraint.capability.active)
Generator 1: active[1] ∈ [0, 0.5] Generator 2: active[2] ∈ [0.1, 0.5] Generator 3: active[3] ∈ [0, 0.5]
Add User-Defined Constraints
Users also have the option to include their custom constraints within the established AC optimal power flow model by employing the @constraint
macro. For example, the addition of a new constraint can be achieved as follows:
JuMP.@constraint(analysis.method.jump, 0.0 <= analysis.method.variable.active[3] <= 0.3)
Delete Constraints
To delete a constraint, users can make use of the delete
function from the JuMP package. When handling constraints that have been internally created, users can refer to the constraint references stored in the constraint
field of the ACOptimalPowerFlow
type.
For example, if the intention is to eliminate constraints related to the capability of Generator 3
, we can use:
JuMP.delete(analysis.method.jump, analysis.method.constraint.capability.active[3])
In the event that a user deletes a constraint and subsequently executes a function that updates bus, branch, or generator parameters, and if the deleted constraint is affected by these functions, JuliaGrid will automatically reinstate that constraint. Users should exercise caution when deleting constraints, as this action is considered potentially harmful since it operates independently of power system data.
Objective Function
The objective function of the AC optimal power flow is formulated using polynomial and linear piecewise cost functions associated with the generators, defined using the cost!
functions.
In the provided example, the objective function to be minimized in order to obtain optimal values for the active and reactive power outputs of the generators, as well as the bus voltage magnitudes and angles, is as follows:
julia> JuMP.objective_function(analysis.method.jump)
800 active[1]² + 200 active[1] + actwise[2] + reactive[2] + 84
JuliaGrid also stores the objective function in a separate variable, which can be accessed by referring to the variable analysis.objective
. In this variable, the objective function is organized in a way that separates the quadratic and nonlinear components of the objective function.
Update Objective Function
By utilizing the cost!
functions, users have the flexibility to modify the objective function by adjusting polynomial or linear piecewise coefficients or by changing the type of polynomial or linear piecewise function employed. For example, consider Generator 1
, which employs a quadratic polynomial cost function for active power. We can redefine the cost function for this generator as a cubic polynomial and thereby define a nonlinear objective function:
cost!(system, analysis; label = "Generator 1", active = 2, polynomial = [631; 257; 40; 5.0])
This leads to an updated objective function, which can be examined as follows:
julia> JuMP.objective_function(analysis.method.jump)
(257 active[1]² + 40 active[1] + actwise[2] + reactive[2] + 9) + (631.0 * (active[1] ^ 3.0))
User-Defined Objective Function
Users can modify the objective function using the set_objective_function
function from the JuMP package. This operation is considered destructive because it is independent of power system data; however, in certain scenarios, it may be more straightforward than using the cost!
function for updates. Moreover, using this methodology, users can combine a defined function with a newly defined expression.
In this context, we can utilize the saved objective function within the objective
field of the ACOptimalPowerFlow
type. For example, we can easily eliminate nonlinear parts and alter the quadratic component of the objective:
expr = 5.0 * analysis.method.variable.active[1] * analysis.method.variable.active[1]
JuMP.set_objective_function(analysis.method.jump, analysis.method.objective.quadratic - expr)
We can now observe the updated objective function as follows:
julia> JuMP.objective_function(analysis.method.jump)
252 active[1]² + 40 active[1] + actwise[2] + reactive[2] + 9
Setup Starting Values
In JuliaGrid, the assignment of starting primal and dual values for optimization variables and constraints takes place when the solve!
function is executed.
Starting Primal Values
Starting primal values are determined based on the generator
and voltage
fields within the ACOptimalPowerFlow
type. By default, these values are initially established using the active and reactive power outputs of the generators and the initial bus voltage magnitudes and angles:
julia> generator = analysis.power.generator;
julia> print(system.generator.label, generator.active, generator.reactive)
Generator 1: 0.4, 0.2 Generator 2: 0.2, 0.1 Generator 3: 2.0, 0.0
julia> print(system.bus.label, analysis.voltage.magnitude, analysis.voltage.angle)
Bus 1: 1.0, -0.1 Bus 2: 1.1, -0.2
Users have the flexibility to adjust these values according to their specifications, which will then be used as the starting primal values when executing the solve!
function.
Using AC Power Flow
In this perspective, users have the capability to conduct the AC power flow analysis and leverage the resulting solution to configure starting primal values. Here is an illustration of how this can be achieved:
flow = newtonRaphson(system)
for iteration = 1:100
stopping = mismatch!(system, flow)
if all(stopping .< 1e-8)
break
end
solve!(system, flow)
end
After obtaining the solution, we can calculate the active and reactive power outputs of the generators and utilize the bus voltage magnitudes and angles to set the starting values. In this case, the generator
and voltage
fields of the ACOptimalPowerFlow
type can be employed to store the new starting values:
for (key, idx) in system.generator.label
active, reactive = generatorPower(system, flow; label = key)
analysis.power.generator.active[idx] = active
analysis.power.generator.reactive[idx] = reactive
end
for i = 1:system.bus.number
analysis.voltage.magnitude[i] = flow.voltage.magnitude[i]
analysis.voltage.angle[i] = flow.voltage.angle[i]
end
Starting Dual Values
Dual variables, often referred to as Lagrange multipliers or Kuhn-Tucker multipliers, represent the shadow prices or marginal costs associated with constraints. The assignment of initial dual values occurs when the solve!
function is executed. Initially, the starting dual values are unknown, but users can access and manually set them. For example:
analysis.method.dual.balance.active[1] = 0.4
Optimal Power Flow Solution
To establish the AC optimal power flow problem, we can utilize the acOptimalPowerFlow
function. After setting up the problem, we can use the solve!
function to compute the optimal values for the active and reactive power outputs of the generators and the bus voltage magnitudes angles. Also, to turn off the solver output within the REPL, we use the set_silent
function before calling solve!
function. Here is an example:
JuMP.set_silent(analysis.method.jump)
solve!(system, analysis)
By executing this function, we will obtain the solution with the optimal values for the active and reactive power outputs of the generators, as well as the bus voltage magnitudes and angles.
julia> generator = analysis.power.generator;
julia> print(system.generator.label, generator.active, generator.reactive)
Generator 1: -9.847040444910615e-9, 0.053699893519534185 Generator 2: 0.30000001503710166, -0.10000000422506586 Generator 3: 0.2999999948099388, 0.05887684681121572
julia> print(system.bus.label, analysis.voltage.magnitude, analysis.voltage.angle)
Bus 1: 1.0, -0.2202143797620762 Bus 2: 0.9894621719972532, -0.2
Objective Value
To obtain the objective value of the optimal power flow solution, we can use the objective_value
function:
julia> JuMP.objective_value(analysis.method.jump)
20.02727223105133
Dual Variables
The values of the dual variables are stored in the dual
field of the ACOptimalPowerFlow
type. For example:
julia> analysis.method.dual.balance.active[1]
0.39393945244763234
Print Results in the REPL
Users can utilize the functions printBusData
and printGeneratorData
to display results. Additionally, the functions listed in the Print Constraint Data section allow users to print constraint data related to buses, branches, or generators in the desired units. For example:
@power(MW, MVAr, pu)
show = Dict("Active Power Balance" => false)
printBusConstraint(system, analysis; show)
|---------------------------------------------------------------------------|
| Bus Constraint Data |
|---------------------------------------------------------------------------|
| Label | Voltage Magnitude | Reactive Power Balance |
| | | |
| | Minimum | Solution | Maximum | Dual | Solution | Dual |
| | [pu] | [pu] | [pu] | [$/pu-hr] | [MVAr] | [$/MVAr-hr] |
|-------|---------|----------|---------|-----------|----------|-------------|
| Bus 1 | 1.0000 | 1.0000 | 1.0000 | -0.0000 | -0.0000 | 0.0000 |
| Bus 2 | 0.9500 | 0.9895 | 1.0500 | 0.0000 | -0.0000 | 0.0000 |
|---------------------------------------------------------------------------|
Next, users can easily customize the print results for specific constraint, for example:
printBusConstraint(system, analysis; label = "Bus 1", header = true)
printBusConstraint(system, analysis; label = "Bus 2", footer = true)
Save Results to a File
Users can also redirect print output to a file. For example, data can be saved in a text file as follows:
open("bus.txt", "w") do file
printBusConstraint(system, analysis, file)
end
Save Results to a CSV File
For CSV output, users should first generate a simple table with style = false
, and then save it to a CSV file:
using CSV
io = IOBuffer()
printBusConstraint(system, analysis, io; style = false)
CSV.write("constraint.csv", CSV.File(take!(io); delim = "|"))
Primal and Dual Warm Start
Utilizing the ACOptimalPowerFlow
type and proceeding directly to the solver offers the advantage of a "warm start". In this scenario, the starting primal and dual values for the subsequent solving step correspond to the solution obtained from the previous step.
Primal Variables
In the previous example, the following solution was obtained, representing the values of the primal variables:
julia> print(system.generator.label, generator.active, generator.reactive)
Generator 1: -9.847040444910615e-9, 0.053699893519534185 Generator 2: 0.30000001503710166, -0.10000000422506586 Generator 3: 0.2999999948099388, 0.05887684681121572
julia> print(system.bus.label, analysis.voltage.magnitude, analysis.voltage.angle)
Bus 1: 1.0, -0.2202143797620762 Bus 2: 0.9894621719972532, -0.2
Dual Variables
We also obtained all dual values. Here, we list only the dual variables for one type of constraint as an example:
julia> print(system.generator.label, analysis.method.dual.capability.reactive)
Generator 1: -9.800017812106651e-8 Generator 2: 0.999999888440207 Generator 3: -1.1155979297348172e-7
Modify Optimal Power Flow
Now, let us introduce changes to the power system from the previous example:
updateGenerator!(system, analysis; label = "Generator 2", maxActive = 0.08)
Next, we want to solve this modified optimal power flow problem. If we use solve!
at this point, the primal and dual starting values will be set to the previously obtained values:
solve!(system, analysis)
As a result, we obtain a new solution:
julia> print(system.generator.label, generator.active, generator.reactive)
Generator 1: -9.847040444910615e-9, 0.053699893519534185 Generator 2: 0.30000001503710166, -0.10000000422506586 Generator 3: 0.2999999948099388, 0.05887684681121572
julia> print(system.bus.label, analysis.voltage.magnitude, analysis.voltage.angle)
Bus 1: 1.0, -0.2202143797620762 Bus 2: 0.9894621719972532, -0.2
Reset Primal and Dual Values
Users retain the flexibility to reset initial primal values to their default configurations at any juncture. This can be accomplished by utilizing the active and reactive power outputs of the generators and the initial bus voltage magnitudes and angles extracted from the PowerSystem
type, employing the startingPrimal!
function:
startingPrimal!(system, analysis)
The primal starting values will now be identical to those that would be obtained if the acOptimalPowerFlow
function were executed after all the updates have been applied.
Using the startingDual!
function, users can clear all dual variable values, resetting them to their default state:
startingDual!(system, analysis)
Power and Current Analysis
After obtaining the solution from the AC optimal power flow, we can calculate various electrical quantities related to buses and branches using the power!
and current!
functions. For instance, let us consider the power system for which we obtained the AC optimal power flow solution:
using Ipopt
system = powerSystem()
@bus(minMagnitude = 0.9, maxMagnitude = 1.1)
addBus!(system; label = "Bus 1", type = 3, magnitude = 1.05, angle = 0.17)
addBus!(system; label = "Bus 2", active = 0.1, reactive = 0.01, conductance = 0.04)
addBus!(system; label = "Bus 3", active = 0.05, reactive = 0.02)
@branch(resistance = 0.5, reactance = 1.0, conductance = 1e-4, susceptance = 0.01)
addBranch!(system; label = "Branch 1", from = "Bus 1", to = "Bus 2", maxFromBus = 0.15)
addBranch!(system; label = "Branch 2", from = "Bus 1", to = "Bus 3", maxFromBus = 0.10)
addBranch!(system; label = "Branch 3", from = "Bus 2", to = "Bus 3", maxFromBus = 0.25)
@generator(maxActive = 0.5, minReactive = -0.1, maxReactive = 0.1)
addGenerator!(system; label = "Generator 1", bus = "Bus 1", active = 3.2, reactive = 0.5)
addGenerator!(system; label = "Generator 2", bus = "Bus 2", active = 0.2, reactive = 0.1)
cost!(system; label = "Generator 1", active = 2, polynomial = [1100.2; 500; 80])
cost!(system; label = "Generator 2", active = 1, piecewise = [10.8 12.3; 14.7 16.8; 18 18.1])
analysis = acOptimalPowerFlow(system, Ipopt.Optimizer)
solve!(system, analysis)
We can now utilize the following functions to calculate powers and currents:
power!(system, analysis)
current!(system, analysis)
For instance, if we want to show the active power injections and the from-bus current magnitudes, we can employ:
julia> print(system.bus.label, analysis.power.injection.active)
Bus 1: -9.612864106285646e-9 Bus 2: 0.08516606808653232 Bus 3: -0.0499999999925584
julia> print(system.branch.label, analysis.current.from.magnitude)
Branch 1: 0.02659167712850094 Branch 2: 0.023283650969243567 Branch 3: 0.03814230961962449
To better understand the powers and current associated with buses and branches that are calculated by the power!
and current!
functions, we suggest referring to the tutorials on AC Optimal Power Flow.
Print Results in the REPL
Users can utilize any of the print functions outlined in the Print Power System Data or Print Power System Summary. For example, to create a bus data with the desired units, users can use the following function:
@voltage(pu, deg, V)
@power(MW, MVAr, pu)
show = Dict("Power Generation" => false, "Current Injection" => false)
printBusData(system, analysis; show)
|-------------------------------------------------------------------------------------------|
| Bus Data |
|-------------------------------------------------------------------------------------------|
| Label | Voltage | Power Demand | Power Injection | Shunt Power |
| | | | | |
| Bus | Magnitude | Angle | Active | Reactive | Active | Reactive | Active | Reactive |
| | [pu] | [deg] | [MW] | [MVAr] | [MW] | [MVAr] | [MW] | [MVAr] |
|-------|-----------|---------|---------|----------|---------|----------|--------|----------|
| Bus 1 | 0.9280 | 9.7403 | 0.0000 | 0.0000 | -0.0000 | 3.0806 | 0.0000 | -0.0000 |
| Bus 2 | 0.9140 | 11.6479 | 10.0000 | 1.0000 | 8.5166 | -3.2874 | 3.3417 | -0.0000 |
| Bus 3 | 0.9000 | 9.1613 | 5.0000 | 2.0000 | -5.0000 | -2.0000 | 0.0000 | -0.0000 |
|-------------------------------------------------------------------------------------------|
Active and Reactive Power Injection
To calculate the active and reactive power injection associated with a specific bus, the function can be used:
julia> active, reactive = injectionPower(system, analysis; label = "Bus 1")
(-9.612864106285646e-9, 0.030805631272862018)
Active and Reactive Power Injection from Generators
To calculate the active and reactive power injection from the generators at a specific bus, the function can be used:
julia> active, reactive = supplyPower(system, analysis; label = "Bus 2")
(0.18516606807837058, -0.022874155704311887)
Active and Reactive Power at Shunt Element
To calculate the active and reactive power associated with shunt element at a specific bus, the function can be used:
julia> active, reactive = shuntPower(system, analysis; label = "Bus 2")
(0.033417015994749506, -0.0)
Active and Reactive Power Flow
Similarly, we can compute the active and reactive power flow at both the from-bus and to-bus ends of the specific branch by utilizing the provided functions below:
julia> active, reactive = fromPower(system, analysis; label = "Branch 2")
(0.0171841141656561, 0.013096900696542745)
julia> active, reactive = toPower(system, analysis; label = "Branch 2")
(-0.016754104690082913, -0.02075951689659613)
Active and Reactive Power at Charging Admittances
To calculate the total active and reactive power linked with branch charging admittances of the particular branch, the function can be used:
julia> active, reactive = chargingPower(system, analysis; label = "Branch 1")
(8.48258844904104e-5, -0.00848258844904104)
Active powers indicate active losses within the branch's charging admittances. Moreover, charging admittances injected reactive powers into the power system due to their capacitive nature, as denoted by a negative sign.
Active and Reactive Power at Series Impedance
To calculate the active and reactive power across the series impedance of the branch, the function can be used:
julia> active, reactive = seriesPower(system, analysis; label = "Branch 2")
(0.000346454228992816, 0.0006929084579856317)
The active power also considers active losses originating from the series resistance of the branch, while the reactive power represents reactive losses resulting from the impedance's inductive characteristics.
Current Injection
To calculate the current injection associated with a specific bus, the function can be used:
julia> magnitude, angle = injectionCurrent(system, analysis; label = "Bus 1")
(0.03319749105151072, -1.400796638843821)
Current Flow
We can compute the current flow at both the from-bus and to-bus ends of the specific branch by using:
julia> magnitude, angle = fromCurrent(system, analysis; label = "Branch 2")
(0.023283650969243567, -0.48123290193360435)
julia> magnitude, angle = toCurrent(system, analysis; label = "Branch 2")
(0.0296407806140337, 2.409719424706662)
Current Through Series Impedance
To calculate the current passing through the series impedance of the branch in the direction from the from-bus end to the to-bus end, we can use the following function:
julia> magnitude, angle = seriesCurrent(system, analysis; label = "Branch 2")
(0.026323154407966228, -0.6229624867446706)