Running Power Flow In The Loop with Unit Commitment

To follow along, you can download this tutorial as a Julia script (.jl) or Jupyter notebook (.ipynb).

In this tutorial, you'll configure a unit commitment (UC) simulation that automatically calls an AC power flow solver from PowerFlows.jl at every dispatch interval.

You'll validate the committed dispatch against the full AC network model at each hour, check for branch overloads, and export the results to PSS/e format if needed for further analysis.

This tutorial builds on the tutorial for Running a Multi-Stage Production Cost Simulation.

Setup

Load the needed packages and define basic inputs:

using PowerSystemCaseBuilder
using PowerSimulations
using HydroPowerSimulations
using PowerFlows
using PowerSystems
using DataFrames
using HiGHS
using Dates
using Logging
const SCENARIO_NAME = "uc_with_pf"
const INLOOP_SYSTEM = "modified_RTS_GMLC_DA_sys"
const INLOOP_SIM_STEPS = 1

run_dir = joinpath(".", "uc_power_flow_in_the_loop_results")
mkpath(run_dir)
export_dir = joinpath(run_dir, "psse_exports")
mkpath(export_dir)
"./uc_power_flow_in_the_loop_results/psse_exports"

Build a test PowerSystems.System via PowerSystemCaseBuilder.build_system. We use modified_RTS_GMLC_DA_sys (24+ DA forecast steps):

sys = build_system(
    PSISystems,
    INLOOP_SYSTEM;
    skip_serialization = true,
    runchecks = false,
)
System
Property Value
Name
Description
System Units Base SYSTEM_BASE
Base Power 100.0
Base Frequency 60.0
Num Components 504
Static Components
Type Count
ACBus 73
Arc 109
Area 3
FixedAdmittance 3
HydroDispatch 1
Line 105
LoadZone 21
PowerLoad 51
RenewableDispatch 29
RenewableNonDispatch 31
SynchronousCondenser 3
TapTransformer 15
ThermalStandard 54
TwoTerminalGenericHVDCLine 1
VariableReserve{ReserveDown} 1
VariableReserve{ReserveUp} 4
StaticTimeSeries Summary
owner_type owner_category name time_series_type initial_timestamp resolution count time_step_count
String String String String String Dates.CompoundPeriod Int64 Int64
Area Component max_active_power SingleTimeSeries 2020-01-01T00:00:00 1 hour 3 8784
FixedAdmittance Component max_active_power SingleTimeSeries 2020-01-01T00:00:00 1 hour 3 8784
HydroDispatch Component max_active_power SingleTimeSeries 2020-01-01T00:00:00 1 hour 1 8784
PowerLoad Component max_active_power SingleTimeSeries 2020-01-01T00:00:00 1 hour 51 8784
RenewableDispatch Component max_active_power SingleTimeSeries 2020-01-01T00:00:00 1 hour 29 8784
RenewableNonDispatch Component max_active_power SingleTimeSeries 2020-01-01T00:00:00 1 hour 31 8784
VariableReserve Component requirement SingleTimeSeries 2020-01-01T00:00:00 1 hour 5 8784
Forecast Summary
owner_type owner_category name time_series_type initial_timestamp resolution count horizon interval window_count
String String String String String Dates.CompoundPeriod Int64 Dates.CompoundPeriod Dates.CompoundPeriod Int64
Area Component max_active_power DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 3 2 days 1 day 365
FixedAdmittance Component max_active_power DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 3 2 days 1 day 365
HydroDispatch Component max_active_power DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 1 2 days 1 day 365
PowerLoad Component max_active_power DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 51 2 days 1 day 365
RenewableDispatch Component max_active_power DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 29 2 days 1 day 365
RenewableNonDispatch Component max_active_power DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 31 2 days 1 day 365
VariableReserve Component requirement DeterministicSingleTimeSeries 2020-01-01T00:00:00 1 hour 5 2 days 1 day 365

Configuring the Power Flow Solver

Create an PowerFlows.ACPowerFlow solver. We attach a PowerFlows.PSSEExportPowerFlow exporter so that we can automatically write a PSS/e .raw file for each solved interval.

psse_export = PSSEExportPowerFlow(;
    psse_version = :v33,
    export_dir = export_dir,
    overwrite = true,
)

power_flow_model = ACPowerFlow(; exporter = psse_export)
PowerFlows.ACPolarPowerFlow{PowerFlows.NewtonRaphsonACPowerFlow}(false, PowerFlows.PSSEExportPowerFlow(:v33, "./uc_power_flow_in_the_loop_results/psse_exports", "export", false, true), false, false, false, nothing, true, false, false, false, PowerNetworkMatrices.NetworkReduction[], 1, String[], false, Dict{Symbol, Any}())

