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, nothing, true, false, false, false, PowerNetworkMatrices.NetworkReduction[], 1, String[], false, Dict{Symbol, Any}())

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
PowerFlowBranchActivePowerFromTo__TapTransformer
TimeDurationOn__ThermalStandard
PowerFlowBranchReactivePowerToFrom__Line
PowerFlowVoltageMagnitude__ACBus
PowerFlowBranchActivePowerLoss__TwoTerminalGenericHVDCLine
PowerFlowBranchActivePowerLoss__Line
PowerFlowBranchReactivePowerFromTo__Line
PowerFlowBranchActivePowerToFrom__TwoTerminalGenericHVDCLine
PowerFlowBranchActivePowerToFrom__Line
PowerFlowBranchActivePowerToFrom__TapTransformer
PowerFlowBranchReactivePowerFromTo__TwoTerminalGenericHVDCLine
PowerFlowBranchReactivePowerFromTo__TapTransformer
PowerFlowVoltageAngle__ACBus
PowerFlowBranchReactivePowerToFrom__TapTransformer
PowerFlowBranchActivePowerFromTo__Line
PowerFlowBranchReactivePowerToFrom__TwoTerminalGenericHVDCLine
TimeDurationOff__ThermalStandard
PowerFlowBranchActivePowerFromTo__TwoTerminalGenericHVDCLine
HydroEnergyOutput__HydroDispatch
PowerFlowBranchActivePowerLoss__TapTransformer
UC Problem Expressions Results
ShutDownCostExpression__ThermalStandard
ActivePowerBalance__ACBus
VOMCostExpression__RenewableDispatch
FixedCostExpression__ThermalStandard
ProductionCostExpression__RenewableDispatch
StartUpCostExpression__ThermalStandard
PTDFBranchFlow__Line
ProductionCostExpression__ThermalStandard
FixedCostExpression__RenewableDispatch
ProductionCostExpression__HydroDispatch
FuelConsumptionExpression__ThermalStandard
ActivePowerBalance__System
VOMCostExpression__ThermalStandard
FuelCostExpression__ThermalStandard
CurtailmentCostExpression__RenewableDispatch
UC Problem Parameters Results
ReactivePowerTimeSeriesParameter__HydroDispatch
ReactivePowerTimeSeriesParameter__PowerLoad
ActivePowerTimeSeriesParameter__HydroDispatch
ReactivePowerTimeSeriesParameter__RenewableNonDispatch
ActivePowerTimeSeriesParameter__RenewableDispatch
ActivePowerTimeSeriesParameter__RenewableNonDispatch
ReactivePowerTimeSeriesParameter__RenewableDispatch
ActivePowerTimeSeriesParameter__PowerLoad
UC Problem Variables Results
ActivePowerVariable__HydroDispatch
StartVariable__ThermalStandard
ActivePowerVariable__ThermalStandard
SystemBalanceSlackUp__System
OnVariable__ThermalStandard
ActivePowerVariable__RenewableDispatch
SystemBalanceSlackDown__System
StopVariable__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.411365763521367
2020-01-01T01:00:00 A1 18.974231910761745
2020-01-01T02:00:00 A1 20.094662756073827
2020-01-01T03:00:00 A1 20.67962260485144
2020-01-01T04:00:00 A1 16.511279486614868
2020-01-01T05:00:00 A1 18.030431449676943
2020-01-01T06:00:00 A1 15.291795036026606
2020-01-01T07:00:00 A1 29.770571270724094
2020-01-01T08:00:00 A1 41.53998185350879
2020-01-01T09:00:00 A1 33.60584365155333

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.411365763521367 17.905261228634547
2020-01-01T01:00:00 18.974231910761745 16.680300222292487
2020-01-01T02:00:00 20.094662756073827 17.726350963393163
2020-01-01T03:00:00 20.67962260485144 18.228077098026112
2020-01-01T04:00:00 16.511279486614868 14.451997369396658
2020-01-01T05:00:00 18.030431449676943 15.839176212981258
2020-01-01T06:00:00 15.291795036026606 13.453506655854605
2020-01-01T07:00:00 29.770571270724094 29.143666851474308
2020-01-01T08:00:00 41.53998185350879 41.148136578740576
2020-01-01T09:00:00 33.60584365155333 33.27782134838367

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.5145348857181 175.0
2020-01-01T01:00:00 C6 250.4639107394733 175.0
2020-01-01T02:00:00 C6 189.66427935296832 175.0
2020-01-01T05:00:00 C6 175.51180847545996 175.0
2020-01-01T18:00:00 AB1 181.30592402464646 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