Alternative: Fast Decoupled (FDNR) solver

PowerFlows 0.22 adds a fast-decoupled AC solver, selected via the solver type parameter of PowerFlows.ACPowerFlow. It plugs into power_flow_evaluation exactly like the default Newton-Raphson solver (same auxiliary variables and PSS/e exports) and matches its converged solution, but is often faster on large networks. Optionally hand off to an exact Newton solver for the final refinement:

using PowerFlows: FastDecoupledACPowerFlow, NewtonRaphsonACPowerFlow

fd_power_flow_model = ACPowerFlow{FastDecoupledACPowerFlow}(;
    exporter = psse_export,
    solver_settings = Dict{Symbol, Any}(
        :handoff_solver => NewtonRaphsonACPowerFlow,  # refine FD result with Newton-Raphson
        :handoff_tol => 1e-3,                         # FD-stage exit tolerance before handoff
    ),
)

Use it in place of power_flow_model below. Omit solver_settings for pure fast decoupled, or use the FastDecoupledFixed alias for the formulation-agnostic frozen-Jacobian variant.

Building the UC Problem Template

Create a ProblemTemplate with a NetworkModel that uses PTDFPowerModel and power_flow_evaluation set to the solver we just configured. Assign device formulations with set_device_model!. This is the key step that enables power flow in the loop:

template_uc = ProblemTemplate(
    NetworkModel(
        PTDFPowerModel;
        use_slacks = true,
        power_flow_evaluation = power_flow_model,
    ),
)

set_device_model!(
    template_uc,
    ThermalStandard,
    ThermalStandardUnitCommitment,
)
set_device_model!(template_uc, RenewableDispatch, RenewableFullDispatch)
set_device_model!(template_uc, RenewableNonDispatch, FixedOutput)
set_device_model!(template_uc, PowerLoad, StaticPowerLoad)
set_device_model!(template_uc, HydroDispatch, HydroDispatchRunOfRiver)
set_device_model!(template_uc, Line, StaticBranchUnbounded)
set_device_model!(template_uc, Transformer2W, StaticBranchUnbounded)
set_device_model!(template_uc, MonitoredLine, StaticBranch)

use_slacks = true allows the simulation to remain feasible when there is a small mismatch between the PTDF-based UC network model and the full AC power flow.

Building and Executing the Simulation

Follow the same pattern as in the tutorial for Running a Multi-Stage Production Cost Simulation: package the UC DecisionModel in SimulationModels, define a SimulationSequence with InterProblemChronology, construct a Simulation, then build! and execute! it for one simulation step. The in-step horizon on modified_RTS_GMLC_DA_sys provides 24 hourly realized rows.

solver = optimizer_with_attributes(
    HiGHS.Optimizer,
    "log_to_console" => false,
    "mip_rel_gap" => 0.05,
    "time_limit" => 900.0,
)

models = SimulationModels(;
    decision_models = [
        DecisionModel(
            template_uc,
            sys;
            name = "UC",
            optimizer = solver,
            store_variable_names = true,
        ),
    ],
)

sequence = SimulationSequence(;
    models = models,
    ini_cond_chronology = InterProblemChronology(),
)

sim = Simulation(;
    name = SCENARIO_NAME,
    steps = INLOOP_SIM_STEPS,
    models = models,
    sequence = sequence,
    simulation_folder = run_dir,
)

build!(sim; console_level = Logging.Error, file_level = Logging.Error)
execute!(sim; enable_progress_bar = true)
InfrastructureSystems.Simulation.RunStatusModule.RunStatus.SUCCESSFULLY_FINALIZED = 0

Loading Simulation Results

Load the simulation results with SimulationResults and extract the UC problem results with get_decision_problem_results:

sim_results = SimulationResults(sim)
uc_results = get_decision_problem_results(sim_results, "UC")

Start: 2020-01-01T00:00:00

End: 2020-01-01T00:00:00

Resolution: 60 minutes

UC Problem Auxiliary variables Results
PowerFlowBranchActivePowerLoss__TwoTerminalGenericHVDCLine
PowerFlowBranchActivePowerToFrom__Line
PowerFlowBranchReactivePowerFromTo__Line
PowerFlowBranchReactivePowerToFrom__TwoTerminalGenericHVDCLine
PowerFlowBranchActivePowerLoss__Line
PowerFlowBranchActivePowerToFrom__TapTransformer
PowerFlowBranchActivePowerFromTo__TapTransformer
PowerFlowBranchActivePowerFromTo__Line
TimeDurationOn__ThermalStandard
PowerFlowVoltageAngle__ACBus
PowerFlowBranchReactivePowerFromTo__TwoTerminalGenericHVDCLine
PowerFlowBranchReactivePowerFromTo__TapTransformer
HydroEnergyOutput__HydroDispatch
PowerFlowBranchActivePowerFromTo__TwoTerminalGenericHVDCLine
PowerFlowBranchActivePowerLoss__TapTransformer
PowerFlowBranchActivePowerToFrom__TwoTerminalGenericHVDCLine
PowerFlowBranchReactivePowerToFrom__Line
PowerFlowVoltageMagnitude__ACBus
PowerFlowBranchReactivePowerToFrom__TapTransformer
TimeDurationOff__ThermalStandard
UC Problem Expressions Results
ActivePowerBalance__System
VOMCostExpression__RenewableDispatch
StartUpCostExpression__ThermalStandard
VOMCostExpression__ThermalStandard
ProductionCostExpression__HydroDispatch
CurtailmentCostExpression__RenewableDispatch
ProductionCostExpression__ThermalStandard
PTDFBranchFlow__Line
FuelCostExpression__ThermalStandard
ActivePowerBalance__ACBus
FixedCostExpression__RenewableDispatch
FuelConsumptionExpression__ThermalStandard
ShutDownCostExpression__ThermalStandard
ProductionCostExpression__RenewableDispatch
FixedCostExpression__ThermalStandard
UC Problem Parameters Results
ActivePowerTimeSeriesParameter__PowerLoad
ActivePowerTimeSeriesParameter__RenewableDispatch
ReactivePowerTimeSeriesParameter__PowerLoad
ReactivePowerTimeSeriesParameter__RenewableNonDispatch
ReactivePowerTimeSeriesParameter__HydroDispatch
ActivePowerTimeSeriesParameter__RenewableNonDispatch
ActivePowerTimeSeriesParameter__HydroDispatch
ReactivePowerTimeSeriesParameter__RenewableDispatch
UC Problem Variables Results
StopVariable__ThermalStandard
ActivePowerVariable__ThermalStandard
ActivePowerVariable__RenewableDispatch
ActivePowerVariable__HydroDispatch
OnVariable__ThermalStandard
SystemBalanceSlackDown__System
SystemBalanceSlackUp__System
StartVariable__ThermalStandard

Notice the "UC Problem Auxiliary variables Results" table, which lists the active and reactive power flow and bus voltage magnitude and angle results from the AC power flow (e.g., PowerFlowBranchReactivePowerFromTo__Line, PowerFlowVoltageMagnitude__ACBus). These are not output when a UC problem is run alone.

PTDF UC Flows vs. AC Power Flow In the Loop

Now, we'll compare the PTDF UC flows to the AC power flow results.

The UC stage optimizes flows using the PTDF network model, and the line flows are not variables; they are recorded in the PTDFBranchFlow__* expressions.

First, load in the PTDF branch flows for one line with read_realized_expression:

ptdf_flows = read_realized_expression(uc_results, "PTDFBranchFlow__Line")
example_line = first(unique(ptdf_flows.name))
ptdf_line = filter(row -> row.name == example_line, ptdf_flows)
14 rows omitted
DateTime name value
Dates.DateTime String Float64
2020-01-01T00:00:00 A1 20.43518634150661
2020-01-01T01:00:00 A1 18.852326885851145
2020-01-01T02:00:00 A1 20.101190151287433
2020-01-01T03:00:00 A1 20.68782794169944
2020-01-01T04:00:00 A1 17.176381692046068
2020-01-01T05:00:00 A1 -3.8607101779936133
2020-01-01T06:00:00 A1 -1.013848284882815
2020-01-01T07:00:00 A1 15.873260486691185
2020-01-01T08:00:00 A1 19.147482411110104
2020-01-01T09:00:00 A1 17.913068986497123

After each in-step hour, the AC power flow writes branch flows into auxiliary variables such as PowerFlowBranchActivePowerFromTo__Line. Load those flows with read_realized_aux_variable and compare PTDF expression flows to AC flows for our selected line:

pf_flows_ft = read_realized_aux_variable(
    uc_results,
    "PowerFlowBranchActivePowerFromTo__Line",
)
pf_line = filter(row -> row.name == example_line, pf_flows_ft)

innerjoin(
    rename(select(ptdf_line, :DateTime, :value), :value => :PTDF_MW),
    rename(select(pf_line, :DateTime, :value), :value => :AC_PF_MW);
    on = :DateTime,
)
14 rows omitted
DateTime PTDF_MW AC_PF_MW
Dates.DateTime Float64 Float64
2020-01-01T00:00:00 20.43518634150661 17.926093526804966
2020-01-01T01:00:00 18.852326885851145 16.579285792660702
2020-01-01T02:00:00 20.101190151287433 17.731748912206456
2020-01-01T03:00:00 20.68782794169944 18.234689819612292
2020-01-01T04:00:00 17.176381692046068 15.071265653220175
2020-01-01T05:00:00 -3.8607101779936133 -5.6961587292191345
2020-01-01T06:00:00 -1.013848284882815 -2.7126420486232408
2020-01-01T07:00:00 15.873260486691185 15.36967363626435
2020-01-01T08:00:00 19.147482411110104 18.752985881278928
2020-01-01T09:00:00 17.913068986497123 17.606352110811233

With use_slacks = true in the template, small differences between PTDF and AC flows are expected — the UC model can absorb minor mismatches. The AC auxiliary flows should track the PTDF values closely when the network is well conditioned.

Checking for Branch Overloads

Now, we will run a post-hoc check on our UC results to scan every interval for branches whose AC flow exceeds the thermal rating.

First, build a helper to extract branch flow limits from the PowerSystems.Systemname in the auxiliary results matches the branch name on each PowerSystems.ACBranch. Limits are scaled with PowerSystems.get_base_power. AC lines and transformers use PowerSystems.get_rating; HVDC branches use active-power limits instead.

function _branch_flow_limit_mw(branch, sys)
    base_power = get_base_power(sys)
    try
        if branch isa TwoTerminalVSCLine
            return get_rating(branch) * base_power
        elseif branch isa TwoTerminalHVDC
            return max(
                get_active_power_limits_from(branch).max,
                get_active_power_limits_to(branch).max,
            )
        elseif branch isa GenericArcImpedance
            return get_max_flow(branch) * base_power
        else
            return get_rating(branch) * base_power
        end
    catch e
        e isa MethodError || rethrow(e)
        @warn "Could not get rating for $(typeof(branch)): $e — treating as unlimited"
        return Inf
    end
end
_branch_flow_limit_mw (generic function with 1 method)

Next, build a lookup for each PowerSystems.ACBranch keyed by branch name using PowerSystems.get_components and get_name:

ratings = Dict{String, Float64}()
for b in get_components(ACBranch, sys)
    ratings[get_name(b)] = _branch_flow_limit_mw(b, sys)
end

Then, scan every interval for branches whose AC flow exceeds the thermal rating:

overloads = DataFrame(;
    DateTime = Dates.DateTime[],
    name = String[],
    P_from_to = Float64[],
    rating = Float64[],
)

for row in eachrow(pf_flows_ft)
    rating = get(ratings, row.name, Inf)
    if abs(row.value) > rating
        push!(overloads, (row.DateTime, row.name, row.value, rating))
    end
end

overloads
DateTime name P_from_to rating
Dates.DateTime String Float64 Float64
2020-01-01T00:00:00 C6 189.44660790370895 175.0
2020-01-01T01:00:00 C6 250.54929177510448 175.0
2020-01-01T02:00:00 C6 189.5142207840236 175.0
2020-01-01T05:00:00 C6 187.34961077010942 175.0
2020-01-01T16:00:00 AB1 177.63718691526805 175.0
2020-01-01T17:00:00 AB1 195.89429309632416 175.0
2020-01-01T18:00:00 AB1 199.72123960142883 175.0

Notice we have multiple overloads in this example. Each row identifies a congestion event: the interval (DateTime), branch name (name), actual AC flow in MW (P_from_to), and thermal rating (rating). In-loop AC power flow records realized flows but does not add thermal-limit constraints to the UC optimization. The UC model's network representation could be too loose if overloads appear — add transmission constraints, re-run UC, and repeat the AC power flow check until the table is empty.

PSS/e Export Files

The PowerFlows.PSSEExportPowerFlow exporter writes under export_dir (here, results/psse_exports). After running, we have one folder per solved interval in the 24-hour in-step horizon, plus the 24-hour lookahead:

psse_export_folders = readdir(export_dir)
length(psse_export_folders)
48

Each folder contains the PSS/e .raw file for that interval, available for further analysis:

first_subdir = first(sort(psse_export_folders))
readdir(joinpath(export_dir, first_subdir))
2-element Vector{String}:
 "export_1_1.raw"
 "export_1_1_export_metadata.json"

Next Steps