From 027f186f919e9c088e2efb809f56225c16360c0a Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 13 Aug 2026 14:14:06 -0400 Subject: [PATCH 01/19] refactor voltage-only ac network flow code --- src/ac_transmission_models/AC_branches.jl | 396 +++------ .../voltage_control_tap_models.jl | 788 ------------------ test/test_native_transformer_tap.jl | 79 +- 3 files changed, 139 insertions(+), 1124 deletions(-) delete mode 100644 src/ac_transmission_models/voltage_control_tap_models.jl diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 23cdf3f..0ca0eb5 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -1411,30 +1411,6 @@ function _add_flow_constraint_containers!( return cons_pft, cons_qft, cons_ptf, cons_qtf end -# Pure, tap-free π-model coefficients shared by the polar (ACP) and rectangular (ACR) -# Ohm's law, for both the fixed-tap StaticBranch path and the variable-tap VoltageControlTap -# path. `cs`/`sn` are the phase-shift trig; `a_cos`/`a_sin`/`c_cos`/`d_sin` are the tm-free -# coupling coefficients (each divided by the live tap at the constraint site — `tm` for -# fixed tap, `TapRatioVariable[name, t]` for variable tap). ACR uses `e_sin = -d_sin`. -function _tap_flow_coefficients(g, b, g_fr, b_fr, g_to, b_to, shift) - cs = cos(shift) - sn = sin(shift) - return ( - cs = cs, - sn = sn, - g = g, - b = b, - g_fr = g_fr, - b_fr = b_fr, - gg_to = g + g_to, - bb_to = b + b_to, - a_cos = -g * cs + b * sn, - a_sin = -b * cs - g * sn, - c_cos = -g * cs - b * sn, - d_sin = b * cs - g * sn, - ) -end - # Slack holders for the equality/limit rows. `_SlackPair` carries a metaed upper/lower pair # (equality relaxation, term `up - lo`); `_UpperSlack` carries a one-sided upper slack # (quadratic-limit relaxation, term `up`). The no-slack twins contribute a constant 0.0 so @@ -1576,107 +1552,105 @@ function _current_magnitude_slacks( return _UpperSlack(get_variable(container, FlowActivePowerSlackUpperBound, T, meta)) end -# Polar (ACP) π-model Ohm's law for one branch, one time step. `coef` from -# `_tap_flow_coefficients`; `tap` is the constant `tm` (fixed tap) or the -# `TapRatioVariable` (variable tap). The constraints reduce term-for-term to the -# fixed-tap StaticBranch form when `tap == tm`. -function _add_tap_acp_flow!( - jump_model, cons_pft, cons_qft, cons_ptf, cons_qtf, pft, qft, ptf, qtf, - name, t, vmf, vmt, θ, coef, tap, p_ft_slack, q_ft_slack, p_tf_slack, q_tf_slack, +function _voltage_products( + container::OptimizationContainer, + ::NetworkModel{ACPNetworkModel}, + ::Type{<:PSY.ACTransmission}, + ::String, + from_bus::String, + to_bus::String, + t::Int, ) - cons_pft[name, t] = JuMP.@constraint( - jump_model, - pft[name, t] == - (coef.g / tap^2 + coef.g_fr) * vmf^2 + - coef.a_cos / tap * vmf * vmt * cos(θ) + - coef.a_sin / tap * vmf * vmt * sin(θ) + p_ft_slack, - ) - cons_qft[name, t] = JuMP.@constraint( - jump_model, - qft[name, t] == - -(coef.b / tap^2 + coef.b_fr) * vmf^2 + - (-coef.a_sin) / tap * vmf * vmt * cos(θ) + - coef.a_cos / tap * vmf * vmt * sin(θ) + q_ft_slack, - ) - cons_ptf[name, t] = JuMP.@constraint( - jump_model, - ptf[name, t] == - coef.gg_to * vmt^2 + - coef.c_cos / tap * vmt * vmf * cos(θ) + - coef.d_sin / tap * vmt * vmf * sin(θ) + p_tf_slack, - ) - cons_qtf[name, t] = JuMP.@constraint( - jump_model, - qtf[name, t] == - -coef.bb_to * vmt^2 + - coef.d_sin / tap * vmt * vmf * cos(θ) + - (-coef.c_cos) / tap * vmt * vmf * sin(θ) + q_tf_slack, + vm = get_variable(container, VoltageMagnitude, PSY.ACBus) + va = get_variable(container, VoltageAngle, PSY.ACBus) + vmf = vm[from_bus, t] + vmt = vm[to_bus, t] + θ = va[from_bus, t] - va[to_bus, t] + return ( + v2_fr = vmf^2, + v2_to = vmt^2, + vv_cos = vmf * vmt * cos(θ), + vv_sin = vmf * vmt * sin(θ), ) - return end -# Rectangular (ACR) π-model Ohm's law for one branch, one time step. Same coefficients as -# ACP; the rectangular substitution replaces vmf²/vmf·vmt·cos/vmf·vmt·sin with the -# pre-built bilinears `vsq_fr`/`vv_cos`/`vv_sin`. `e_sin = -d_sin` (rectangular sin sign). -function _add_tap_acr_flow!( - jump_model, cons_pft, cons_qft, cons_ptf, cons_qtf, pft, qft, ptf, qtf, - name, t, vsq_fr, vsq_to, vv_cos, vv_sin, coef, tap, - p_ft_slack, q_ft_slack, p_tf_slack, q_tf_slack, +function _voltage_products( + container::OptimizationContainer, + ::NetworkModel{ACRNetworkModel}, + ::Type{<:PSY.ACTransmission}, + ::String, + from_bus::String, + to_bus::String, + t::Int, ) - e_sin = -coef.d_sin - cons_pft[name, t] = JuMP.@constraint( - jump_model, - pft[name, t] == - (coef.g / tap^2 + coef.g_fr) * vsq_fr + - coef.a_cos / tap * vv_cos + - coef.a_sin / tap * vv_sin + p_ft_slack, - ) - cons_qft[name, t] = JuMP.@constraint( - jump_model, - qft[name, t] == - -(coef.b / tap^2 + coef.b_fr) * vsq_fr + - (-coef.a_sin) / tap * vv_cos + - coef.a_cos / tap * vv_sin + q_ft_slack, - ) - cons_ptf[name, t] = JuMP.@constraint( - jump_model, - ptf[name, t] == - coef.gg_to * vsq_to + - coef.c_cos / tap * vv_cos - - e_sin / tap * vv_sin + p_tf_slack, - ) - cons_qtf[name, t] = JuMP.@constraint( - jump_model, - qtf[name, t] == - -coef.bb_to * vsq_to - - e_sin / tap * vv_cos - - coef.c_cos / tap * vv_sin + q_tf_slack, + vr = get_variable(container, VoltageReal, PSY.ACBus) + vi = get_variable(container, VoltageImaginary, PSY.ACBus) + vr_fr = vr[from_bus, t] + vr_to = vr[to_bus, t] + vi_fr = vi[from_bus, t] + vi_to = vi[to_bus, t] + return ( + v2_fr = vr_fr^2 + vi_fr^2, + v2_to = vr_to^2 + vi_to^2, + vv_cos = vr_fr * vr_to + vi_fr * vi_to, + vv_sin = vi_fr * vr_to - vr_fr * vi_to, ) - return end -""" -Add full π-model rectangular AC Ohm's law constraints for ACBranch under ACRNetworkModel. - -Four constraints per branch per time step (p_ft, q_ft, p_tf, q_tf) relate the four -directional flow variables to rectangular voltage components (vr, vi) via the -π-equivalent circuit. Rectangular identity applied to the ACP polar expressions: - vmf^2 → vr_fr^2 + vi_fr^2 - vmf*vmt*cos(θ) → vr_fr*vr_to + vi_fr*vi_to - vmf*vmt*sin(θ) → vi_fr*vr_to - vr_fr*vi_to -""" +function _voltage_products( + container::OptimizationContainer, + ::NetworkModel{LPACCNetworkModel}, + ::Type{T}, + name::String, + from_bus::String, + to_bus::String, + t::Int, +) where {T <: PSY.ACTransmission} + va = get_variable(container, VoltageAngle, PSY.ACBus) + phi = get_variable(container, VoltageDeviation, PSY.ACBus) + cs = get_variable(container, CosineApproximation, T) + phi_fr = phi[from_bus, t] + phi_to = phi[to_bus, t] + return ( + v2_fr = 1.0 + 2.0 * phi_fr, + v2_to = 1.0 + 2.0 * phi_to, + vv_cos = cs[name, t] + phi_fr + phi_to, + vv_sin = va[from_bus, t] - va[to_bus, t], + ) +end + +# Ybus terms, supporting Float64 and VariableRef taps. PNM's ybus functions +# use imaginary numbers which VariableRef doesn't support. +function _tapped_admittance(adm, tap) + cs = cos(adm.shift) + sn = sin(adm.shift) + return ( + g11 = adm.g / tap^2 + adm.g_fr, + b11 = adm.b / tap^2 + adm.b_fr, + g12 = (-adm.g * cs + adm.b * sn) / tap, + b12 = (-adm.b * cs - adm.g * sn) / tap, + g21 = (-adm.g * cs - adm.b * sn) / tap, + b21 = (adm.g * sn - adm.b * cs) / tap, + g22 = adm.g + adm.g_to, + b22 = adm.b + adm.b_to, + ) +end + +# Voltage-only AC networks. function add_constraints!( container::OptimizationContainer, sys::PSY.System, ::Type{NetworkFlowConstraint}, devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T, U}, - network_model::NetworkModel{ACRNetworkModel}, -) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} + network_model::NetworkModel{N}, +) where { + T <: PSY.ACTransmission, + U <: AbstractBranchFormulation, + N <: Union{ACPNetworkModel, ACRNetworkModel, LPACCNetworkModel}, +} time_steps = get_time_steps(container) - vr = get_variable(container, VoltageReal, PSY.ACBus) - vi = get_variable(container, VoltageImaginary, PSY.ACBus) pft = get_variable(container, FlowActivePowerFromToVariable, T) ptf = get_variable(container, FlowActivePowerToFromVariable, T) qft = get_variable(container, FlowReactivePowerFromToVariable, T) @@ -1689,34 +1663,42 @@ function add_constraints!( cons_pft, cons_qft, cons_ptf, cons_qtf = _add_flow_constraint_containers!(container, T, branch_names) jump_model = get_jump_model(container) + slacks = _flow_equality_slacks(container, device_model, T) + p_ft_slack = _slack_term(slacks.p_ft, name, t) + q_ft_slack = _slack_term(slacks.q_ft, name, t) + p_tf_slack = _slack_term(slacks.p_tf, name, t) + q_tf_slack = _slack_term(slacks.q_tf, name, t) for g_geom in geoms name = g_geom.name adm = g_geom.adm - tm = adm.tap from_bus = g_geom.from_name to_bus = g_geom.to_name - coef = _tap_flow_coefficients( - adm.g, adm.b, adm.g_fr, adm.b_fr, adm.g_to, adm.b_to, adm.shift, - ) for t in time_steps - vr_fr = vr[from_bus, t] - vr_to = vr[to_bus, t] - vi_fr = vi[from_bus, t] - vi_to = vi[to_bus, t] - vsq_fr = vr_fr^2 + vi_fr^2 - vsq_to = vr_to^2 + vi_to^2 - vv_cos = vr_fr * vr_to + vi_fr * vi_to - vv_sin = vi_fr * vr_to - vr_fr * vi_to - _add_tap_acr_flow!( - jump_model, cons_pft, cons_qft, cons_ptf, cons_qtf, pft, qft, ptf, qtf, - name, t, vsq_fr, vsq_to, vv_cos, vv_sin, coef, tm, - _slack_term(slacks.p_ft, name, t), - _slack_term(slacks.q_ft, name, t), - _slack_term(slacks.p_tf, name, t), - _slack_term(slacks.q_tf, name, t), + vp = _voltage_products(container, network_model, T, name, from_bus, to_bus, t) + y = _tapped_admittance(adm, adm.tap) + + cons_pft[name, t] = JuMP.@constraint( + jump_model, + pft[name, t] == + y.g11 * vp.v2_fr + y.g12 * vp.vv_cos + y.b12 * vp.vv_sin + p_ft_slack, + ) + cons_qft[name, t] = JuMP.@constraint( + jump_model, + qft[name, t] == + -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + q_ft_slack, + ) + cons_ptf[name, t] = JuMP.@constraint( + jump_model, + ptf[name, t] == + y.g22 * vp.v2_to + y.g21 * vp.vv_cos - y.b21 * vp.vv_sin + p_tf_slack, + ) + cons_qtf[name, t] = JuMP.@constraint( + jump_model, + qtf[name, t] == + -y.b22 * vp.v2_to - y.b21 * vp.vv_cos - y.g21 * vp.vv_sin + q_tf_slack, ) end end @@ -1869,105 +1851,6 @@ function _entry_angle_limits(geometry, device_by_name::Dict{String, <:PSY.ACTran return (min = -π / 2, max = π / 2) end -""" -Add the LPAC-linearized π-model AC Ohm's law constraints for ACBranch under -LPACCNetworkModel. - -Four constraints per branch per time step (p_ft, q_ft, p_tf, q_tf) relate the directional -flow variables to the voltage-magnitude deviations (phi), the bus-pair cosine variable (cs), -and the voltage-angle difference (va_fr - va_to). Transcribed from PowerModels `lpac.jl` -`constraint_ohms_yt_from/to` for `AbstractLPACCNetworkModel`, with `tr = tm·cos(shift)`, -`ti = tm·sin(shift)`. -""" -function add_constraints!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{NetworkFlowConstraint}, - devices::IS.FlattenIteratorWrapper{T}, - device_model::DeviceModel{T, U}, - network_model::NetworkModel{LPACCNetworkModel}, -) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} - time_steps = get_time_steps(container) - - va = get_variable(container, VoltageAngle, PSY.ACBus) - phi = get_variable(container, VoltageDeviation, PSY.ACBus) - cs = get_variable(container, CosineApproximation, T) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - ptf = get_variable(container, FlowActivePowerToFromVariable, T) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] - cons_pft, cons_qft, cons_ptf, cons_qtf = - _add_flow_constraint_containers!(container, T, branch_names) - - jump_model = get_jump_model(container) - slacks = _flow_equality_slacks(container, device_model, T) - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm - g = adm.g - b = adm.b - g_fr = adm.g_fr - b_fr = adm.b_fr - g_to = adm.g_to - b_to = adm.b_to - tm = adm.tap - nominal_shift = adm.shift - from_bus = g_geom.from_name - to_bus = g_geom.to_name - tr = tm * cos(nominal_shift) - ti = tm * sin(nominal_shift) - # Coupling coefficients (identical to ACP / PowerModels lpac.jl). - c_cos_fr = (-g * tr + b * ti) / tm^2 - c_sin_fr = (-b * tr - g * ti) / tm^2 - c_cos_to = (-g * tr - b * ti) / tm^2 - c_sin_to = (-b * tr + g * ti) / tm^2 - - for t in time_steps - phi_fr = phi[from_bus, t] - phi_to = phi[to_bus, t] - vad = va[from_bus, t] - va[to_bus, t] - cs_b = cs[name, t] - - # Shared affine terms reused across the four flow constraints: - # cs_sum = cs + phi_fr + phi_to, dev_* = 1 + 2·phi_* - cs_sum = cs_b + phi_fr + phi_to - dev_fr = 1.0 + 2.0 * phi_fr - dev_to = 1.0 + 2.0 * phi_to - - cons_pft[name, t] = JuMP.@constraint( - jump_model, - pft[name, t] == - (g / tm^2 + g_fr) * dev_fr + c_cos_fr * cs_sum + c_sin_fr * vad + - _slack_term(slacks.p_ft, name, t), - ) - cons_qft[name, t] = JuMP.@constraint( - jump_model, - qft[name, t] == - -(b / tm^2 + b_fr) * dev_fr - c_sin_fr * cs_sum + c_cos_fr * vad + - _slack_term(slacks.q_ft, name, t), - ) - cons_ptf[name, t] = JuMP.@constraint( - jump_model, - ptf[name, t] == - (g + g_to) * dev_to + c_cos_to * cs_sum + c_sin_to * (-vad) + - _slack_term(slacks.p_tf, name, t), - ) - cons_qtf[name, t] = JuMP.@constraint( - jump_model, - qtf[name, t] == - -(b + b_to) * dev_to - c_sin_to * cs_sum + c_cos_to * (-vad) + - _slack_term(slacks.q_tf, name, t), - ) - end - end - return -end - ################################## IVRNetworkModel branch constraints ################## # Compute the per-unit current rating bound for an IVR branch variable. @@ -2769,65 +2652,6 @@ function add_constraints!( return end -""" -Add full π-model AC Ohm's law constraints for ACBranch under ACPNetworkModel. - -Four constraints per branch per time step (p_ft, q_ft, p_tf, q_tf) relate the four -directional flow variables to voltage magnitudes and angles via the π-equivalent circuit. -""" -function add_constraints!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{NetworkFlowConstraint}, - devices::IS.FlattenIteratorWrapper{T}, - device_model::DeviceModel{T, U}, - network_model::NetworkModel{ACPNetworkModel}, -) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} - time_steps = get_time_steps(container) - - va = get_variable(container, VoltageAngle, PSY.ACBus) - vm = get_variable(container, VoltageMagnitude, PSY.ACBus) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - ptf = get_variable(container, FlowActivePowerToFromVariable, T) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] - cons_pft, cons_qft, cons_ptf, cons_qtf = - _add_flow_constraint_containers!(container, T, branch_names) - slacks = _flow_equality_slacks(container, device_model, T) - - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm - tm = adm.tap - from_bus = g_geom.from_name - to_bus = g_geom.to_name - coef = _tap_flow_coefficients( - adm.g, adm.b, adm.g_fr, adm.b_fr, adm.g_to, adm.b_to, adm.shift, - ) - - for t in time_steps - θ = va[from_bus, t] - va[to_bus, t] - vmf = vm[from_bus, t] - vmt = vm[to_bus, t] - jump_model = get_jump_model(container) - _add_tap_acp_flow!( - jump_model, cons_pft, cons_qft, cons_ptf, cons_qtf, pft, qft, ptf, qtf, - name, t, vmf, vmt, θ, coef, tm, - _slack_term(slacks.p_ft, name, t), - _slack_term(slacks.q_ft, name, t), - _slack_term(slacks.p_tf, name, t), - _slack_term(slacks.q_tf, name, t), - ) - end - end - return -end - ################################## DCPLLNetworkModel branch constraints ################# # Tighten a flow variable to ±rate without loosening any bound it already carries (a diff --git a/src/ac_transmission_models/voltage_control_tap_models.jl b/src/ac_transmission_models/voltage_control_tap_models.jl deleted file mode 100644 index c694b8d..0000000 --- a/src/ac_transmission_models/voltage_control_tap_models.jl +++ /dev/null @@ -1,788 +0,0 @@ -################################################################################# -# Voltage-controlling tap transformer (Family B). -# -# `VoltageControlTap` models the off-nominal tap ratio of a `PSY.TwoWindingTransformer` -# as a bounded continuous decision variable `t ∈ [t_min, t_max]` -# (`TapRatioVariable`) that enters the AC π-model Ohm's law nonlinearly (the fixed -# tap `tm` of the StaticBranch law is replaced by the variable `t`, so the self -# terms scale as `1/t²` and the coupling terms as `1/t`). The control objective is -# applied count-invariantly with a single `JuMP.fix` on an already-created variable: -# VOLTAGE → fix the regulated-bus VoltageMagnitude to voltage_setpoint -# REACTIVE_POWER_FLOW → fix the from-to reactive flow to reactive_power_flow -# ACTIVE_POWER_FLOW → fix the from-to active flow to active_power_flow -# No per-mode constraint is ever added — the variable/constraint containers are the -# same in every mode (a `FixRef` lives at the variable level). -# -# Voltage-objective regulation: under ACP, the scalar VoltageMagnitude is pinned -# directly; under ACR/IVR, a per-device RegulatedVoltageMagnitude aux variable is -# tied to the rectangular components via RegulatedVoltageMagnitudeConstraint and then -# fixed. Reactive/active-flow objectives share a common path across ACP and ACR. -# The formulation is dropped from DC templates via `models_reactive_power`. -################################################################################# - -# Finite tap-ratio bounds (pu turns ratio) for the control variable `t`. A -# non-finite limit is a data error (Principle 0 / IPOPT). -function _tap_ratio_limits(d::PSY.TwoWindingTransformer) - lims = PSY.get_tap_limits(d) - lo = lims.min - hi = lims.max - if !(isfinite(lo) && isfinite(hi)) - error( - "TwoWindingTransformer $(PSY.get_name(d)) has non-finite tap_limits ", - "($(lo), $(hi)); cannot bound TapRatioVariable", - ) - end - if lo <= 0.0 - error( - "TwoWindingTransformer $(PSY.get_name(d)) has a non-positive tap lower limit ", - "($(lo)); the variable-tap Ohm's law divides by t and requires t > 0", - ) - end - if hi < lo - error( - "TwoWindingTransformer $(PSY.get_name(d)) has tap_limits.max < tap_limits.min ", - "($(hi) < $(lo))", - ) - end - return (min = lo, max = hi) -end - -################################################################################# -# TapRatioVariable traits -################################################################################# - -get_variable_binary( - ::Type{TapRatioVariable}, - ::Type{<:PSY.TwoWindingTransformer}, - ::Type{VoltageControlTap}, -) = false - -get_variable_multiplier( - ::Type{TapRatioVariable}, - ::Type{<:PSY.TwoWindingTransformer}, - ::Type{VoltageControlTap}, -) = 1.0 - -function get_variable_lower_bound( - ::Type{TapRatioVariable}, - d::PSY.TwoWindingTransformer, - ::Type{VoltageControlTap}, -) - return _tap_ratio_limits(d).min -end - -function get_variable_upper_bound( - ::Type{TapRatioVariable}, - d::PSY.TwoWindingTransformer, - ::Type{VoltageControlTap}, -) - return _tap_ratio_limits(d).max -end - -# Warm-start the tap at its current position so IPOPT begins inside the bounds. -function get_variable_warm_start_value( - ::Type{TapRatioVariable}, - d::PSY.TwoWindingTransformer, - ::Type{VoltageControlTap}, -) - return PSY.get_tap(d) -end - -requires_initialization(::VoltageControlTap) = false - -function get_default_attributes( - ::Type{<:PSY.TwoWindingTransformer}, - ::Type{VoltageControlTap}, -) - return Dict{String, Any}( - PARALLEL_BRANCH_MAX_RATING_KEY => "single_element_contingency", - ) -end - -function get_default_time_series_names( - ::Type{<:PSY.TwoWindingTransformer}, - ::Type{VoltageControlTap}, -) - return Dict{Type{<:TimeSeriesParameter}, String}() -end - -################################################################################# -# Variable-tap AC π-model Ohm's law constraints. -# -# The ACP/ACR variable-tap Ohm's law shares its π-model coefficients and constraint -# builders with the fixed-tap StaticBranch path in AC_branches.jl -# (`_tap_flow_coefficients`, `_add_tap_acp_flow!`, `_add_tap_acr_flow!`), passing -# `TapRatioVariable[name, t]` as the tap in place of the constant `tm`, so each constraint -# reduces to its StaticBranch counterpart when `t == tm`. IVR keeps its own form below. -################################################################################# - -# ACP (polar) variable-tap Ohm's law. -function add_constraints!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{NetworkFlowConstraint}, - devices::IS.FlattenIteratorWrapper{T}, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{ACPNetworkModel}, -) where {T <: PSY.TwoWindingTransformer} - time_steps = get_time_steps(container) - - va = get_variable(container, VoltageAngle, PSY.ACBus) - vm = get_variable(container, VoltageMagnitude, PSY.ACBus) - tap = get_variable(container, TapRatioVariable, T) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - ptf = get_variable(container, FlowActivePowerToFromVariable, T) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] - cons_pft, cons_qft, cons_ptf, cons_qtf = - _add_flow_constraint_containers!(container, T, branch_names) - jump_model = get_jump_model(container) - slacks = _flow_equality_slacks(container, device_model, T) - - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm - from_bus = g_geom.from_name - to_bus = g_geom.to_name - coef = _tap_flow_coefficients( - adm.g, adm.b, adm.g_fr, adm.b_fr, adm.g_to, adm.b_to, adm.shift, - ) - for t in time_steps - θ = va[from_bus, t] - va[to_bus, t] - vmf = vm[from_bus, t] - vmt = vm[to_bus, t] - _add_tap_acp_flow!( - jump_model, cons_pft, cons_qft, cons_ptf, cons_qtf, pft, qft, ptf, qtf, - name, t, vmf, vmt, θ, coef, tap[name, t], - _slack_term(slacks.p_ft, name, t), - _slack_term(slacks.q_ft, name, t), - _slack_term(slacks.p_tf, name, t), - _slack_term(slacks.q_tf, name, t), - ) - end - end - return -end - -# ACR (rectangular) variable-tap Ohm's law. -function add_constraints!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{NetworkFlowConstraint}, - devices::IS.FlattenIteratorWrapper{T}, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{ACRNetworkModel}, -) where {T <: PSY.TwoWindingTransformer} - time_steps = get_time_steps(container) - - vr = get_variable(container, VoltageReal, PSY.ACBus) - vi = get_variable(container, VoltageImaginary, PSY.ACBus) - tap = get_variable(container, TapRatioVariable, T) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - ptf = get_variable(container, FlowActivePowerToFromVariable, T) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] - cons_pft, cons_qft, cons_ptf, cons_qtf = - _add_flow_constraint_containers!(container, T, branch_names) - jump_model = get_jump_model(container) - slacks = _flow_equality_slacks(container, device_model, T) - - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm - from_bus = g_geom.from_name - to_bus = g_geom.to_name - coef = _tap_flow_coefficients( - adm.g, adm.b, adm.g_fr, adm.b_fr, adm.g_to, adm.b_to, adm.shift, - ) - for t in time_steps - vr_fr = vr[from_bus, t] - vr_to = vr[to_bus, t] - vi_fr = vi[from_bus, t] - vi_to = vi[to_bus, t] - vv_fr = vr_fr^2 + vi_fr^2 - vv_to = vr_to^2 + vi_to^2 - cosprod = vr_fr * vr_to + vi_fr * vi_to - sinprod = vi_fr * vr_to - vr_fr * vi_to - _add_tap_acr_flow!( - jump_model, cons_pft, cons_qft, cons_ptf, cons_qtf, pft, qft, ptf, qtf, - name, t, vv_fr, vv_to, cosprod, sinprod, coef, tap[name, t], - _slack_term(slacks.p_ft, name, t), - _slack_term(slacks.q_ft, name, t), - _slack_term(slacks.p_tf, name, t), - _slack_term(slacks.q_tf, name, t), - ) - end - end - return -end - -# IVR (current-injection, rectangular) variable-tap Ohm's law. -# -# Mirrors the fixed-tap IVR branch constraints in AC_branches.jl term-by-term, with -# the constant tap `tm` (and the derived `tr = tm·cos(shift)`, `ti = tm·sin(shift)`, -# `tm² = tm^2`) replaced by the variable tap `t = TapRatioVariable[name, ts]`: -# tr → t·cs, ti → t·sn, tm² → t² (cs = cos(shift), sn = sin(shift)). -# The series impedance Z = r + jx is tap-independent (unchanged). Ten constraints -# per branch per time step (the same ten as the fixed-tap IVR branch). Because -# every `tm`-bearing term carries the live `t` symbol, each constraint reduces -# EXACTLY to its fixed-tap counterpart when `t == tap_nominal` (= PSY.get_tap(d) = -# adm.tap). The multiplied-through form (LHS·t²) keeps the equations polynomial -# (no division) and makes the reduction term-identical; t > 0 (TapRatioVariable -# bounds) guarantees equivalence with the divided form. -function add_constraints!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{NetworkFlowConstraint}, - devices::IS.FlattenIteratorWrapper{T}, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{IVRNetworkModel}, -) where {T <: PSY.TwoWindingTransformer} - time_steps = get_time_steps(container) - - vr = get_variable(container, VoltageReal, PSY.ACBus) - vi = get_variable(container, VoltageImaginary, PSY.ACBus) - tap = get_variable(container, TapRatioVariable, T) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - ptf = get_variable(container, FlowActivePowerToFromVariable, T) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - cr_fr = get_variable(container, BranchCurrentFromToReal, T) - ci_fr = get_variable(container, BranchCurrentFromToImaginary, T) - cr_to = get_variable(container, BranchCurrentToFromReal, T) - ci_to = get_variable(container, BranchCurrentToFromImaginary, T) - csr = get_variable(container, BranchSeriesCurrentReal, T) - csi = get_variable(container, BranchSeriesCurrentImaginary, T) - - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] - - cons_pft = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "p_ft", - ) - cons_qft = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "q_ft", - ) - cons_ptf = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "p_tf", - ) - cons_qtf = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "q_tf", - ) - cons_cr_fr = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "cr_fr", - ) - cons_ci_fr = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "ci_fr", - ) - cons_cr_to = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "cr_to", - ) - cons_ci_to = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "ci_to", - ) - cons_vr_to = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "vr_to", - ) - cons_vi_to = add_constraints_container!( - container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "vi_to", - ) - - jump_model = get_jump_model(container) - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm - g = adm.g - b = adm.b - g_fr = adm.g_fr - b_fr = adm.b_fr - g_to = adm.g_to - b_to = adm.b_to - from_bus = g_geom.from_name - to_bus = g_geom.to_name - cs = cos(adm.shift) - sn = sin(adm.shift) - - # Series impedance Z = r + jx = conj(y)/|y|² (tap-independent). - ymag2 = g^2 + b^2 - r = g / ymag2 - x = -b / ymag2 - - for t in time_steps - vr_f = vr[from_bus, t] - vi_f = vi[from_bus, t] - vr_t = vr[to_bus, t] - vi_t = vi[to_bus, t] - tt = tap[name, t] - tt2 = tt^2 - tr = tt * cs - ti = tt * sn - csr_b = csr[name, t] - csi_b = csi[name, t] - cr_f = cr_fr[name, t] - ci_f = ci_fr[name, t] - cr_t = cr_to[name, t] - ci_t = ci_to[name, t] - - # Bilinear power-current linking (tap-independent) - cons_pft[name, t] = JuMP.@constraint( - jump_model, pft[name, t] == vr_f * cr_f + vi_f * ci_f, - ) - cons_qft[name, t] = JuMP.@constraint( - jump_model, qft[name, t] == vi_f * cr_f - vr_f * ci_f, - ) - cons_ptf[name, t] = JuMP.@constraint( - jump_model, ptf[name, t] == vr_t * cr_t + vi_t * ci_t, - ) - cons_qtf[name, t] = JuMP.@constraint( - jump_model, qtf[name, t] == vi_t * cr_t - vr_t * ci_t, - ) - - # KCL at from terminal (tm → t) - cons_cr_fr[name, t] = JuMP.@constraint( - jump_model, - cr_f * tt2 == - tr * csr_b - ti * csi_b + (g_fr * vr_f - b_fr * vi_f) * tt2, - ) - cons_ci_fr[name, t] = JuMP.@constraint( - jump_model, - ci_f * tt2 == - tr * csi_b + ti * csr_b + (g_fr * vi_f + b_fr * vr_f) * tt2, - ) - - # KCL at to terminal (no tap) - cons_cr_to[name, t] = JuMP.@constraint( - jump_model, cr_t == -csr_b + g_to * vr_t - b_to * vi_t, - ) - cons_ci_to[name, t] = JuMP.@constraint( - jump_model, ci_t == -csi_b + g_to * vi_t + b_to * vr_t, - ) - - # Ohm's law across series impedance (tm → t) - cons_vr_to[name, t] = JuMP.@constraint( - jump_model, - vr_t * tt2 == - vr_f * tr + vi_f * ti - r * csr_b * tt2 + x * csi_b * tt2, - ) - cons_vi_to[name, t] = JuMP.@constraint( - jump_model, - vi_t * tt2 == - vi_f * tr - vr_f * ti - r * csi_b * tt2 - x * csr_b * tt2, - ) - end - end - return -end - -################################################################################# -# Control-objective application — count-invariant JuMP.fix on existing variables. -# Branch on the enum value (data, not type). -################################################################################# - -# Shared handler for REACTIVE_POWER_FLOW and ACTIVE_POWER_FLOW objectives — identical -# between ACP and ACR. VOLTAGE regulation is handled per-network (ACP: direct vm fix; -# ACR/IVR: fix via RegulatedVoltageMagnitude aux variable). -function _fix_tap_flow_objective!( - d::PSY.TwoWindingTransformer, - name::String, - qft, - pft, - objective, - time_steps, -) - if objective == PSY.TransformerControlObjective.REACTIVE_POWER_FLOW - target = PSY.get_reactive_power_flow(d, PSY.SU) - for t in time_steps - JuMP.fix(qft[name, t], target; force = true) - end - elseif objective == PSY.TransformerControlObjective.ACTIVE_POWER_FLOW - target = PSY.get_active_power_flow(d, PSY.SU) - for t in time_steps - JuMP.fix(pft[name, t], target; force = true) - end - end - return -end - -# Resolve the regulated-bus name for a transformer: `regulated_bus_number == 0` -# means the arc's to-bus (local control). -function _tap_regulated_bus_name(d::PSY.TwoWindingTransformer, geom, number_to_name) - reg = PSY.get_regulated_bus_number(d) - if iszero(reg) - return geom.to_name - end - if !haskey(number_to_name, reg) - error( - "TwoWindingTransformer $(PSY.get_name(d)) regulates bus number $(reg), which is \ - not a retained bus — it does not exist or was absorbed by a network \ - reduction. Fix the regulated_bus_number or exclude the bus from the \ - reduction with a PNM reduction filter.", - ) - end - return number_to_name[reg] -end - -# Resolve the regulated ACBus for a transformer (used to bound and tie the ACR/IVR -# RegulatedVoltageMagnitude aux variable). `regulated_bus_number == 0` means the -# arc's to-bus (local control); otherwise the ACBus carrying that number. -function _tap_regulated_bus(d::PSY.TwoWindingTransformer, bus_by_number) - reg = PSY.get_regulated_bus_number(d) - if iszero(reg) - return PSY.get_to(PSY.get_arc(d)) - end - if !haskey(bus_by_number, reg) - error( - "TwoWindingTransformer $(PSY.get_name(d)) regulates bus number $(reg), which does \ - not exist in the system. Fix the regulated_bus_number.", - ) - end - return bus_by_number[reg] -end - -_regulated_buses(d::PSY.TwoWindingTransformer, bus_by_number) = - [("1", _tap_regulated_bus(d, bus_by_number))] - -# Dispatch entry: the VOLTAGE objective is pinned differently depending on how the -# network expresses a regulated bus voltage magnitude (polar scalar vs rectangular aux). -function _apply_tap_control_objective!( - container::OptimizationContainer, - sys::PSY.System, - devices::IS.FlattenIteratorWrapper{T}, - network_model::NetworkModel{N}, -) where {T <: PSY.TwoWindingTransformer, N} - return _apply_tap_control_objective!( - regulated_voltage_form(N), - container, - sys, - devices, - network_model, - ) -end - -# Polar (ACP): VOLTAGE pins the regulated-bus VoltageMagnitude directly; -# REACTIVE/ACTIVE_POWER_FLOW pin the from-to terminal flow. Other objectives -# (UNDEFINED / disabled) free-float. -function _apply_tap_control_objective!( - ::PolarRegulatedVoltage, - container::OptimizationContainer, - sys::PSY.System, - devices::IS.FlattenIteratorWrapper{T}, - network_model::NetworkModel, -) where {T <: PSY.TwoWindingTransformer} - time_steps = get_time_steps(container) - vm = get_variable(container, VoltageMagnitude, PSY.ACBus) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - number_to_name = _retained_number_to_name(sys, network_model) - # Control objectives act on the device's own terminals; the reduction guard in the - # ArgumentConstructStage ensures every device here is a direct (un-aggregated) entry. - for d in devices - geom = _branch_geometry(d) - name = geom.name - objective = PSY.get_control_objective(d) - if objective == PSY.TransformerControlObjective.VOLTAGE - reg_name = _tap_regulated_bus_name(d, geom, number_to_name) - setpoint = PSY.get_voltage_setpoint(d) - for t in time_steps - JuMP.fix(vm[reg_name, t], setpoint; force = true) - end - else - _fix_tap_flow_objective!(d, name, qft, pft, objective, time_steps) - end - end - return -end - -# Rectangular (ACR/IVR): VOLTAGE pins the regulated-bus magnitude via the component-owned -# (component, "1") RegulatedVoltageMagnitude aux variable (see fix_regulated_voltage!); -# reactive/active-flow objectives pin the from-to terminal flow. The aux variable/ -# constraint are added unconditionally in the construction stages, so only the fix is -# objective-conditional (count-invariance). Under IVR the from-to power variables -# (pft/qft) are bilinear-linked to the branch currents in the IVR Ohm's law, so the -# flow objectives are well-defined in current space — identical control logic to ACR. -function _apply_tap_control_objective!( - ::RectangularRegulatedVoltage, - container::OptimizationContainer, - sys::PSY.System, - devices::IS.FlattenIteratorWrapper{T}, - network_model::NetworkModel, -) where {T <: PSY.TwoWindingTransformer} - time_steps = get_time_steps(container) - qft = get_variable(container, FlowReactivePowerFromToVariable, T) - pft = get_variable(container, FlowActivePowerFromToVariable, T) - bus_by_number = _bus_by_number(sys) - for d in devices - name = PSY.get_name(d) - objective = PSY.get_control_objective(d) - if objective == PSY.TransformerControlObjective.VOLTAGE - reg_bus = _tap_regulated_bus(d, bus_by_number) - fix_regulated_voltage!( - container, d, "1", reg_bus, PSY.get_voltage_setpoint(d), network_model, - ) - else - _fix_tap_flow_objective!(d, name, qft, pft, objective, time_steps) - end - end - return -end - -################################################################################# -# construct_device! — two-stage. ACP/ACR build the branch in power only; -# IVR adds explicit branch current variables and a CurrentLimitConstraint. The -# `tap_branch_current_form` trait selects between the two construction paths. -################################################################################# - -function construct_device!( - container::OptimizationContainer, - sys::PSY.System, - stage::ArgumentConstructStage, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{N}, -) where { - T <: PSY.TwoWindingTransformer, - N <: Union{ACPNetworkModel, ACRNetworkModel, IVRNetworkModel}, -} - return construct_device!( - tap_branch_current_form(N), - container, - sys, - stage, - device_model, - network_model, - ) -end - -function construct_device!( - container::OptimizationContainer, - sys::PSY.System, - stage::ModelConstructStage, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{N}, -) where { - T <: PSY.TwoWindingTransformer, - N <: Union{ACPNetworkModel, ACRNetworkModel, IVRNetworkModel}, -} - return construct_device!( - tap_branch_current_form(N), - container, - sys, - stage, - device_model, - network_model, - ) -end - -# Power-only branch construction (ACP/ACR), mirrors StaticBranch. -function construct_device!( - ::PowerOnlyTapBranch, - container::OptimizationContainer, - sys::PSY.System, - ::ArgumentConstructStage, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel, -) where {T <: PSY.TwoWindingTransformer} - @debug "construct_device VoltageControlTap (ArgumentConstructStage)" _group = - LOG_GROUP_BRANCH_CONSTRUCTIONS - devices = get_available_components(device_model, sys) - _validate_controlled_branch_not_reduced(network_model, devices, "VoltageControlTap") - add_variables!(container, TapRatioVariable, devices, VoltageControlTap) - add_variables!(container, FlowActivePowerFromToVariable, devices, VoltageControlTap) - add_variables!(container, FlowActivePowerToFromVariable, devices, VoltageControlTap) - add_variables!(container, FlowReactivePowerFromToVariable, devices, VoltageControlTap) - add_variables!(container, FlowReactivePowerToFromVariable, devices, VoltageControlTap) - add_regulated_voltage_magnitude!( - container, devices, sys, network_model, - ) - add_to_expression!( - container, ActivePowerBalance, FlowActivePowerFromToVariable, - devices, device_model, network_model, - ) - add_to_expression!( - container, ActivePowerBalance, FlowActivePowerToFromVariable, - devices, device_model, network_model, - ) - add_to_expression!( - container, ReactivePowerBalance, FlowReactivePowerFromToVariable, - devices, device_model, network_model, - ) - add_to_expression!( - container, ReactivePowerBalance, FlowReactivePowerToFromVariable, - devices, device_model, network_model, - ) - add_feedforward_arguments!(container, device_model, devices) - return -end - -function construct_device!( - ::PowerOnlyTapBranch, - container::OptimizationContainer, - sys::PSY.System, - ::ModelConstructStage, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{N}, -) where {T <: PSY.TwoWindingTransformer, N} - @debug "construct_device VoltageControlTap (ModelConstructStage)" _group = - LOG_GROUP_BRANCH_CONSTRUCTIONS - devices = get_available_components(device_model, sys) - add_constraints!( - container, FlowRateConstraintFromTo, devices, device_model, network_model, - ) - add_constraints!( - container, FlowRateConstraintToFrom, devices, device_model, network_model, - ) - add_constraints!( - container, sys, NetworkFlowConstraint, devices, device_model, network_model, - ) - add_constraints!( - container, sys, AngleDifferenceConstraint, devices, device_model, network_model, - ) - add_regulated_voltage_magnitude_constraints!( - container, devices, sys, network_model, - ) - _apply_tap_control_objective!(container, sys, devices, network_model) - add_feedforward_constraints!(container, device_model, devices) - add_to_objective_function!(container, devices, device_model, N) - add_constraint_dual!(container, sys, device_model) - return -end - -################################################################################# -# construct_device! — IVR (current-injection) variable-tap branch. -# Mirrors StaticBranch under IVRNetworkModel (branch_constructor.jl) plus the -# TapRatioVariable and the RegulatedVoltageMagnitude aux variable / constraint. -################################################################################# - -function construct_device!( - ::CurrentInjectionTapBranch, - container::OptimizationContainer, - sys::PSY.System, - ::ArgumentConstructStage, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel, -) where {T <: PSY.TwoWindingTransformer} - @debug "construct_device IVR VoltageControlTap (ArgumentConstructStage)" _group = - LOG_GROUP_BRANCH_CONSTRUCTIONS - devices = get_available_components(device_model, sys) - _validate_controlled_branch_not_reduced(network_model, devices, "VoltageControlTap") - add_variables!(container, TapRatioVariable, devices, VoltageControlTap) - add_variables!(container, FlowActivePowerFromToVariable, devices, VoltageControlTap) - add_variables!(container, FlowActivePowerToFromVariable, devices, VoltageControlTap) - add_variables!(container, FlowReactivePowerFromToVariable, devices, VoltageControlTap) - add_variables!(container, FlowReactivePowerToFromVariable, devices, VoltageControlTap) - add_variables!(container, BranchCurrentFromToReal, devices, device_model, network_model) - add_variables!( - container, - BranchCurrentFromToImaginary, - devices, - device_model, - network_model, - ) - add_variables!(container, BranchCurrentToFromReal, devices, device_model, network_model) - add_variables!( - container, - BranchCurrentToFromImaginary, - devices, - device_model, - network_model, - ) - add_variables!(container, BranchSeriesCurrentReal, devices, device_model, network_model) - add_variables!( - container, - BranchSeriesCurrentImaginary, - devices, - device_model, - network_model, - ) - add_regulated_voltage_magnitude!( - container, devices, sys, network_model, - ) - add_to_expression!( - container, ActivePowerBalance, FlowActivePowerFromToVariable, - devices, device_model, network_model, - ) - add_to_expression!( - container, ActivePowerBalance, FlowActivePowerToFromVariable, - devices, device_model, network_model, - ) - add_to_expression!( - container, ReactivePowerBalance, FlowReactivePowerFromToVariable, - devices, device_model, network_model, - ) - add_to_expression!( - container, ReactivePowerBalance, FlowReactivePowerToFromVariable, - devices, device_model, network_model, - ) - add_feedforward_arguments!(container, device_model, devices) - return -end - -function construct_device!( - ::CurrentInjectionTapBranch, - container::OptimizationContainer, - sys::PSY.System, - ::ModelConstructStage, - device_model::DeviceModel{T, VoltageControlTap}, - network_model::NetworkModel{N}, -) where {T <: PSY.TwoWindingTransformer, N} - @debug "construct_device IVR VoltageControlTap (ModelConstructStage)" _group = - LOG_GROUP_BRANCH_CONSTRUCTIONS - devices = get_available_components(device_model, sys) - add_constraints!( - container, FlowRateConstraintFromTo, devices, device_model, network_model, - ) - add_constraints!( - container, FlowRateConstraintToFrom, devices, device_model, network_model, - ) - add_constraints!( - container, sys, NetworkFlowConstraint, devices, device_model, network_model, - ) - add_constraints!( - container, sys, CurrentLimitConstraint, devices, device_model, network_model, - ) - add_constraints!( - container, sys, AngleDifferenceConstraint, devices, device_model, network_model, - ) - add_regulated_voltage_magnitude_constraints!( - container, devices, sys, network_model, - ) - _apply_tap_control_objective!(container, sys, devices, network_model) - add_feedforward_constraints!(container, device_model, devices) - add_to_objective_function!(container, devices, device_model, N) - add_constraint_dual!(container, sys, device_model) - return -end - -# Defensive no-ops for active-power-only networks. template_validation drops the -# reactive VoltageControlTap formulation before construction, so these are only -# reached if template validation is bypassed. -function construct_device!( - ::OptimizationContainer, - ::PSY.System, - ::ArgumentConstructStage, - ::DeviceModel{T, VoltageControlTap}, - ::NetworkModel{<:AbstractActivePowerModel}, -) where {T <: PSY.TwoWindingTransformer} - return -end - -function construct_device!( - ::OptimizationContainer, - ::PSY.System, - ::ModelConstructStage, - ::DeviceModel{T, VoltageControlTap}, - ::NetworkModel{<:AbstractActivePowerModel}, -) where {T <: PSY.TwoWindingTransformer} - return -end diff --git a/test/test_native_transformer_tap.jl b/test/test_native_transformer_tap.jl index c7988ca..5f65143 100644 --- a/test/test_native_transformer_tap.jl +++ b/test/test_native_transformer_tap.jl @@ -2,8 +2,8 @@ # Off-nominal transformer tap under the native network models. # # These testsets cover only code that ships in the current module: the DC susceptance -# `b_dc = 1/(tap*x)` used by `BThetaBranchFlow`/`NetworkFlowConstraint`, and the tap-free -# π coefficients in `_tap_flow_coefficients` (both in `ac_transmission_models/ +# `b_dc = 1/(tap*x)` used by `BThetaBranchFlow`/`NetworkFlowConstraint`, and the Ybus +# two-port terms in `_pi_flow_terms` (both in `ac_transmission_models/ # AC_branches.jl`). They deliberately do NOT touch `TapControl` / `VoltageControlTap`, # whose formulation files are not yet included — those live in # `test_native_tapcontrol.jl` / `test_voltage_control_tap_models.jl` and stay disabled @@ -61,54 +61,33 @@ @test tested_a_real_tap end -@testset "_tap_flow_coefficients ground truth (hand-computed)" begin - # No shift: cs=1, sn=0. Hand-computed π terms and coupling coefficients. - c0 = POM._tap_flow_coefficients(1.0, -2.0, 0.1, 0.3, 0.2, 0.4, 0.0) - @test c0.cs == 1.0 - @test c0.sn == 0.0 - # From side is returned UNFOLDED (series and shunt separate) because the two get - # different tap treatment at the constraint site; see the composition asserts below. - @test c0.g == 1.0 - @test c0.b == -2.0 - @test c0.g_fr == 0.1 - @test c0.b_fr == 0.3 - # To side stays folded: neither half is tap-referred, matching PNM's `Y22 = Y_l + y_to`. - @test c0.gg_to == 1.2 - @test c0.bb_to == -1.6 - @test c0.a_cos == -1.0 # -g*cs + b*sn - @test c0.a_sin == 2.0 # -b*cs - g*sn - @test c0.c_cos == -1.0 # -g*cs - b*sn - @test c0.d_sin == -2.0 # b*cs - g*sn +@testset "_tapped_admittance round-trips PNM.ybus_branch_entries" begin + function check_terms(y, ybus) + Y11, Y12, Y21, Y22 = ybus + @test isapprox(complex(y.g11, y.b11), Y11; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g12, y.b12), Y12; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g21, y.b21), Y21; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g22, y.b22), Y22; rtol = 1e-10, atol = 1e-12) + end - # From-side composition exactly as the ACP/ACR constraint bodies build it: - # g/tm^2 + g_fr (the series is tap-referred; the magnetizing shunt is NOT) - # This mirrors PNM's Ybus stamp `Y11 = Y_series/abs2(tap) + y_shunt_from`. The folded - # convention `(g + g_fr)/tm^2` would give 0.704 / -1.088 instead, which is what made - # POM's AC solutions disagree with PowerFlows for off-nominal taps. - tm = 1.25 # tm^2 == 1.5625, so 1/tm^2 == 0.64 exactly - @test c0.g / tm^2 + c0.g_fr ≈ 0.74 # 0.64 + 0.1 - @test c0.b / tm^2 + c0.b_fr ≈ -0.98 # -1.28 + 0.3 - # Continuity: at nominal tap the split form reproduces the folded value, so the - # convention only bites for off-nominal taps. - @test c0.g / 1.0^2 + c0.g_fr == 1.1 - @test c0.b / 1.0^2 + c0.b_fr == -1.7 + sys = PSB.build_system(PSITestSystems, "c_sys14") + for br in Iterators.flatten(( + PSY.get_components(PSY.Line, sys), + PSY.get_components(PSY.TwoWindingTransformer, sys), + )) + adm = PNM.branch_admittance(br) + check_terms(POM._pi_flow_terms(adm, adm.tap), PNM.ybus_branch_entries(br)) + end - # Nonzero shift = π/6: cs=√3/2, sn=1/2 exercises the trig. - cs = cos(pi / 6) - sn = sin(pi / 6) - cS = POM._tap_flow_coefficients(1.0, -2.0, 0.1, 0.3, 0.2, 0.4, pi / 6) - @test cS.cs ≈ cs - @test cS.sn ≈ sn - @test cS.g == 1.0 - @test cS.b == -2.0 - @test cS.g_fr == 0.1 - @test cS.b_fr == 0.3 - @test cS.gg_to == 1.2 - @test cS.bb_to == -1.6 - @test cS.a_cos ≈ -1.0 * cs + (-2.0) * sn - @test cS.a_sin ≈ -(-2.0) * cs - 1.0 * sn - @test cS.c_cos ≈ -1.0 * cs - (-2.0) * sn - @test cS.d_sin ≈ (-2.0) * cs - 1.0 * sn - # ACR's e_sin sign relationship the constraint body relies on. - @test -cS.d_sin ≈ -(-2.0) * cs + 1.0 * sn + tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") + circuit = PSY.get_circuit(tr) + for shift in (-pi / 5, 0.0, pi / 6) + PSY.set_α!(circuit, shift) + PSY.set_tap!(circuit, 1.0) + adm = PNM.branch_admittance(tr) + for tap in (0.9, 1.0, 1.1, 1.25) + PSY.set_tap!(circuit, tap) + check_terms(POM._pi_flow_terms(adm, tap), PNM.ybus_branch_entries(tr)) + end + end end From ce8924d221f08211843bc9506952bdc29b5a1416 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 17 Aug 2026 14:21:55 -0400 Subject: [PATCH 02/19] refactor irreducible bus code; make controllable transformers irreducible --- test/test_native_transformer_tap.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_native_transformer_tap.jl b/test/test_native_transformer_tap.jl index 5f65143..0d7a198 100644 --- a/test/test_native_transformer_tap.jl +++ b/test/test_native_transformer_tap.jl @@ -3,7 +3,7 @@ # # These testsets cover only code that ships in the current module: the DC susceptance # `b_dc = 1/(tap*x)` used by `BThetaBranchFlow`/`NetworkFlowConstraint`, and the Ybus -# two-port terms in `_pi_flow_terms` (both in `ac_transmission_models/ +# two-port terms in `_tapped_admittance` (both in `ac_transmission_models/ # AC_branches.jl`). They deliberately do NOT touch `TapControl` / `VoltageControlTap`, # whose formulation files are not yet included — those live in # `test_native_tapcontrol.jl` / `test_voltage_control_tap_models.jl` and stay disabled From ad78ddf82024ba7150c33c67af0f9c71488541a7 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 13 Aug 2026 23:57:18 -0400 Subject: [PATCH 03/19] rough tap control implementation; still need to run tests and add voltage and q bounds --- src/ac_transmission_models/AC_branches.jl | 112 ++++++++++++------ .../branch_constructor.jl | 1 + 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 0ca0eb5..c5473a9 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -69,6 +69,14 @@ function get_default_time_series_names( return Dict{Type{<:TimeSeriesParameter}, String}() end +ENABLE_CONTROLS_KEY = "enable_controls" + +_control_attribute( + ::Union{Type{PSY.TwoWindingTransformer}, Type{PSY.ThreeWindingTransformer}}, +) = (ENABLE_CONTROLS_KEY => false,) + +_control_attribute(_) = () + """ DeviceModel attribute key selecting which `PowerNetworkMatrices` function aggregates the individual circuit ratings of a `PNM.BranchesParallel` into a single maximum flow @@ -84,6 +92,7 @@ function get_default_attributes( ) where {U <: PSY.ACTransmission, V <: AbstractBranchFormulation} return Dict{String, Any}( PARALLEL_BRANCH_MAX_RATING_KEY => "single_element_contingency", + _control_attribute(U)... ) end @@ -94,6 +103,7 @@ function get_default_attributes( return Dict{String, Any}( PARALLEL_BRANCH_MAX_RATING_KEY => "single_element_contingency", "include_planned_outages" => false, + _control_attribute(U)... ) end @@ -271,12 +281,48 @@ function add_variables!( return end -# Non-negative flow-definition slack container carrying a container META. StaticBranchBounds -# distinguishes its per-direction slack pairs ("p_ft"/"p_tf"/"q_ft"/"q_tf") by meta on the -# shared FlowActivePowerSlack{Upper,Lower}Bound types; `add_variables!` threads no meta, so -# build the container directly. One slack per representative arc — the equality is written -# once per arc. Axes are precomputed by the caller (shared across all metas of one device -# model). +# Matches the names returned by _branch_geometries +_circuit_arc_name(d::PSY.TwoWindingTransformer, ::PSY.TransformerCircuit, ::Int) = + PSY.get_name(d) +_circuit_arc_name(d::PSY.ThreeWindingTransformer, c::PSY.TransformerCircuit, i::Int) = + PNM.get_name(PNM.ThreeWindingTransformerCircuit(d, c, i)) + +function _add_transformer_control_variables!( + container::OptimizationContainer, + model::DeviceModel{U, F}, + devices::IS.FlattenIteratorWrapper{U}, + network_model::NetworkModel, +) where { + U <: Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer}, + F <: AbstractBranchFormulation, +} + get_attribute(model, ENABLE_CONTROLS_KEY) === true || return + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + names = [ + _circuit_arc_name(d, c, i) + for d in devices + for (i, c) in enumerate(PSY.get_circuits(d)) + if PSY.get_available(c) && PSY.get_control_objective(c) in (PSY.TransformerControlObjective.VOLTAGE, PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) + ] + _validate_controlled_branch_not_reduced(network_model, U, names) + variable = add_variable_container!(container, TapRatioVariable, U, names, time_steps) + for name in names, t in time_steps + variable[name, t] = JuMP.@variable( + jump_model, + base_name = "TapRatioVariable_$(U)_{$(name), $(t)}", + ) + end + return +end + +_add_transformer_control_variables!( + ::OptimizationContainer, + ::DeviceModel, + ::IS.FlattenIteratorWrapper, + ::NetworkModel, +) = nothing + function _add_meta_flow_slack!( container::OptimizationContainer, ::Type{T}, @@ -1113,27 +1159,19 @@ function _branch_rating_entries( ] end -# Formulations that model a per-device control decision variable (variable tap ratio, -# phase-shifter angle) cannot be expressed on a PNM series/parallel equivalent — the -# reduction folds a FIXED device setting into the merged π-parameters. A controlled -# branch absorbed by a network reduction is a modeling conflict the user must resolve, -# not something to silently approximate. function _validate_controlled_branch_not_reduced( network_model::NetworkModel, - devices::IS.FlattenIteratorWrapper{T}, - formulation_name::String, + ::Type{T}, + controlled_names, ) where {T <: PSY.ACTransmission} network_reduction = get_network_reduction(network_model) isempty(network_reduction) && return arc_map = get_name_to_arc_map_entries(network_reduction, T) - for d in devices - name = PSY.get_name(d) - if !haskey(arc_map, name) || arc_map[name][2] != "direct_branch_map" + for name in controlled_names + entry = get(arc_map, name, nothing) + if entry === nothing || entry[2] != "direct_branch_map" error( - "$(formulation_name) branch $(name) was absorbed by a network \ - reduction (radial, degree-two, or parallel aggregation). Exclude it \ - from the reduction with a PNM reduction filter or model it with a \ - static branch formulation.", + "Controlled transformer circuit $(name) was merged with a parallel branch. Either remove the parallel branch or disable control for this circuit.", ) end end @@ -1172,6 +1210,9 @@ _dc_phase_shift(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReduction _dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = PNM.get_series_phase_shift(branch) +_control_objective(c::PSY.TransformerCircuit) = PSY.get_control_objective(c) +_control_objective(_) = PSY.TransformerControlObjective.UNDEFINED + function _branch_geometry( nr::PNM.NetworkReductionData, number_to_name::Dict{Int, String}, @@ -1192,6 +1233,7 @@ function _branch_geometry( shift_dc = _dc_phase_shift(branch, nr), r_dc = PNM.arc_dc_resistance(nr, arc_tuple), direct = !_is_aggregate(branch), + objective = _control_objective(branch) ) end @@ -1382,14 +1424,6 @@ function add_constraints!( return end -""" -Create the four directional `NetworkFlowConstraint` containers shared by every AC -branch-flow formulation, fixed- and variable-tap alike: active and reactive power in -the from→to and to→from directions, keyed by branch name and time step. Thin factory -over `add_constraints_container!`; returns them in (p_ft, q_ft, p_tf, q_tf) order so a -caller can write `cons_pft, cons_qft, cons_ptf, cons_qtf = ...`. Keeping this in one -place lets each formulation's method show only the Ohm's-law math that actually differs. -""" function _add_flow_constraint_containers!( container::OptimizationContainer, ::Type{T}, @@ -1656,6 +1690,8 @@ function add_constraints!( qft = get_variable(container, FlowReactivePowerFromToVariable, T) qtf = get_variable(container, FlowReactivePowerToFromVariable, T) + tap_var = get_attribute(device_model, ENABLE_CONTROLS_KEY) ? get_variable(container, TapRatioVariable, T) : nothing + number_to_name = _retained_number_to_name(sys, network_model) geoms = _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) @@ -1663,12 +1699,7 @@ function add_constraints!( cons_pft, cons_qft, cons_ptf, cons_qtf = _add_flow_constraint_containers!(container, T, branch_names) jump_model = get_jump_model(container) - slacks = _flow_equality_slacks(container, device_model, T) - p_ft_slack = _slack_term(slacks.p_ft, name, t) - q_ft_slack = _slack_term(slacks.q_ft, name, t) - p_tf_slack = _slack_term(slacks.p_tf, name, t) - q_tf_slack = _slack_term(slacks.q_tf, name, t) for g_geom in geoms name = g_geom.name @@ -1678,27 +1709,32 @@ function add_constraints!( for t in time_steps vp = _voltage_products(container, network_model, T, name, from_bus, to_bus, t) - y = _tapped_admittance(adm, adm.tap) + tap = g_geom.control in TAP_CONTROL_OBJECTIVES ? tap_var[name, t] : adm.tap + y = _tapped_admittance(adm, tap) cons_pft[name, t] = JuMP.@constraint( jump_model, pft[name, t] == - y.g11 * vp.v2_fr + y.g12 * vp.vv_cos + y.b12 * vp.vv_sin + p_ft_slack, + y.g11 * vp.v2_fr + y.g12 * vp.vv_cos + y.b12 * vp.vv_sin + + p_slack_term(slacks.p_ft, name, t) ) cons_qft[name, t] = JuMP.@constraint( jump_model, qft[name, t] == - -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + q_ft_slack, + -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + + _slack_term(slacks.q_ft, name, t), ) cons_ptf[name, t] = JuMP.@constraint( jump_model, ptf[name, t] == - y.g22 * vp.v2_to + y.g21 * vp.vv_cos - y.b21 * vp.vv_sin + p_tf_slack, + y.g22 * vp.v2_to + y.g21 * vp.vv_cos - y.b21 * vp.vv_sin + + _slack_term(slacks.p_tf, name, t), ) cons_qtf[name, t] = JuMP.@constraint( jump_model, qtf[name, t] == - -y.b22 * vp.v2_to - y.b21 * vp.vv_cos - y.g21 * vp.vv_sin + q_tf_slack, + -y.b22 * vp.v2_to - y.b21 * vp.vv_cos - y.g21 * vp.vv_sin + + _slack_term(slacks.q_tf, name, t), ) end end diff --git a/src/ac_transmission_models/branch_constructor.jl b/src/ac_transmission_models/branch_constructor.jl index 270db4e..018b49e 100644 --- a/src/ac_transmission_models/branch_constructor.jl +++ b/src/ac_transmission_models/branch_constructor.jl @@ -288,6 +288,7 @@ function construct_device!( devices = get_available_components(device_model, sys) _add_static_branch_flow_variables!(container, devices, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) + _add_transformer_control_variables!(container, device_model, devices, network_model) return end From be410db89f95bc389de6267fee3d84536227c4ea Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 14 Aug 2026 09:52:45 -0400 Subject: [PATCH 04/19] finished tap controls --- src/ac_transmission_models/AC_branches.jl | 62 ++++++++++++------- .../branch_constructor.jl | 1 + 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index c5473a9..e495914 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -287,6 +287,8 @@ _circuit_arc_name(d::PSY.TwoWindingTransformer, ::PSY.TransformerCircuit, ::Int) _circuit_arc_name(d::PSY.ThreeWindingTransformer, c::PSY.TransformerCircuit, i::Int) = PNM.get_name(PNM.ThreeWindingTransformerCircuit(d, c, i)) +# TODO: add other controls + refactor +# TODO: Change TransformerCircuit <: DeviceParameter -> <: Device function _add_transformer_control_variables!( container::OptimizationContainer, model::DeviceModel{U, F}, @@ -297,32 +299,30 @@ function _add_transformer_control_variables!( F <: AbstractBranchFormulation, } get_attribute(model, ENABLE_CONTROLS_KEY) === true || return - time_steps = get_time_steps(container) - jump_model = get_jump_model(container) - names = [ - _circuit_arc_name(d, c, i) + # TODO: collectzip is ugly, refactor now or do Circuit <: Device first + names, circuits = collect(zip([ + (_circuit_arc_name(d, c, i), c) for d in devices for (i, c) in enumerate(PSY.get_circuits(d)) if PSY.get_available(c) && PSY.get_control_objective(c) in (PSY.TransformerControlObjective.VOLTAGE, PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) - ] + ])) _validate_controlled_branch_not_reduced(network_model, U, names) + + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) variable = add_variable_container!(container, TapRatioVariable, U, names, time_steps) - for name in names, t in time_steps + for (i, name) in enumerate(names), t in time_steps + bounds = get_control_limits(circuits[i]) variable[name, t] = JuMP.@variable( jump_model, base_name = "TapRatioVariable_$(U)_{$(name), $(t)}", + lower_bound = bounds.min, + upper_bound = bounds.max ) end return end -_add_transformer_control_variables!( - ::OptimizationContainer, - ::DeviceModel, - ::IS.FlattenIteratorWrapper, - ::NetworkModel, -) = nothing - function _add_meta_flow_slack!( container::OptimizationContainer, ::Type{T}, @@ -1213,6 +1213,12 @@ _dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = _control_objective(c::PSY.TransformerCircuit) = PSY.get_control_objective(c) _control_objective(_) = PSY.TransformerControlObjective.UNDEFINED +_quantity_limits(c::PSY.TransformerCircuit) = PSY.get_controlled_quantity_limits(c) +_quantity_limits(_) = (min = -Inf, max = Inf) + +_regulated_bus(c::PSY.TransformerCircuit) = PSY.get_regulated_bus_number(c) === PSY.get_number(PSY.get_from(PSY.get_arc(c))) ? :from : :to +_regulated_bus(_) = nothing + function _branch_geometry( nr::PNM.NetworkReductionData, number_to_name::Dict{Int, String}, @@ -1234,6 +1240,8 @@ function _branch_geometry( r_dc = PNM.arc_dc_resistance(nr, arc_tuple), direct = !_is_aggregate(branch), objective = _control_objective(branch) + quantity_limits = _quantity_limits(branch) + regulated_bus = _regulated_bus(branch) ) end @@ -1718,24 +1726,36 @@ function add_constraints!( y.g11 * vp.v2_fr + y.g12 * vp.vv_cos + y.b12 * vp.vv_sin + p_slack_term(slacks.p_ft, name, t) ) - cons_qft[name, t] = JuMP.@constraint( - jump_model, - qft[name, t] == - -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + - _slack_term(slacks.q_ft, name, t), - ) cons_ptf[name, t] = JuMP.@constraint( jump_model, ptf[name, t] == y.g22 * vp.v2_to + y.g21 * vp.vv_cos - y.b21 * vp.vv_sin + _slack_term(slacks.p_tf, name, t), ) - cons_qtf[name, t] = JuMP.@constraint( + qft_expr = JuMP.@expression( + jump_model, + -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + + _slack_term(slacks.q_ft, name, t), + ) + qtf_expr = JuMP.@expression( jump_model, - qtf[name, t] == -y.b22 * vp.v2_to - y.b21 * vp.vv_cos - y.g21 * vp.vv_sin + _slack_term(slacks.q_tf, name, t), ) + cons_qft[name, t] = JuMP.@constraint(jump_model, qft[name, t] == qft_expr) + cons_qtf[name, t] = JuMP.@constraint(jump_model, qtf[name, t] == qtf_expr) + + if g_geom.control === PSY.TransformerControlObjective.VOLTAGE + voltage = g_geom.regulated_bus == :from ? vp.v2_fr : vp.v2_to + JuMP.@constraint(jump_model, voltage >= g_geom.quantity_limits.min) + JuMP.@constraint(jump_model, voltage <= g_geom.quantity_limits.max) + end + if g_geom.control === PSY.TransformerControlObjective.REACTIVE_POWER_FLOW + JuMP.@constraint(jump_model, qft_expr >= g_geom.quantity_limits.min) + JuMP.@constraint(jump_model, qft_expr <= g_geom.quantity_limits.max) + JuMP.@constraint(jump_model, qtf_expr >= g_geom.quantity_limits.min) + JuMP.@constraint(jump_model, qtf_expr <= g_geom.quantity_limits.max) + end end end return diff --git a/src/ac_transmission_models/branch_constructor.jl b/src/ac_transmission_models/branch_constructor.jl index 018b49e..39326da 100644 --- a/src/ac_transmission_models/branch_constructor.jl +++ b/src/ac_transmission_models/branch_constructor.jl @@ -320,6 +320,7 @@ function construct_device!( add_constraints!( container, sys, AngleDifferenceConstraint, devices, device_model, network_model, ) + _add_transformer_control_constraints!(container, device_model, devices, network_model) add_feedforward_constraints!(container, device_model, devices) add_to_objective_function!(container, devices, device_model, ACPNetworkModel) add_constraint_dual!(container, sys, device_model) From 702114c57aab049a06effad65f6928afcd1e6061 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 17 Aug 2026 14:26:07 -0400 Subject: [PATCH 05/19] claude reorganize tests and update tap tests --- test/runtests.jl | 9 +- test/test_native_dcp_acp_models.jl | 37 +- test/test_native_lpacc_model.jl | 55 +- test/test_native_tapcontrol.jl | 72 -- test/test_power_flow_in_the_loop.jl | 60 -- test/test_transformer_controls.jl | 707 ++++++++++++++++++ ...r_tap.jl => test_transformer_fixed_tap.jl} | 15 +- test/test_voltage_control_tap_models.jl | 405 ---------- 8 files changed, 752 insertions(+), 608 deletions(-) delete mode 100644 test/test_native_tapcontrol.jl create mode 100644 test/test_transformer_controls.jl rename test/{test_native_transformer_tap.jl => test_transformer_fixed_tap.jl} (86%) delete mode 100644 test/test_voltage_control_tap_models.jl diff --git a/test/runtests.jl b/test/runtests.jl index b092cde..25c59ad 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -20,14 +20,7 @@ const TEST_DIR = @__DIR__ # helpers, and `test_data/` are shared infrastructure, not standalone testsets — they # must not be run as tests (ParallelTestRunner's default discovery would pick them up). -# psy6: disabled pending the transformer refactor. Both remaining entries dispatch on a -# formulation that is still commented out of the module — `TapControl` in -# `transformer_models.jl` and `VoltageControlTap` in `voltage_control_tap_models.jl` — so -# their bodies cannot even be compiled yet. Re-enable them with those formulations. -const DISABLED_TESTS = Set([ - "test_native_tapcontrol", - "test_voltage_control_tap_models", -]) +const DISABLED_TESTS = Set(String[]) testsuite = Dict{String, Expr}( splitext(f)[1] => :(include($(joinpath(TEST_DIR, f)))) for diff --git a/test/test_native_dcp_acp_models.jl b/test/test_native_dcp_acp_models.jl index 6c18806..ec14187 100644 --- a/test/test_native_dcp_acp_models.jl +++ b/test/test_native_dcp_acp_models.jl @@ -483,29 +483,20 @@ end end @testset "use_slacks on a no-machinery formulation fails template validation" begin - # # slack_spec defaults to NoBranchSlacks, so every pair whose constructors build no - # # slack containers now rejects the request instead of silently ignoring it. - # # StaticBranchUnbounded builds nothing at all; VoltageControlTap never creates slacks. - # sys = PSB.build_system(PSITestSystems, "c_sys5") - # for network_formulation in (DCPNetworkModel, ACPNetworkModel) - # template = - # get_thermal_dispatch_template_network(NetworkModel(network_formulation)) - # set_device_model!( - # template, - # DeviceModel(PSY.Line, StaticBranchUnbounded; use_slacks = true), - # ) - # model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - # @test_throws IS.ConflictingInputsError POM.validate_template(model) - # end - - # sys14 = PSB.build_system(PSITestSystems, "c_sys14") - # template = get_thermal_dispatch_template_network(NetworkModel(ACPNetworkModel)) - # set_device_model!( - # template, - # DeviceModel(PSY.TwoWindingTransformer, VoltageControlTap; use_slacks = true), - # ) - # model = DecisionModel(template, sys14; optimizer = ipopt_optimizer) - # @test_throws IS.ConflictingInputsError POM.validate_template(model) + # slack_spec defaults to NoBranchSlacks, so every pair whose constructors build no + # slack containers rejects the request instead of silently ignoring it. + # StaticBranchUnbounded builds nothing at all. + sys = PSB.build_system(PSITestSystems, "c_sys5") + for network_formulation in (DCPNetworkModel, ACPNetworkModel) + template = + get_thermal_dispatch_template_network(NetworkModel(network_formulation)) + set_device_model!( + template, + DeviceModel(PSY.Line, StaticBranchUnbounded; use_slacks = true), + ) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test_throws IS.ConflictingInputsError POM.validate_template(model) + end end @testset "CopperPlateNetworkModel accepts use_slacks as inert with a validation warning" begin diff --git a/test/test_native_lpacc_model.jl b/test/test_native_lpacc_model.jl index 0a5d3c7..f3c96f5 100644 --- a/test/test_native_lpacc_model.jl +++ b/test/test_native_lpacc_model.jl @@ -44,36 +44,27 @@ end end @testset "LPACCNetworkModel rejects reactive control devices at validation" begin - # # LPACC is reactive-capable at the network level (network_has_reactive_power is - # # true), but VoltageControlTap/ShuntSusceptanceDispatch have no LPACC construct - # # path. The validation gate must reject the pairing with a - # # ConflictingInputsError. (build! swallows build/validation exceptions into a - # # FAILED status, so assert against validate_template directly — same pattern as - # # test_network_constructors_with_branch_rating_time_series.jl.) - # sys = PSB.build_system(PSITestSystems, "c_sys14") - # template = get_thermal_dispatch_template_network(NetworkModel(LPACCNetworkModel)) - # set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - - # model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - # @test_throws IS.ConflictingInputsError POM.validate_template(model) - - # # Same gate for the shunt controller. - # sys_shunt = PSB.build_system(PSITestSystems, "c_sys5") - # bus = PSY.get_component(PSY.ACBus, sys_shunt, "nodeA") - # PSY.add_component!( - # sys_shunt, - # PSY.SwitchedAdmittance(; - # name = "shunt_lpacc_gate", - # available = true, - # bus = bus, - # Y = 0.0 + 0.1im, - # number_of_steps = [2], - # Y_increase = [0.0 + 0.1im], - # ), - # ) - # template_shunt = - # get_thermal_dispatch_template_network(NetworkModel(LPACCNetworkModel)) - # set_device_model!(template_shunt, PSY.SwitchedAdmittance, ShuntSusceptanceDispatch) - # model_shunt = DecisionModel(template_shunt, sys_shunt; optimizer = ipopt_optimizer) - # @test_throws IS.ConflictingInputsError POM.validate_template(model_shunt) + # LPACC is reactive-capable at the network level (network_has_reactive_power is true), + # but ShuntSusceptanceDispatch has no LPACC construct path. The validation gate must + # reject the pairing with a ConflictingInputsError. (build! swallows build/validation + # exceptions into a FAILED status, so assert against validate_template directly — same + # pattern as test_network_constructors_with_branch_rating_time_series.jl.) + sys_shunt = PSB.build_system(PSITestSystems, "c_sys5") + bus = PSY.get_component(PSY.ACBus, sys_shunt, "nodeA") + PSY.add_component!( + sys_shunt, + PSY.SwitchedAdmittance(; + name = "shunt_lpacc_gate", + available = true, + bus = bus, + Y = 0.0 + 0.1im, + number_of_steps = [2], + Y_increase = [0.0 + 0.1im], + ), + ) + template_shunt = + get_thermal_dispatch_template_network(NetworkModel(LPACCNetworkModel)) + set_device_model!(template_shunt, PSY.SwitchedAdmittance, ShuntSusceptanceDispatch) + model_shunt = DecisionModel(template_shunt, sys_shunt; optimizer = ipopt_optimizer) + @test_throws IS.ConflictingInputsError POM.validate_template(model_shunt) end diff --git a/test/test_native_tapcontrol.jl b/test/test_native_tapcontrol.jl deleted file mode 100644 index 63e6962..0000000 --- a/test/test_native_tapcontrol.jl +++ /dev/null @@ -1,72 +0,0 @@ -######################################################################################### -# `TapControl` coverage. Disabled in `runtests.jl` until -# `ac_transmission_models/transformer_models.jl` is included again and the `TapControl` -# formulation exists. -# -# The tap physics that DOES ship today — `StaticBranch` under DCP, whose susceptance is -# tap-divided (`b_dc = 1/(tap*x)`) — is covered by `test_native_transformer_tap.jl`, which -# runs. Do not duplicate it here. -######################################################################################### - -@testset "TapControl models transformer tap ratio under DCP (c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, TapControl) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir()) == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - base = IOM.get_model_base_power(res) - flow = read_variable( - res, "FlowActivePowerVariable__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - va = read_variable(res, "VoltageAngle__ACBus"; table_format = TableFormat.WIDE) - - tested_a_real_tap = false - for tr in PSY.get_components(PSY.TwoWindingTransformer, sys) - name = PSY.get_name(tr) - @test name in names(flow) - adm = PNM.branch_admittance(tr) - x = -adm.b / (adm.g^2 + adm.b^2) - fr = PSY.get_name(PSY.get_from(PSY.get_arc(tr))) - to = PSY.get_name(PSY.get_to(PSY.get_arc(tr))) - if !isapprox(adm.tap, 1.0; atol = 1e-6) - tested_a_real_tap = true - end - for r in 1:nrow(flow) - p_pu = flow[r, name] / base - expected = (va[r, fr] - va[r, to] - adm.shift) / (x * adm.tap) - @test isapprox(p_pu, expected; atol = 1e-5) - end - end - # Guard: the test system must actually have a non-unit tap, else it proves nothing. - @test tested_a_real_tap -end - -@testset "TapControl differs from StaticBranch for non-unit-tap transformers (c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - - function _solve_obj(transformer_formulation) - template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, transformer_formulation) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir()) == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - return JuMP.objective_value(IOM.get_jump_model(model)) - end - - static_obj = _solve_obj(StaticBranch) - tap_obj = _solve_obj(TapControl) - # `StaticBranch` under DCP now takes its susceptance from `PNM.get_series_susceptance`, - # which is already tap-divided (`1/(tap*x)`), so a FIXED tap is modelled identically by - # both formulations and the two optima must AGREE. This testset previously asserted the - # opposite, from when the DC Ohm's law used the tap-free π susceptance and StaticBranch - # ignored the tap entirely. - # - # Before re-enabling: decide whether `TapControl` still earns its place under DCP at - # all, given StaticBranch subsumes the fixed-tap case. If it survives only to carry a - # variable tap, this comparison should be replaced by a test that moves the tap. - @test isapprox(static_obj, tap_obj; rtol = 1e-8) -end diff --git a/test/test_power_flow_in_the_loop.jl b/test/test_power_flow_in_the_loop.jl index 917570c..95a23df 100644 --- a/test/test_power_flow_in_the_loop.jl +++ b/test/test_power_flow_in_the_loop.jl @@ -184,72 +184,12 @@ end # ----------------------------------------------------------------------------- # Baseline PFitL coverage (ported from PowerSimulations.jl test file lines 1-548). # These exercise the regular non-headroom paths through the migrated code: -# - PhaseShiftingTransformer in PFitL # - Parallel-line aggregation # - Breaker-switch (DiscreteControlledACBranch) # - HVDCs with DC PowerFlow # - Line active power loss aux variable # ----------------------------------------------------------------------------- -@testset "AC Power Flow in the loop for PhaseShiftingTransformer" begin - # system = buid_system(PSITestSystems, "c_sys5_uc") - # - # line = get_component(Line, system, "1") - # arc = get_arc(line) - # - # ps = PhaseShiftingTransformer(; - # name = get_name(line), - # available = true, - # active_power_flow = 0.0, - # reactive_power_flow = 0.0, - # r = get_r(line, PSY.SU), - # x = get_x(line, PSY.SU), - # primary_shunt = 0.0, - # tap = 1.0, - # α = 0.0, - # rating = get_rating(line, PSY.SU), - # arc = arc, - # base_power = get_base_power(system, PSY.NU), - # ) - # add_component!(system, ps) - # remove_component!(system, line) - # - # template = get_template_dispatch_with_network( - # NetworkModel( - # PTDFNetworkModel; - # network_matrix = PTDF(system), - # evaluations = power_flow_evaluations(ACPowerFlow()), - # ), - # ) - # set_device_model!(template, DeviceModel(PhaseShiftingTransformer, PhaseAngleControl)) - # model_m = DecisionModel(template, system; optimizer = HiGHS_optimizer) - # @test build!(model_m; output_dir = mktempdir(; cleanup = true)) == - # ModelBuildStatus.BUILT - # @test solve!(model_m) == RunStatus.SUCCESSFULLY_FINALIZED - # - # container = get_optimization_container(model_m) - # pf_e_data = only(values(get_evaluation_data(get_evaluations(container)))) - # data = get_inner_data(pf_e_data) - # bus_lookup = PFS.get_bus_lookup(data) - # - # flow_key = VariableKey(FlowActivePowerVariable, PhaseShiftingTransformer) - # flow_values = lookup_value(container, flow_key) - # line_name = get_name(line) - # line_flows = - # [JuMP.value(flow_values[line_name, t]) for t in 1:length(get_time_steps(container))] - # - # # The PhaseShiftingTransformer flow contributes to the "to"-bus active power injection. - # # Both sides are in per-unit; lookup_value returns raw JuMP values in the model unit - # # system rather than the natural-unit conversion that `read_variables(...; WIDE)` - # # performs in PSI. - # @test isapprox( - # data.bus_active_power_injections[bus_lookup[get_number(get_to(arc))], :], - # line_flows; - # atol = 1e-9, - # rtol = 0, - # ) -end - @testset "AC Power Flow in the loop with parallel lines" begin original_line_flow, parallel_line_flow = zero(ComplexF64), zero(ComplexF64) for replace_line in (true, false) diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl new file mode 100644 index 0000000..0eaa2d9 --- /dev/null +++ b/test/test_transformer_controls.jl @@ -0,0 +1,707 @@ +######################################################################################### +# Transformer tap controls: every `TransformerControlObjective` other than FIXED / +# UNDEFINED. Control is opted into per `DeviceModel` with the `enable_controls` attribute; +# when it is on, each `TransformerCircuit`'s `control_objective` decides what is built. +# A controlled circuit gets a `TapRatioVariable` bounded by `control_limits`, and the +# controlled quantity is held inside `controlled_quantity_limits`. +# +# Fixed / off-nominal tap physics (tap as a constant component property) lives in +# `test_transformer_fixed_tap.jl` — do not duplicate it here. +######################################################################################### + +const VOLTAGE_CONTROL = PSY.TransformerControlObjective.VOLTAGE +const Q_FLOW_CONTROL = PSY.TransformerControlObjective.REACTIVE_POWER_FLOW +const P_FLOW_CONTROL = PSY.TransformerControlObjective.ACTIVE_POWER_FLOW + +_control_attributes(enable::Bool) = + Dict{String, Any}(POM.ENABLE_CONTROLS_KEY => enable) + +""" +`c_sys14` with one transformer circuit put under `objective`. `regulated` picks which end +of the circuit's arc is regulated (the bus number, not a sentinel — the API takes the +number of either the from or the to bus). Returns the system, the transformer, its +circuit, and the regulated bus name. +""" +function _controlled_sys14( + objective; + name = "Trans1", + regulated = :to, + quantity_limits = (min = 0.9, max = 1.1), + control_limits = (min = 0.9, max = 1.1), +) + sys = PSB.build_system(PSITestSystems, "c_sys14") + transformer = PSY.get_component(PSY.TwoWindingTransformer, sys, name) + circuit = PSY.get_circuit(transformer) + arc = PSY.get_arc(circuit) + bus = regulated == :from ? PSY.get_from(arc) : PSY.get_to(arc) + PSY.set_control_objective!(circuit, objective) + PSY.set_regulated_bus_number!(circuit, PSY.get_number(bus)) + PSY.set_controlled_quantity_limits!(circuit, quantity_limits) + PSY.set_control_limits!(circuit, control_limits) + return sys, transformer, circuit, PSY.get_name(bus) +end + +function _controlled_template(network_formulation; enable = true, kwargs...) + template = get_thermal_dispatch_template_network(NetworkModel(network_formulation; kwargs...)) + set_device_model!( + template, + DeviceModel( + PSY.TwoWindingTransformer, + StaticBranch; + attributes = _control_attributes(enable), + ), + ) + return template +end + +function _build_controlled(sys, network_formulation; enable = true, optimizer, kwargs...) + template = _controlled_template(network_formulation; enable = enable, kwargs...) + model = DecisionModel(template, sys; optimizer = optimizer) + status = build!(model; output_dir = mktempdir(; cleanup = true)) + return model, status +end + +_has_tap_variable(container) = + any(k -> occursin("TapRatioVariable", string(k)), keys(IOM.get_variables(container))) + +################################### attribute plumbing ################################# + +@testset "enable_controls is a transformer-only attribute defaulting to false" begin + for T in (PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer) + attributes = POM.get_default_attributes(T, StaticBranch) + @test haskey(attributes, POM.ENABLE_CONTROLS_KEY) + @test attributes[POM.ENABLE_CONTROLS_KEY] === false + end + # Non-transformer branches carry no control switch at all. + @test !haskey( + POM.get_default_attributes(PSY.Line, StaticBranch), + POM.ENABLE_CONTROLS_KEY, + ) + + # The attribute survives onto the DeviceModel and merges with the other defaults. + device_model = DeviceModel( + PSY.TwoWindingTransformer, + StaticBranch; + attributes = _control_attributes(true), + ) + @test IOM.get_attribute(device_model, POM.ENABLE_CONTROLS_KEY) === true + @test IOM.get_attribute(device_model, POM.PARALLEL_BRANCH_MAX_RATING_KEY) == + "single_element_contingency" +end + +@testset "a controlled circuit builds no tap variable while enable_controls is off" begin + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + model, status = + _build_controlled(sys, ACPNetworkModel; enable = false, optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test !_has_tap_variable(IOM.get_optimization_container(model)) +end + +@testset "TapRatioVariable is created only for controlled circuits, bounded by control_limits" begin + limits = (min = 0.95, max = 1.05) + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; control_limits = limits) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + # Trans2 / Trans3 are left UNDEFINED, so only the controlled circuit gets a variable. + @test axes(tap)[1] == ["Trans1"] + @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) + for v in tap + @test JuMP.lower_bound(v) == limits.min + @test JuMP.upper_bound(v) == limits.max + end +end + +@testset "REACTIVE_POWER_FLOW control also creates a tap variable" begin + sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == + ["Trans1"] +end + +@testset "ACTIVE_POWER_FLOW is a phase-shift objective, not a tap control" begin + # The tap controls cover the voltage / reactive-power objectives; an active-power + # (phase-shifting) circuit must not silently acquire a tap variable. + sys, _, _, _ = _controlled_sys14(P_FLOW_CONTROL) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test !_has_tap_variable(IOM.get_optimization_container(model)) +end + +@testset "a DISABLED objective builds no control" begin + sys, _, _, _ = + _controlled_sys14(PSY.TransformerControlObjective.VOLTAGE_DISABLED) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test !_has_tap_variable(IOM.get_optimization_container(model)) +end + +################################### VOLTAGE objective ################################## + +# Solve `c_sys14` once with the transformer uncontrolled and report the regulated bus +# voltage, so each control test can aim its band away from the free-running solution and +# prove the constraint actually bites. +function _uncontrolled_voltage(bus_name; network_formulation = ACPNetworkModel) + sys = PSB.build_system(PSITestSystems, "c_sys14") + model, status = + _build_controlled(sys, network_formulation; enable = false, optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = IOM.OptimizationProblemOutputs(model) + vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) + return vm[1, bus_name] +end + +@testset "VOLTAGE control holds the regulated bus inside its band (ACP, to-side)" begin + _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) + free_vm = _uncontrolled_voltage(bus_name) + # A band the free-running solution violates, so holding it requires the tap to move. + band = (min = free_vm + 0.01, max = free_vm + 0.02) + + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) + @test bus_name in names(vm) + for r in 1:nrow(vm) + @test vm[r, bus_name] >= band.min - 1e-6 + @test vm[r, bus_name] <= band.max + 1e-6 + end + # The band is on the voltage itself, not on its square. + @test !(free_vm >= band.min - 1e-6 && free_vm <= band.max + 1e-6) + + tap = read_variable( + res, "TapRatioVariable__TwoWindingTransformer"; table_format = TableFormat.WIDE, + ) + for r in 1:nrow(tap) + @test tap[r, "Trans1"] >= 0.9 - 1e-6 + @test tap[r, "Trans1"] <= 1.1 + 1e-6 + end +end + +@testset "VOLTAGE control regulates the from-side bus when its number is given" begin + _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL; regulated = :from) + free_vm = _uncontrolled_voltage(bus_name) + band = (min = free_vm + 0.01, max = free_vm + 0.02) + + sys, _, _, _ = + _controlled_sys14(VOLTAGE_CONTROL; regulated = :from, quantity_limits = band) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) + for r in 1:nrow(vm) + @test vm[r, bus_name] >= band.min - 1e-6 + @test vm[r, bus_name] <= band.max + 1e-6 + end +end + +@testset "the VOLTAGE band is a voltage, not a squared voltage" begin + # A band well away from 1.0 pu separates the two readings: v ∈ [0.80, 0.82] is + # satisfied by v² ∈ [0.64, 0.67], so a builder comparing the raw band against v² + # would land the voltage near 0.9 pu instead. + band = (min = 0.80, max = 0.82) + sys, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) + @test vm[1, bus_name] >= band.min - 1e-6 + @test vm[1, bus_name] <= band.max + 1e-6 +end + +############################ REACTIVE_POWER_FLOW objective ############################# + +@testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its band (ACP)" begin + # `controlled_quantity_limits` reaches the constraint builder unconverted, so it is + # read as system-base pu here; the reported flow is MVAR and divided back down. + band = (min = -0.05, max = 0.05) + sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = band) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + base = IOM.get_model_base_power(res) + for key in ( + "FlowReactivePowerFromToVariable__TwoWindingTransformer", + "FlowReactivePowerToFromVariable__TwoWindingTransformer", + ) + flow = read_variable(res, key; table_format = TableFormat.WIDE) + for r in 1:nrow(flow) + @test flow[r, "Trans1"] / base >= band.min - 1e-6 + @test flow[r, "Trans1"] / base <= band.max + 1e-6 + end + end +end + +################################### model invariants ################################### + +@testset "the model is count-invariant across control objectives (ACP)" begin + function _container_for(objective) + sys, _, _, _ = _controlled_sys14(objective) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + return IOM.get_optimization_container(model) + end + + cv = _container_for(VOLTAGE_CONTROL) + cq = _container_for(Q_FLOW_CONTROL) + + var_v = IOM.get_variables(cv) + var_q = IOM.get_variables(cq) + @test Set(keys(var_v)) == Set(keys(var_q)) + for k in keys(var_v) + @test size(var_v[k]) == size(var_q[k]) + end + + con_v = IOM.get_constraints(cv) + con_q = IOM.get_constraints(cq) + @test Set(keys(con_v)) == Set(keys(con_q)) + for k in keys(con_v) + @test size(con_v[k]) == size(con_q[k]) + end +end + +@testset "a tap pinned at nominal reproduces the uncontrolled model (ACP)" begin + # White-box reduction gate: with the tap variable fixed at the circuit's nominal + # ratio and a band too wide to bind, the controlled Ohm's law is term-by-term the + # fixed-tap one, so both models must reach the same optimum and terminal flows. + wide = (min = 0.5, max = 1.5) + + sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = wide) + model_fixed, status_fixed = _build_controlled( + sys_fixed, ACPNetworkModel; enable = false, optimizer = ipopt_optimizer, + ) + @test status_fixed == IOM.ModelBuildStatus.BUILT + @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + sys_var, transformer, circuit, _ = _controlled_sys14( + VOLTAGE_CONTROL; quantity_limits = wide, control_limits = wide, + ) + model_var, status_var = + _build_controlled(sys_var, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status_var == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model_var) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + for t in axes(tap, 2) + JuMP.fix(tap[PSY.get_name(transformer), t], PSY.get_tap(circuit); force = true) + end + @test solve!(model_var) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res_fixed = IOM.OptimizationProblemOutputs(model_fixed) + res_var = IOM.OptimizationProblemOutputs(model_var) + @test isapprox( + IOM.get_objective_value(res_var), + IOM.get_objective_value(res_fixed); + rtol = 1e-3, + ) + for key in ( + "FlowActivePowerFromToVariable__TwoWindingTransformer", + "FlowReactivePowerFromToVariable__TwoWindingTransformer", + ) + flow_fixed = read_variable(res_fixed, key; table_format = TableFormat.WIDE) + flow_var = read_variable(res_var, key; table_format = TableFormat.WIDE) + for d in PSY.get_components(PSY.TwoWindingTransformer, sys_var) + name = PSY.get_name(d) + @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) + end + end +end + +@testset "NetworkFlowConstraint carries the live tap variable (ACP coefficients)" begin + # Ground truth: the built from-to flow constraint must use exactly the + # `_tapped_admittance` terms evaluated at the tap VARIABLE. Evaluate + # `constraint_object(con).func` at an arbitrary point and compare against the + # hand-assembled right-hand side. + sys, transformer, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model) + pft = IOM.get_variable(container, FlowActivePowerFromToVariable, PSY.TwoWindingTransformer) + vm = IOM.get_variable(container, VoltageMagnitude, PSY.ACBus) + va = IOM.get_variable(container, VoltageAngle, PSY.ACBus) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + con_pft = IOM.get_constraint( + container, POM.NetworkFlowConstraint, PSY.TwoWindingTransformer, "p_ft", + ) + + t = 1 + name = PSY.get_name(transformer) + arc = PSY.get_arc(PSY.get_circuit(transformer)) + fr = PSY.get_name(PSY.get_from(arc)) + to = PSY.get_name(PSY.get_to(arc)) + adm = PNM.branch_admittance(transformer) + + vals = Dict{JuMP.VariableRef, Float64}( + vm[fr, t] => 1.02, vm[to, t] => 0.98, + va[fr, t] => 0.05, va[to, t] => -0.03, + tap[name, t] => 1.05, + pft[name, t] => 0.7, + ) + lookup = z -> vals[z] + + y = POM._tapped_admittance(adm, vals[tap[name, t]]) + vmf = vals[vm[fr, t]] + vmt = vals[vm[to, t]] + θ = vals[va[fr, t]] - vals[va[to, t]] + rhs = y.g11 * vmf^2 + y.g12 * vmf * vmt * cos(θ) + y.b12 * vmf * vmt * sin(θ) + # `func` is stored as (lhs - rhs). + @test isapprox( + JuMP.value(lookup, JuMP.constraint_object(con_pft[name, t]).func), + vals[pft[name, t]] - rhs; + atol = 1e-10, + ) +end + +################################### network coverage ################################### + +@testset "VOLTAGE control is wired on every voltage-carrying AC network" begin + for network_formulation in (ACRNetworkModel, IVRNetworkModel, LPACCNetworkModel) + @testset "$network_formulation" begin + _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) + band = (min = 1.00, max = 1.02) + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) + model, status = + _build_controlled(sys, network_formulation; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test _has_tap_variable(IOM.get_optimization_container(model)) + @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + if network_formulation == LPACCNetworkModel + phi = read_variable( + res, "VoltageDeviation__ACBus"; table_format = TableFormat.WIDE, + ) + magnitude = 1.0 + phi[1, bus_name] + else + vr = read_variable(res, "VoltageReal__ACBus"; table_format = TableFormat.WIDE) + vi = read_variable( + res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE, + ) + magnitude = sqrt(vr[1, bus_name]^2 + vi[1, bus_name]^2) + end + @test magnitude >= band.min - 1e-4 + @test magnitude <= band.max + 1e-4 + end + end +end + +@testset "DC networks carry no tap control" begin + # The DC network has no voltage magnitude or reactive flow to regulate, so an enabled + # control switch has nothing to build; the tap stays the component property that the + # tap-divided DC susceptance already carries. + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + model, status = _build_controlled(sys, DCPNetworkModel; optimizer = HiGHS_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test !_has_tap_variable(IOM.get_optimization_container(model)) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +################################ reductions and conflicts ############################## + +@testset "a controlled circuit survives the network reduction" begin + # Controlled transformers pin their endpoint buses irreducible, so the circuit keeps + # its own arc (and therefore its own tap variable) even with reductions requested. + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + model, status = _build_controlled( + sys, + ACPNetworkModel; + optimizer = ipopt_optimizer, + reduce_radial_branches = true, + reduce_degree_two_branches = true, + ) + @test status == IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == + ["Trans1"] +end + +@testset "a controlled circuit merged with a parallel branch fails with a clear error" begin + # PNM collapses parallel branches onto one equivalent arc before POM sees them, which + # would leave the control acting on a flow that is not the transformer's own. + sys, transformer, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + arc = PSY.get_arc(PSY.get_circuit(transformer)) + PSY.add_component!( + sys, + PSY.Line(; + name = "parallel_to_Trans1", + available = true, + active_power_flow = 0.0, + reactive_power_flow = 0.0, + arc = arc, + r = 0.01, + x = 0.1, + b = (from = 0.0, to = 0.0), + rating = 2.0, + angle_limits = (min = -π / 2, max = π / 2), + ), + ) + template = _controlled_template(ACPNetworkModel) + model = DecisionModel(template, sys; optimizer = ipopt_optimizer) + out = mktempdir(; cleanup = true) + @test build!(model; output_dir = out, console_level = Logging.Error) == + IOM.ModelBuildStatus.FAILED + log = read(joinpath(out, "operation_problem.log"), String) + @test occursin("Controlled transformer circuit", log) + @test occursin(PSY.get_name(transformer), log) +end + +# `case11_network_reductions` is the purpose-built reducible system (c_sys14 reduces +# nothing); it carries no forecast, which a DecisionModel build requires. +function _case11_with_forecast() + sys = PSB.build_system(PSITestSystems, "case11_network_reductions") + dummy_data = Dict( + DateTime("2020-01-01T08:00:00") => [5.0, 6, 7, 7, 7], + DateTime("2020-01-01T08:30:00") => [9.0, 9, 9, 9, 8], + DateTime("2020-01-01T09:00:00") => [6.0, 6, 5, 5, 4], + ) + dummy_forecast = Deterministic("max_active_power", dummy_data, Dates.Minute(5)) + load = first(PSY.get_components(PSY.StandardLoad, sys)) + PSY.add_time_series!(sys, load, dummy_forecast) + return sys +end + +@testset "a controlled circuit absorbed by a series reduction fails with a clear error" begin + # "1-6-i_1" is one segment of the (1,2) series chain, so under reduction it has no + # direct-branch entry of its own and its tap would control an equivalent arc it only + # partly owns. + sys = _case11_with_forecast() + line = PSY.get_component(PSY.Line, sys, "1-6-i_1") + arc = PSY.get_arc(line) + transformer = PSY.TwoWindingTransformer(; + name = PSY.get_name(line), + circuit = PSY.TransformerCircuit(; + available = true, + arc = arc, + r = PSY.get_r(line, PSY.SU), + x = PSY.get_x(line, PSY.SU), + tap = 1.0, + α = 0.0, + rating = PSY.get_rating(line, PSY.SU), + control_objective = VOLTAGE_CONTROL, + regulated_bus_number = PSY.get_number(PSY.get_to(arc)), + control_limits = (min = 0.9, max = 1.1), + controlled_quantity_limits = (min = 0.95, max = 1.05), + base_power = PSY.get_base_power(sys, PSY.NU), + ), + magnetizing_shunt = 0.0 + 0.0im, + shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, + ) + PSY.remove_component!(sys, line) + PSY.add_component!(sys, transformer) + + template = _controlled_template( + ACPNetworkModel; + reduce_radial_branches = true, + reduce_degree_two_branches = true, + ) + model = DecisionModel(template, sys; optimizer = ipopt_optimizer) + out = mktempdir(; cleanup = true) + @test build!(model; output_dir = out, console_level = Logging.Error) == + IOM.ModelBuildStatus.FAILED + @test occursin( + "Controlled transformer circuit", + read(joinpath(out, "operation_problem.log"), String), + ) +end + +@testset "ACP rejects two transformers regulating the same bus" begin + # Trans1 (4 → 9) and Trans3 (4 → 7) share bus 4. Under ACP both would drive the one + # shared VoltageMagnitude, so the conflicting bands must be caught at validation. + sys = PSB.build_system(PSITestSystems, "c_sys14") + for name in ("Trans1", "Trans3") + circuit = PSY.get_circuit(PSY.get_component(PSY.TwoWindingTransformer, sys, name)) + PSY.set_control_objective!(circuit, VOLTAGE_CONTROL) + PSY.set_regulated_bus_number!(circuit, PSY.get_number(PSY.get_from(PSY.get_arc(circuit)))) + PSY.set_controlled_quantity_limits!(circuit, (min = 0.98, max = 1.02)) + end + template = _controlled_template(ACPNetworkModel) + model = DecisionModel(template, sys; optimizer = ipopt_optimizer) + @test_throws IS.ConflictingInputsError POM.validate_template(model) +end + +######################################################################################### +# Phase control (the ACTIVE_POWER_FLOW / ASYMMETRIC_ACTIVE_POWER_FLOW objectives, where the +# phase shift α rather than the tap ratio is the decision variable) is NOT supported yet. +# The testsets below are the coverage that existed for the old `PhaseAngleControl` +# formulation, kept commented until the objective is implemented. They still name +# `PhaseAngleControl` / `PhaseShiftingTransformer`; port them onto the control-objective +# framework when phase control lands. +######################################################################################### + +# @testset "PhaseAngleControl branch absorbed by a network reduction fails with a clear error" begin +# # "1-6-i_1" is one segment of the (1,2) series chain, so under reduction it has no +# # direct-branch entry of its own — the same _validate_controlled_branch_not_reduced +# # gate exercised above for tap control also covers PhaseAngleControl. +# sys = _case11_with_forecast() +# line = PSY.get_component(Line, sys, "1-6-i_1") +# arc = PSY.get_arc(line) +# +# # TODO: phase_angle_limits? +# ps = PSY.TwoWindingTransformer(; +# name = PSY.get_name(line), +# circuit = PSY.TransformerCircuit(; +# available = true, +# active_power_flow = 0.0, +# reactive_power_flow = 0.0, +# r = PSY.get_r(line, PSY.SU), +# x = PSY.get_x(line, PSY.SU), +# tap = 1.0, +# α = 0.0, +# rating = PSY.get_rating(line, PSY.SU), +# arc = arc, +# base_power = PSY.get_base_power(sys, PSY.NU) +# ), +# magnetizing_shunt = 0.0 + 0.0im, +# shunt_location = TwoWindingTransformerShuntLocation.PRIMARY +# ) +# PSY.add_component!(sys, ps) +# PSY.remove_component!(sys, line) +# +# net = NetworkModel( +# DCPNetworkModel; +# reduce_radial_branches = true, +# reduce_degree_two_branches = true, +# ) +# template = get_thermal_dispatch_template_network(net) +# set_device_model!( +# template, DeviceModel(PSY.TwoWindingTransformer, PhaseAngleControl), +# ) +# model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) +# out = mktempdir(; cleanup = true) +# @test build!(model; output_dir = out, console_level = Logging.Error) == +# IOM.ModelBuildStatus.FAILED +# log = read(joinpath(out, "operation_problem.log"), String) +# @test occursin("absorbed by a network reduction", log) +# end + +# @testset "DC Power Flow Models for phase-shifting TwoWindingTransformer and Line" begin +# system = build_system(PSITestSystems, "c_sys5_uc") +# +# line = get_component(Line, system, "1") +# +# ps = TwoWindingTransformer(; +# name = get_name(line), +# available = true, +# active_power_flow = 0.0, +# reactive_power_flow = 0.0, +# r = get_r(line, PSY.SU), +# x = get_r(line, PSY.SU), +# primary_shunt = 0.0, +# tap = 1.0, +# α = 0.0, +# rating = get_rating(line, PSY.SU), +# arc = get_arc(line), +# base_power = get_base_power(system, PSY.NU), +# ) +# +# add_component!(system, ps) +# remove_component!(system, line) +# +# template = get_template_dispatch_with_network( +# NetworkModel(PTDFNetworkModel; network_matrix = PTDF(system)), +# ) +# set_device_model!(template, DeviceModel(TwoWindingTransformer, PhaseAngleControl)) +# model_m = DecisionModel(template, system; optimizer = HiGHS_optimizer) +# @test build!(model_m; output_dir = mktempdir(; cleanup = true)) == +# IOM.ModelBuildStatus.BUILT +# +# @test check_variable_unbounded( +# model_m, +# FlowActivePowerVariable, +# TwoWindingTransformer, +# ) +# +# @test solve!(model_m) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +# +# @test check_flow_variable_values( +# model_m, +# FlowActivePowerVariable, +# TwoWindingTransformer, +# "1", +# get_rating(ps, PSY.SU), +# ) +# +# @test check_flow_variable_values( +# model_m, +# PhaseShifterAngle, +# TwoWindingTransformer, +# "1", +# -π / 2, +# π / 2, +# ) +# end + +# @testset "AC Power Flow in the loop for PhaseShiftingTransformer" begin +# system = buid_system(PSITestSystems, "c_sys5_uc") +# +# line = get_component(Line, system, "1") +# arc = get_arc(line) +# +# ps = PhaseShiftingTransformer(; +# name = get_name(line), +# available = true, +# active_power_flow = 0.0, +# reactive_power_flow = 0.0, +# r = get_r(line, PSY.SU), +# x = get_x(line, PSY.SU), +# primary_shunt = 0.0, +# tap = 1.0, +# α = 0.0, +# rating = get_rating(line, PSY.SU), +# arc = arc, +# base_power = get_base_power(system, PSY.NU), +# ) +# add_component!(system, ps) +# remove_component!(system, line) +# +# template = get_template_dispatch_with_network( +# NetworkModel( +# PTDFNetworkModel; +# network_matrix = PTDF(system), +# evaluations = power_flow_evaluations(ACPowerFlow()), +# ), +# ) +# set_device_model!(template, DeviceModel(PhaseShiftingTransformer, PhaseAngleControl)) +# model_m = DecisionModel(template, system; optimizer = HiGHS_optimizer) +# @test build!(model_m; output_dir = mktempdir(; cleanup = true)) == +# ModelBuildStatus.BUILT +# @test solve!(model_m) == RunStatus.SUCCESSFULLY_FINALIZED +# +# container = get_optimization_container(model_m) +# pf_e_data = only(values(get_evaluation_data(get_evaluations(container)))) +# data = get_inner_data(pf_e_data) +# bus_lookup = PFS.get_bus_lookup(data) +# +# flow_key = VariableKey(FlowActivePowerVariable, PhaseShiftingTransformer) +# flow_values = lookup_value(container, flow_key) +# line_name = get_name(line) +# line_flows = +# [JuMP.value(flow_values[line_name, t]) for t in 1:length(get_time_steps(container))] +# +# # The PhaseShiftingTransformer flow contributes to the "to"-bus active power injection. +# # Both sides are in per-unit; lookup_value returns raw JuMP values in the model unit +# # system rather than the natural-unit conversion that `read_variables(...; WIDE)` +# # performs in PSI. +# @test isapprox( +# data.bus_active_power_injections[bus_lookup[get_number(get_to(arc))], :], +# line_flows; +# atol = 1e-9, +# rtol = 0, +# ) +# end diff --git a/test/test_native_transformer_tap.jl b/test/test_transformer_fixed_tap.jl similarity index 86% rename from test/test_native_transformer_tap.jl rename to test/test_transformer_fixed_tap.jl index 0d7a198..a9bd882 100644 --- a/test/test_native_transformer_tap.jl +++ b/test/test_transformer_fixed_tap.jl @@ -1,13 +1,12 @@ ######################################################################################### -# Off-nominal transformer tap under the native network models. +# Fixed off-nominal transformer tap under the native network models: the tap as a constant +# component property, with no control block (FIXED / UNDEFINED control objectives). Covers +# the DC susceptance `b_dc = 1/(tap*x)` used by `BThetaBranchFlow`/`NetworkFlowConstraint` +# and the Ybus two-port terms in `_tapped_admittance` (both in +# `ac_transmission_models/AC_branches.jl`). # -# These testsets cover only code that ships in the current module: the DC susceptance -# `b_dc = 1/(tap*x)` used by `BThetaBranchFlow`/`NetworkFlowConstraint`, and the Ybus -# two-port terms in `_tapped_admittance` (both in `ac_transmission_models/ -# AC_branches.jl`). They deliberately do NOT touch `TapControl` / `VoltageControlTap`, -# whose formulation files are not yet included — those live in -# `test_native_tapcontrol.jl` / `test_voltage_control_tap_models.jl` and stay disabled -# until the formulations are re-enabled. +# Tap CONTROL — the tap as a decision variable under a `TransformerControlObjective` — is +# covered by `test_transformer_controls.jl`. ######################################################################################### @testset "StaticBranch models transformer off-nominal tap under DCP (c_sys14)" begin diff --git a/test/test_voltage_control_tap_models.jl b/test/test_voltage_control_tap_models.jl deleted file mode 100644 index b081e4c..0000000 --- a/test/test_voltage_control_tap_models.jl +++ /dev/null @@ -1,405 +0,0 @@ -@testset "VoltageControlTap tap bounds are finite (Principle 0)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - for tr in PSY.get_components(PSY.TwoWindingTransformer, sys) - lim = POM._tap_ratio_limits(tr) - @test isfinite(lim.min) - @test isfinite(lim.max) - @test lim.min > 0.0 - @test lim.max >= lim.min - end -end - -@testset "VoltageControlTap VOLTAGE objective pins regulated bus voltage (c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - # Regulate the to-bus of Trans1 (Bus 9) to 1.0 pu via a local (regbus 0) tap. - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - PSY.set_control_objective!(tr, PSY.TransformerControlObjective.VOLTAGE) - PSY.set_regulated_bus_number!(tr, 0) - PSY.set_voltage_setpoint!(tr, 1.0) - regulated_bus = PSY.get_name(PSY.get_to(PSY.get_arc(tr))) - setpoint = PSY.get_voltage_setpoint(tr) - - template = get_thermal_dispatch_template_network(NetworkModel(ACPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) - @test regulated_bus in names(vm) - for r in 1:nrow(vm) - @test isapprox(vm[r, regulated_bus], setpoint; atol = 1e-6) - end - - # The tap floats within its bounds to hold the setpoint. - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) -end - -@testset "VoltageControlTap is count-invariant across control objectives (c_sys14)" begin - function _container_for_objective(objective) - sys = PSB.build_system(PSITestSystems, "c_sys14") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - PSY.set_control_objective!(tr, objective) - PSY.set_regulated_bus_number!(tr, 0) - PSY.set_voltage_setpoint!(tr, 1.0) - template = get_thermal_dispatch_template_network(NetworkModel(ACPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - return IOM.get_optimization_container(model) - end - - cv = _container_for_objective(PSY.TransformerControlObjective.VOLTAGE) - cq = _container_for_objective(PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) - - var_v = IOM.get_variables(cv) - var_q = IOM.get_variables(cq) - @test Set(keys(var_v)) == Set(keys(var_q)) - for k in keys(var_v) - @test size(var_v[k]) == size(var_q[k]) - end - - con_v = IOM.get_constraints(cv) - con_q = IOM.get_constraints(cq) - @test Set(keys(con_v)) == Set(keys(con_q)) - for k in keys(con_v) - @test size(con_v[k]) == size(con_q[k]) - end -end - -@testset "VoltageControlTap VOLTAGE objective pins regulated bus (ACR, c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - PSY.set_control_objective!(tr, PSY.TransformerControlObjective.VOLTAGE) - PSY.set_regulated_bus_number!(tr, 0) - PSY.set_voltage_setpoint!(tr, 1.0) - regulated_bus = PSY.get_name(PSY.get_to(PSY.get_arc(tr))) - setpoint = PSY.get_voltage_setpoint(tr) - - template = get_thermal_dispatch_template_network(NetworkModel(ACRNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - vr = read_variable(res, "VoltageReal__ACBus"; table_format = TableFormat.WIDE) - vi = read_variable(res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE) - @test regulated_bus in names(vr) - for r in 1:nrow(vr) - mag = sqrt(vr[r, regulated_bus]^2 + vi[r, regulated_bus]^2) - @test isapprox(mag, setpoint; atol = 1e-4) - end - - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) -end - -@testset "VoltageControlTap is count-invariant across control objectives (ACR, c_sys14)" begin - function _acr_container_for_objective(objective) - sys = PSB.build_system(PSITestSystems, "c_sys14") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - PSY.set_control_objective!(tr, objective) - PSY.set_regulated_bus_number!(tr, 0) - PSY.set_voltage_setpoint!(tr, 1.0) - template = get_thermal_dispatch_template_network(NetworkModel(ACRNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - return IOM.get_optimization_container(model) - end - - cv = _acr_container_for_objective(PSY.TransformerControlObjective.VOLTAGE) - cq = _acr_container_for_objective(PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) - - var_v = IOM.get_variables(cv) - var_q = IOM.get_variables(cq) - @test any(k -> occursin("RegulatedVoltageMagnitude", string(k)), keys(var_v)) - @test Set(keys(var_v)) == Set(keys(var_q)) - for k in keys(var_v) - @test size(var_v[k]) == size(var_q[k]) - end - - con_v = IOM.get_constraints(cv) - con_q = IOM.get_constraints(cq) - @test any(k -> occursin("RegulatedVoltageMagnitudeConstraint", string(k)), keys(con_v)) - @test Set(keys(con_v)) == Set(keys(con_q)) - for k in keys(con_v) - @test size(con_v[k]) == size(con_q[k]) - end -end - -@testset "VoltageControlTap VOLTAGE objective pins regulated bus (IVR, c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - PSY.set_control_objective!(tr, PSY.TransformerControlObjective.VOLTAGE) - PSY.set_regulated_bus_number!(tr, 0) - PSY.set_voltage_setpoint!(tr, 1.0) - regulated_bus = PSY.get_name(PSY.get_to(PSY.get_arc(tr))) - setpoint = PSY.get_voltage_setpoint(tr) - - template = get_thermal_dispatch_template_network(NetworkModel(IVRNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - vr = read_variable(res, "VoltageReal__ACBus"; table_format = TableFormat.WIDE) - vi = read_variable(res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE) - @test regulated_bus in names(vr) - for r in 1:nrow(vr) - mag = sqrt(vr[r, regulated_bus]^2 + vi[r, regulated_bus]^2) - @test isapprox(mag, setpoint; atol = 1e-4) - end - - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) -end - -@testset "VoltageControlTap IVR currents reduce to fixed-tap at t==tap_nominal" begin - # White-box reduction gate: with the tap variable pinned at its nominal value - # (PSY.get_tap), the variable-tap IVR Ohm's law is term-by-term identical to the - # fixed-tap (StaticBranch) IVR branch, so the two models must converge to the same - # optimum and the same physical (gauge-invariant) terminal power flows. - sys = PSB.build_system(PSITestSystems, "c_sys14") - - template_fixed = get_thermal_dispatch_template_network(NetworkModel(IVRNetworkModel)) - model_fixed = DecisionModel(template_fixed, sys; optimizer = ipopt_optimizer) - @test build!(model_fixed; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - template_var = get_thermal_dispatch_template_network(NetworkModel(IVRNetworkModel)) - set_device_model!(template_var, PSY.TwoWindingTransformer, VoltageControlTap) - model_var = DecisionModel(template_var, sys; optimizer = ipopt_optimizer) - @test build!(model_var; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - # Pin every tap variable at its nominal ratio before solving. - container = IOM.get_optimization_container(model_var) - tapvar = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - for d in PSY.get_components(PSY.TwoWindingTransformer, sys) - name = PSY.get_name(d) - for t in axes(tapvar, 2) - JuMP.fix(tapvar[name, t], PSY.get_tap(d); force = true) - end - end - @test solve!(model_var) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - obj_fixed = IOM.get_objective_value(IOM.OptimizationProblemOutputs(model_fixed)) - obj_var = IOM.get_objective_value(IOM.OptimizationProblemOutputs(model_var)) - @test isapprox(obj_var, obj_fixed; rtol = 1e-3) - - # Compare physical terminal flows on the TwoWindingTransformers (reference-invariant). - res_fixed = IOM.OptimizationProblemOutputs(model_fixed) - res_var = IOM.OptimizationProblemOutputs(model_var) - pft_fixed = read_variable( - res_fixed, "FlowActivePowerFromToVariable__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - pft_var = read_variable( - res_var, "FlowActivePowerFromToVariable__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - qft_fixed = read_variable( - res_fixed, "FlowReactivePowerFromToVariable__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - qft_var = read_variable( - res_var, "FlowReactivePowerFromToVariable__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - for d in PSY.get_components(PSY.TwoWindingTransformer, sys) - name = PSY.get_name(d) - @test isapprox(pft_var[1, name], pft_fixed[1, name]; atol = 1e-3) - @test isapprox(qft_var[1, name], qft_fixed[1, name]; atol = 1e-3) - end -end - -@testset "VoltageControlTap is count-invariant across control objectives (IVR, c_sys14)" begin - function _ivr_container_for_objective(objective) - sys = PSB.build_system(PSITestSystems, "c_sys14") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - PSY.set_control_objective!(tr, objective) - PSY.set_regulated_bus_number!(tr, 0) - PSY.set_voltage_setpoint!(tr, 1.0) - template = get_thermal_dispatch_template_network(NetworkModel(IVRNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - return IOM.get_optimization_container(model) - end - - cv = _ivr_container_for_objective(PSY.TransformerControlObjective.VOLTAGE) - cq = _ivr_container_for_objective(PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) - cp = _ivr_container_for_objective(PSY.TransformerControlObjective.ACTIVE_POWER_FLOW) - - var_v = IOM.get_variables(cv) - var_q = IOM.get_variables(cq) - var_p = IOM.get_variables(cp) - @test any(k -> occursin("RegulatedVoltageMagnitude", string(k)), keys(var_v)) - @test Set(keys(var_v)) == Set(keys(var_q)) == Set(keys(var_p)) - for k in keys(var_v) - @test size(var_v[k]) == size(var_q[k]) == size(var_p[k]) - end - - con_v = IOM.get_constraints(cv) - con_q = IOM.get_constraints(cq) - con_p = IOM.get_constraints(cp) - @test any(k -> occursin("RegulatedVoltageMagnitudeConstraint", string(k)), keys(con_v)) - @test Set(keys(con_v)) == Set(keys(con_q)) == Set(keys(con_p)) - for k in keys(con_v) - @test size(con_v[k]) == size(con_q[k]) == size(con_p[k]) - end -end - -@testset "VoltageControlTap @info-drop under DCPNetworkModel (c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - # The voltage-controlling tap formulation is reactive-only, so it is dropped - # with an @info from the (active-power-only) DC template during validation. - # A TwoWindingTransformer is a branch the DC network still requires to be modeled, so - # the build then fails on the now-unmodeled branch (unlike a droppable shunt - # injection). Both facts are asserted: the drop happened, and the build failed. - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.FAILED - @test !haskey(get_branch_models(get_template(model)), :TwoWindingTransformer) -end - -@testset "ACP rejects two voltage regulators on one bus" begin - # Two TwoWindingTransformers both set to VOLTAGE control regulating bus 9. Under ACP each - # pins the shared network VoltageMagnitude via JuMP.fix(force=true), so the second - # silently overrides the first. validate_template! must reject this. (build! - # swallows the throw into FAILED, so assert against validate_template directly.) - sys = PSB.build_system(PSITestSystems, "c_sys14") - for nm in ("Trans1", "Trans2") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, nm) - PSY.set_control_objective!(tr, PSY.TransformerControlObjective.VOLTAGE) - PSY.set_regulated_bus_number!(tr, 9) - PSY.set_voltage_setpoint!(tr, 1.0) - end - template = get_thermal_dispatch_template_network(NetworkModel(ACPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test_throws IS.ConflictingInputsError POM.validate_template(model) -end - -@testset "ACR does not validation-reject two regulators on one bus" begin - # Under ACR each regulator owns a (component, tag) RegulatedVoltageMagnitude aux - # variable tied by vm_reg^2 == vr^2 + vi^2, so the conflict is solver-infeasibility, - # not a validation error. validate_template must NOT throw. - sys = PSB.build_system(PSITestSystems, "c_sys14") - for nm in ("Trans1", "Trans2") - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, nm) - PSY.set_control_objective!(tr, PSY.TransformerControlObjective.VOLTAGE) - PSY.set_regulated_bus_number!(tr, 9) - PSY.set_voltage_setpoint!(tr, 1.0) - end - template = get_thermal_dispatch_template_network(NetworkModel(ACRNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test POM.validate_template(model) === nothing -end - -# The `_tap_flow_coefficients` hand-computed ground truth lives in -# `test_native_transformer_tap.jl`: it exercises only `AC_branches.jl`, which is included, -# so it must not sit behind this file's `DISABLED_TESTS` entry. - -@testset "ACR NetworkFlowConstraint coefficients equal _tap_flow_coefficients" begin - # Ground-truth: the built ACR to-from flow constraint (ptf/qtf) must use exactly the - # pure-function coefficients. Evaluate constraint_object(con).func at chosen variable - # values and compare to the hand RHS assembled from _tap_flow_coefficients. - sys = PSB.build_system(PSITestSystems, "c_sys14") - template = get_thermal_dispatch_template_network(NetworkModel(ACRNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, VoltageControlTap) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(model) - ptf = IOM.get_variable( - container, - FlowActivePowerToFromVariable, - PSY.TwoWindingTransformer, - ) - qft = IOM.get_variable( - container, - FlowReactivePowerFromToVariable, - PSY.TwoWindingTransformer, - ) - vr = IOM.get_variable(container, VoltageReal, PSY.ACBus) - vi = IOM.get_variable(container, VoltageImaginary, PSY.ACBus) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - con_ptf = - IOM.get_constraint( - container, - POM.NetworkFlowConstraint, - PSY.TwoWindingTransformer, - "p_tf", - ) - con_qft = - IOM.get_constraint( - container, - POM.NetworkFlowConstraint, - PSY.TwoWindingTransformer, - "q_ft", - ) - - t = 1 - for d in Iterators.take(PSY.get_components(PSY.TwoWindingTransformer, sys), 3) - name = PSY.get_name(d) - # Read the π-model and endpoints straight from PNM/PSY rather than through - # `_branch_geometry`, which is reduction-keyed and takes the reduction data. - # c_sys14 reduces nothing, so the device's own arc is the retained arc. - adm = PNM.branch_admittance(d) - coef = POM._tap_flow_coefficients( - adm.g, adm.b, adm.g_fr, adm.b_fr, adm.g_to, adm.b_to, adm.shift, - ) - e_sin = -coef.d_sin - arc = PSY.get_arc(PSY.get_circuit(d)) - fr = PSY.get_name(PSY.get_from(arc)) - to = PSY.get_name(PSY.get_to(arc)) - - # Arbitrary evaluation point for the (nonlinear) constraint functions. - vals = Dict{JuMP.VariableRef, Float64}( - vr[fr, t] => 1.02, vi[fr, t] => 0.05, - vr[to, t] => 0.98, vi[to, t] => -0.03, - tap[name, t] => 1.05, - ptf[name, t] => 0.7, qft[name, t] => -0.2, - ) - lookup = z -> vals[z] - - vv_to = vals[vr[to, t]]^2 + vals[vi[to, t]]^2 - cosprod = vals[vr[fr, t]] * vals[vr[to, t]] + vals[vi[fr, t]] * vals[vi[to, t]] - sinprod = vals[vi[fr, t]] * vals[vr[to, t]] - vals[vr[fr, t]] * vals[vi[to, t]] - tt = vals[tap[name, t]] - - # func is stored as (lhs - rhs); assert it matches the hand-assembled (lhs - rhs). - rhs_ptf = - coef.gg_to * vv_to + coef.c_cos / tt * cosprod + e_sin / tt * (-sinprod) - want_ptf = vals[ptf[name, t]] - rhs_ptf - got_ptf = JuMP.value(lookup, JuMP.constraint_object(con_ptf[name, t]).func) - @test isapprox(got_ptf, want_ptf; atol = 1e-10) - - vv_fr = vals[vr[fr, t]]^2 + vals[vi[fr, t]]^2 - # From side: only the series term is tap-referred, the magnetizing shunt is not. - rhs_qft = - -(coef.b / tt^2 + coef.b_fr) * vv_fr + (-coef.a_sin) / tt * cosprod + - coef.a_cos / tt * sinprod - want_qft = vals[qft[name, t]] - rhs_qft - got_qft = JuMP.value(lookup, JuMP.constraint_object(con_qft[name, t]).func) - @test isapprox(got_qft, want_qft; atol = 1e-10) - end -end From 1cd82e6d6f8e32616647f89deac2d87801a502b6 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 14 Aug 2026 17:08:11 -0400 Subject: [PATCH 06/19] claude ported tests; tests now pass --- src/ac_transmission_models/AC_branches.jl | 296 +++++++++++------- .../branch_constructor.jl | 4 +- test/includes.jl | 4 +- test/test_transformer_controls.jl | 241 +++++++------- 4 files changed, 317 insertions(+), 228 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index e495914..7e5076a 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -74,9 +74,24 @@ ENABLE_CONTROLS_KEY = "enable_controls" _control_attribute( ::Union{Type{PSY.TwoWindingTransformer}, Type{PSY.ThreeWindingTransformer}}, ) = (ENABLE_CONTROLS_KEY => false,) - _control_attribute(_) = () +_control_enabled(m::Union{DeviceModel{PSY.TwoWindingTransformer}, DeviceModel{PSY.ThreeWindingTransformer}}) = get_attribute(m, ENABLE_CONTROLS_KEY) === true +_control_enabled(c::PSY.TransformerCircuit) = PSY.get_available(c) && !(PSY.get_control_objective(c) in (PSY.TransformerControlObjective.UNDEFINED, PSY.TransformerControlObjective.FIXED)) +_control_enabled(_) = false + +_tap_controlled(c::PSY.TransformerControlObjective) = c in (PSY.TransformerControlObjective.VOLTAGE, PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) +_tap_controlled(c::PSY.TransformerCircuit) = PSY.get_available(c) && _tap_controlled(PSY.get_control_objective(c)) + +_voltage_controlled(c::PSY.TransformerControlObjective) = c === PSY.TransformerControlObjective.VOLTAGE +_voltage_controlled(c::PSY.TransformerCircuit) = PSY.get_available(c) && _voltage_controlled(PSY.get_control_objective(c)) + +_reactive_controlled(c::PSY.TransformerControlObjective) = c === PSY.TransformerControlObjective.REACTIVE_POWER_FLOW + +_tap_controlled(m::DeviceModel, d) = _control_enabled(m) && _tap_controlled(d) +_voltage_controlled(m::DeviceModel, d) = _control_enabled(m) && _voltage_controlled(d) +_reactive_controlled(m::DeviceModel, d) = _control_enabled(m) && _reactive_controlled(d) + """ DeviceModel attribute key selecting which `PowerNetworkMatrices` function aggregates the individual circuit ratings of a `PNM.BranchesParallel` into a single maximum flow @@ -92,7 +107,7 @@ function get_default_attributes( ) where {U <: PSY.ACTransmission, V <: AbstractBranchFormulation} return Dict{String, Any}( PARALLEL_BRANCH_MAX_RATING_KEY => "single_element_contingency", - _control_attribute(U)... + _control_attribute(U)..., ) end @@ -103,7 +118,7 @@ function get_default_attributes( return Dict{String, Any}( PARALLEL_BRANCH_MAX_RATING_KEY => "single_element_contingency", "include_planned_outages" => false, - _control_attribute(U)... + _control_attribute(U)..., ) end @@ -287,9 +302,14 @@ _circuit_arc_name(d::PSY.TwoWindingTransformer, ::PSY.TransformerCircuit, ::Int) _circuit_arc_name(d::PSY.ThreeWindingTransformer, c::PSY.TransformerCircuit, i::Int) = PNM.get_name(PNM.ThreeWindingTransformerCircuit(d, c, i)) -# TODO: add other controls + refactor -# TODO: Change TransformerCircuit <: DeviceParameter -> <: Device -function _add_transformer_control_variables!( +_add_tap_control_variables!( + ::OptimizationContainer, + ::DeviceModel, + ::IS.FlattenIteratorWrapper, + ::NetworkModel, +) = nothing + +function _add_tap_control_variables!( container::OptimizationContainer, model::DeviceModel{U, F}, devices::IS.FlattenIteratorWrapper{U}, @@ -299,21 +319,22 @@ function _add_transformer_control_variables!( F <: AbstractBranchFormulation, } get_attribute(model, ENABLE_CONTROLS_KEY) === true || return - # TODO: collectzip is ugly, refactor now or do Circuit <: Device first - names, circuits = collect(zip([ - (_circuit_arc_name(d, c, i), c) - for d in devices - for (i, c) in enumerate(PSY.get_circuits(d)) - if PSY.get_available(c) && PSY.get_control_objective(c) in (PSY.TransformerControlObjective.VOLTAGE, PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) - ])) + names = String[] + circuits = PSY.TransformerCircuit[] + for d in devices, (i, c) in enumerate(PSY.get_circuits(d)) + _tap_controlled(c) || continue + push!(names, _circuit_arc_name(d, c, i)) + push!(circuits, c) + end + isempty(names) && return _validate_controlled_branch_not_reduced(network_model, U, names) time_steps = get_time_steps(container) jump_model = get_jump_model(container) - variable = add_variable_container!(container, TapRatioVariable, U, names, time_steps) + tap_var = add_variable_container!(container, TapRatioVariable, U, names, time_steps) for (i, name) in enumerate(names), t in time_steps - bounds = get_control_limits(circuits[i]) - variable[name, t] = JuMP.@variable( + bounds = PSY.get_control_limits(circuits[i]) + tap_var[name, t] = JuMP.@variable( jump_model, base_name = "TapRatioVariable_$(U)_{$(name), $(t)}", lower_bound = bounds.min, @@ -1178,25 +1199,6 @@ function _validate_controlled_branch_not_reduced( return end -# Concrete element type for `_branch_geometries` so constraint builders stay type-stable -# and empty axes still yield `String` name comprehensions (an axis can be empty when the -# other branch type's constructor claimed every shared reduced arc first). -const BranchGeometry = @NamedTuple{ - name::String, - from_name::String, - to_name::String, - from_number::Int, - to_number::Int, - adm::NamedTuple{ - (:g, :b, :g_fr, :b_fr, :g_to, :b_to, :tap, :shift), - NTuple{8, Float64}, - }, - b_dc::Float64, - shift_dc::Float64, - r_dc::Float64, - direct::Bool, -} - _is_aggregate(::PNM.AbstractReductionAggregate) = true _is_aggregate(::PSY.ACTransmission) = false @@ -1210,16 +1212,46 @@ _dc_phase_shift(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReduction _dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = PNM.get_series_phase_shift(branch) -_control_objective(c::PSY.TransformerCircuit) = PSY.get_control_objective(c) -_control_objective(_) = PSY.TransformerControlObjective.UNDEFINED +_get_circuit( + b::Union{PSY.TwoWindingTransformer, PNM.ThreeWindingTransformerCircuit}, +) = PSY.get_circuit(b) +_get_circuit(_) = nothing +_control_objective(branch) = _control_objective(_get_circuit(branch)) +_control_objective(::Nothing) = PSY.TransformerControlObjective.UNDEFINED +_control_objective(c::PSY.TransformerCircuit) = + PSY.get_available(c) ? PSY.get_control_objective(c) : + PSY.TransformerControlObjective.UNDEFINED + +_quantity_limits(branch) = _quantity_limits(_get_circuit(branch)) +_quantity_limits(::Nothing) = (min = -Inf, max = Inf) _quantity_limits(c::PSY.TransformerCircuit) = PSY.get_controlled_quantity_limits(c) -_quantity_limits(_) = (min = -Inf, max = Inf) -_regulated_bus(c::PSY.TransformerCircuit) = PSY.get_regulated_bus_number(c) === PSY.get_number(PSY.get_from(PSY.get_arc(c))) ? :from : :to -_regulated_bus(_) = nothing +_regulated_number(branch) = _regulated_number(_get_circuit(branch)) +_regulated_number(::Nothing) = -1 +_regulated_number(c::PSY.TransformerCircuit) = PSY.get_regulated_bus_number(c) + +Base.@kwdef struct BranchGeometry + name::String + from_name::String + to_name::String + from_number::Int + to_number::Int + adm::NamedTuple{ + (:g, :b, :g_fr, :b_fr, :g_to, :b_to, :tap, :shift), + NTuple{8, Float64}, + } + b_dc::Float64 + shift_dc::Float64 + r_dc::Float64 + direct::Bool + control::PSY.TransformerControlObjective + quantity_limits::MinMax + regulated_number::Int -function _branch_geometry( +end + +function BranchGeometry( nr::PNM.NetworkReductionData, number_to_name::Dict{Int, String}, name::String, @@ -1228,7 +1260,7 @@ function _branch_geometry( ) from_no = arc_tuple[1] to_no = arc_tuple[2] - return ( + return BranchGeometry(; name = name, from_name = number_to_name[from_no], to_name = number_to_name[to_no], @@ -1239,17 +1271,18 @@ function _branch_geometry( shift_dc = _dc_phase_shift(branch, nr), r_dc = PNM.arc_dc_resistance(nr, arc_tuple), direct = !_is_aggregate(branch), - objective = _control_objective(branch) - quantity_limits = _quantity_limits(branch) - regulated_bus = _regulated_bus(branch) + control = _control_objective(branch), + quantity_limits = _quantity_limits(branch), + regulated_number = _regulated_number(branch), ) end +_tap_controlled(g::BranchGeometry) = _tap_controlled(g.control) +_voltage_controlled(g::BranchGeometry) = _voltage_controlled(g.control) +_reactive_controlled(g::BranchGeometry) = _reactive_controlled(g.control) """ -Per-branch network geometry for the native nodal constraint builders. - -One geometry per arc of `T` not yet claimed for the constraint family `C` — the -representative axis from [`get_branch_argument_constraint_axis`](@ref) — with PNM's +One [`BranchGeometry`](@ref) per arc of `T` not yet claimed for the constraint family `C` — +the representative axis from [`get_branch_argument_constraint_axis`](@ref) — with PNM's reduction-aware equivalent admittance. Every member of a reduced arc (series segments, parallel groups, across branch types) @@ -1270,7 +1303,7 @@ function _branch_geometries( arc_map = get_name_to_arc_map_entries(nr, T) all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(nr) geoms = BranchGeometry[ - _branch_geometry( + BranchGeometry( nr, number_to_name, name, @@ -1603,16 +1636,16 @@ function _voltage_products( to_bus::String, t::Int, ) + jump_model = get_jump_model(container) vm = get_variable(container, VoltageMagnitude, PSY.ACBus) va = get_variable(container, VoltageAngle, PSY.ACBus) - vmf = vm[from_bus, t] - vmt = vm[to_bus, t] - θ = va[from_bus, t] - va[to_bus, t] + vmf, vmt = vm[from_bus, t], vm[to_bus, t] + vaf, vat = va[from_bus, t], va[to_bus, t] return ( - v2_fr = vmf^2, - v2_to = vmt^2, - vv_cos = vmf * vmt * cos(θ), - vv_sin = vmf * vmt * sin(θ), + v2_fr = JuMP.@expression(jump_model, vmf^2), + v2_to = JuMP.@expression(jump_model, vmt^2), + vv_cos = JuMP.@expression(jump_model, vmf * vmt * cos(vaf - vat)), + vv_sin = JuMP.@expression(jump_model, vmf * vmt * sin(vaf - vat)), ) end @@ -1625,17 +1658,16 @@ function _voltage_products( to_bus::String, t::Int, ) + jump_model = get_jump_model(container) vr = get_variable(container, VoltageReal, PSY.ACBus) vi = get_variable(container, VoltageImaginary, PSY.ACBus) - vr_fr = vr[from_bus, t] - vr_to = vr[to_bus, t] - vi_fr = vi[from_bus, t] - vi_to = vi[to_bus, t] + vr_fr, vr_to = vr[from_bus, t], vr[to_bus, t] + vi_fr, vi_to = vi[from_bus, t], vi[to_bus, t] return ( - v2_fr = vr_fr^2 + vi_fr^2, - v2_to = vr_to^2 + vi_to^2, - vv_cos = vr_fr * vr_to + vi_fr * vi_to, - vv_sin = vi_fr * vr_to - vr_fr * vi_to, + v2_fr = JuMP.@expression(jump_model, vr_fr^2 + vi_fr^2), + v2_to = JuMP.@expression(jump_model, vr_to^2 + vi_to^2), + vv_cos = JuMP.@expression(jump_model, vr_fr * vr_to + vi_fr * vi_to), + vv_sin = JuMP.@expression(jump_model, vi_fr * vr_to - vr_fr * vi_to), ) end @@ -1648,31 +1680,31 @@ function _voltage_products( to_bus::String, t::Int, ) where {T <: PSY.ACTransmission} + jump_model = get_jump_model(container) va = get_variable(container, VoltageAngle, PSY.ACBus) phi = get_variable(container, VoltageDeviation, PSY.ACBus) cs = get_variable(container, CosineApproximation, T) - phi_fr = phi[from_bus, t] - phi_to = phi[to_bus, t] + phi_fr, phi_to = phi[from_bus, t], phi[to_bus, t] return ( - v2_fr = 1.0 + 2.0 * phi_fr, - v2_to = 1.0 + 2.0 * phi_to, - vv_cos = cs[name, t] + phi_fr + phi_to, - vv_sin = va[from_bus, t] - va[to_bus, t], + v2_fr = JuMP.@expression(jump_model, 1.0 + 2.0 * phi_fr), + v2_to = JuMP.@expression(jump_model, 1.0 + 2.0 * phi_to), + vv_cos = JuMP.@expression(jump_model, cs[name, t] + phi_fr + phi_to), + vv_sin = JuMP.@expression(jump_model, va[from_bus, t] - va[to_bus, t]), ) end # Ybus terms, supporting Float64 and VariableRef taps. PNM's ybus functions # use imaginary numbers which VariableRef doesn't support. -function _tapped_admittance(adm, tap) - cs = cos(adm.shift) - sn = sin(adm.shift) +function _tapped_admittance(jump_model, adm, tap) + g_cos, g_sin = adm.g * cos(adm.shift), adm.g * sin(adm.shift) + b_cos, b_sin = adm.b * cos(adm.shift), adm.b * sin(adm.shift) return ( - g11 = adm.g / tap^2 + adm.g_fr, - b11 = adm.b / tap^2 + adm.b_fr, - g12 = (-adm.g * cs + adm.b * sn) / tap, - b12 = (-adm.b * cs - adm.g * sn) / tap, - g21 = (-adm.g * cs - adm.b * sn) / tap, - b21 = (adm.g * sn - adm.b * cs) / tap, + g11 = JuMP.@expression(jump_model, adm.g / tap^2 + adm.g_fr), + b11 = JuMP.@expression(jump_model, adm.b / tap^2 + adm.b_fr), + g12 = JuMP.@expression(jump_model, (-g_cos + b_sin) / tap), + b12 = JuMP.@expression(jump_model, (-b_cos - g_sin) / tap), + g21 = JuMP.@expression(jump_model, (-g_cos - b_sin) / tap), + b21 = JuMP.@expression(jump_model, (g_sin - b_cos) / tap), g22 = adm.g + adm.g_to, b22 = adm.b + adm.b_to, ) @@ -1698,8 +1730,6 @@ function add_constraints!( qft = get_variable(container, FlowReactivePowerFromToVariable, T) qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - tap_var = get_attribute(device_model, ENABLE_CONTROLS_KEY) ? get_variable(container, TapRatioVariable, T) : nothing - number_to_name = _retained_number_to_name(sys, network_model) geoms = _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) @@ -1717,14 +1747,14 @@ function add_constraints!( for t in time_steps vp = _voltage_products(container, network_model, T, name, from_bus, to_bus, t) - tap = g_geom.control in TAP_CONTROL_OBJECTIVES ? tap_var[name, t] : adm.tap - y = _tapped_admittance(adm, tap) + tap = _tap_controlled(device_model, g_geom) ? get_variable(container, TapRatioVariable, T)[name, t] : adm.tap + y = _tapped_admittance(jump_model, adm, tap) cons_pft[name, t] = JuMP.@constraint( jump_model, pft[name, t] == y.g11 * vp.v2_fr + y.g12 * vp.vv_cos + y.b12 * vp.vv_sin + - p_slack_term(slacks.p_ft, name, t) + _slack_term(slacks.p_ft, name, t) ) cons_ptf[name, t] = JuMP.@constraint( jump_model, @@ -1745,12 +1775,8 @@ function add_constraints!( cons_qft[name, t] = JuMP.@constraint(jump_model, qft[name, t] == qft_expr) cons_qtf[name, t] = JuMP.@constraint(jump_model, qtf[name, t] == qtf_expr) - if g_geom.control === PSY.TransformerControlObjective.VOLTAGE - voltage = g_geom.regulated_bus == :from ? vp.v2_fr : vp.v2_to - JuMP.@constraint(jump_model, voltage >= g_geom.quantity_limits.min) - JuMP.@constraint(jump_model, voltage <= g_geom.quantity_limits.max) - end - if g_geom.control === PSY.TransformerControlObjective.REACTIVE_POWER_FLOW + # TODO: register in containers + if _reactive_controlled(device_model, g_geom) JuMP.@constraint(jump_model, qft_expr >= g_geom.quantity_limits.min) JuMP.@constraint(jump_model, qft_expr <= g_geom.quantity_limits.max) JuMP.@constraint(jump_model, qtf_expr >= g_geom.quantity_limits.min) @@ -1761,6 +1787,62 @@ function add_constraints!( return end +_voltage_magnitude(::Type{<:Union{ACPNetworkModel, IVRNetworkModel}}) = VoltageMagnitude +_voltage_magnitude(_) = RegulatedVoltageMagnitude + +# TODO: It might benefit solvers to reset VariableRef bounds instead of using singleton constraints (or even to use fix()), but then we lose the dual. Worth it? +function _add_voltage_control_constraints!( + container::OptimizationContainer, + sys::PSY.System, + devices::IS.FlattenIteratorWrapper{T}, + device_model::DeviceModel{T}, + ::NetworkModel{N} +) where {T <: Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer}, N <: AbstractNetworkModel} + _control_enabled(device_model) || return + + cons = add_constraints_container!( + container, + VoltageMagnitudeConstraint, + T, + String[], + Int[], + Int[]; + sparse = true + ) + vm = get_variable(container, _voltage_magnitude(N), PSY.ACBus) + + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + for d in devices + for (i, circuit) in enumerate(PSY.get_circuits(d)) + _voltage_controlled(device_model, circuit) || continue + circuit_name = _circuit_arc_name(d, circuit, i) + + bus = PSY.get_bus(sys, PSY.get_regulated_bus_number(circuit)) + bus_name = PSY.get_name(bus) + bus_limits = PSY.get_voltage_limits(bus) + ctl_limits = PSY.get_controlled_quantity_limits(circuit) + + # TODO: temporary pending PSY#1755 + (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error("Bus limits for $bus_name disagree with control limits for circuit $circuit_name.") + + for t in time_steps + cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[bus_name, t] >= ctl_limits.min) + cons[circuit_name, 2, t] = JuMP.@constraint(jump_model, vm[bus_name, t] <= ctl_limits.max) + end + end + end + return +end + +_add_voltage_control_constraints!( + ::OptimizationContainer, + ::PSY.System, + ::IS.FlattenIteratorWrapper{T}, + ::DeviceModel{T}, + ::NetworkModel, +) where {T} = nothing + ################################## LPACCNetworkModel branch constraints ############### # Branch voltage-angle-difference bounds (angmin, angmax). Only Line / MonitoredLine @@ -1909,6 +1991,22 @@ end ################################## IVRNetworkModel branch constraints ################## +_branch_arc(d::PSY.ACTransmission) = PSY.get_arc(d) +_branch_arc(d::PSY.TwoWindingTransformer) = PSY.get_arc(PSY.get_circuit(d)) + +function _min_endpoint_voltage_limit(branch::PSY.ACTransmission) + arc = _branch_arc(branch) + # bus voltage limits are already per-unit + vmin_fr = PSY.get_voltage_limits(PSY.get_from(arc)).min + vmin_to = PSY.get_voltage_limits(PSY.get_to(arc)).min + return min(vmin_fr, vmin_to) +end + +# Series segments may themselves be parallel groups; recursion bottoms out at devices. +function _min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) + return minimum(_min_endpoint_voltage_limit(member) for member in entry) +end + # Compute the per-unit current rating bound for an IVR branch variable. # c_rating_a = rate_a / vmin (system-base power / per-unit voltage → per-unit current). function _ivr_current_rating(branch::PSY.ACTransmission) @@ -1946,22 +2044,6 @@ function _ivr_current_rating( return rate_a / vmin end -_branch_arc(d::PSY.ACTransmission) = PSY.get_arc(d) -_branch_arc(d::PSY.TwoWindingTransformer) = PSY.get_arc(PSY.get_circuit(d)) - -function _min_endpoint_voltage_limit(branch::PSY.ACTransmission) - arc = _branch_arc(branch) - # bus voltage limits are already per-unit - vmin_fr = PSY.get_voltage_limits(PSY.get_from(arc)).min - vmin_to = PSY.get_voltage_limits(PSY.get_to(arc)).min - return min(vmin_fr, vmin_to) -end - -# Series segments may themselves be parallel groups; recursion bottoms out at devices. -function _min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) - return minimum(_min_endpoint_voltage_limit(member) for member in entry) -end - function add_variables!( container::OptimizationContainer, ::Type{V}, diff --git a/src/ac_transmission_models/branch_constructor.jl b/src/ac_transmission_models/branch_constructor.jl index 39326da..9a3c48b 100644 --- a/src/ac_transmission_models/branch_constructor.jl +++ b/src/ac_transmission_models/branch_constructor.jl @@ -288,7 +288,7 @@ function construct_device!( devices = get_available_components(device_model, sys) _add_static_branch_flow_variables!(container, devices, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) - _add_transformer_control_variables!(container, device_model, devices, network_model) + _add_tap_control_variables!(container, device_model, devices, network_model) return end @@ -320,7 +320,7 @@ function construct_device!( add_constraints!( container, sys, AngleDifferenceConstraint, devices, device_model, network_model, ) - _add_transformer_control_constraints!(container, device_model, devices, network_model) + _add_voltage_control_constraints!(container, sys, devices, device_model, network_model) add_feedforward_constraints!(container, device_model, devices) add_to_objective_function!(container, devices, device_model, ACPNetworkModel) add_constraint_dual!(container, sys, device_model) diff --git a/test/includes.jl b/test/includes.jl index 0fa7e74..92a254f 100644 --- a/test/includes.jl +++ b/test/includes.jl @@ -7,7 +7,7 @@ using InfrastructureSystems import InfrastructureSystems: TableFormat using PowerNetworkMatrices import PowerSystemCaseBuilder: PSITestSystems -using PowerFlows +#using PowerFlows using DataFramesMeta # Test Packages @@ -34,7 +34,7 @@ import LinearAlgebra const PSY = PowerSystems const POM = PowerOperationsModels const IOM = InfrastructureOptimizationModels -const PFS = PowerFlows +#const PFS = PowerFlows const PSB = PowerSystemCaseBuilder const PNM = PowerNetworkMatrices const ISOPT = InfrastructureSystems.Optimization diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 0eaa2d9..90ccc1b 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -26,7 +26,9 @@ function _controlled_sys14( objective; name = "Trans1", regulated = :to, - quantity_limits = (min = 0.9, max = 1.1), + # c_sys14 buses carry (0.94, 1.06) voltage limits; a VOLTAGE band has to sit inside + # the regulated bus's own limits. + quantity_limits = (min = 0.95, max = 1.05), control_limits = (min = 0.9, max = 1.1), ) sys = PSB.build_system(PSITestSystems, "c_sys14") @@ -42,7 +44,8 @@ function _controlled_sys14( end function _controlled_template(network_formulation; enable = true, kwargs...) - template = get_thermal_dispatch_template_network(NetworkModel(network_formulation; kwargs...)) + template = + get_thermal_dispatch_template_network(NetworkModel(network_formulation; kwargs...)) set_device_model!( template, DeviceModel( @@ -148,7 +151,12 @@ end function _uncontrolled_voltage(bus_name; network_formulation = ACPNetworkModel) sys = PSB.build_system(PSITestSystems, "c_sys14") model, status = - _build_controlled(sys, network_formulation; enable = false, optimizer = ipopt_optimizer) + _build_controlled( + sys, + network_formulation; + enable = false, + optimizer = ipopt_optimizer, + ) @test status == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = IOM.OptimizationProblemOutputs(model) @@ -160,7 +168,9 @@ end _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) free_vm = _uncontrolled_voltage(bus_name) # A band the free-running solution violates, so holding it requires the tap to move. - band = (min = free_vm + 0.01, max = free_vm + 0.02) + # It sits below `free_vm`: the free-running voltage rides near the bus's 1.06 upper + # limit, and the band may not reach outside the bus's own limits. + band = (min = free_vm - 0.02, max = free_vm - 0.01) sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) @@ -189,7 +199,7 @@ end @testset "VOLTAGE control regulates the from-side bus when its number is given" begin _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL; regulated = :from) free_vm = _uncontrolled_voltage(bus_name) - band = (min = free_vm + 0.01, max = free_vm + 0.02) + band = (min = free_vm - 0.02, max = free_vm - 0.01) sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; regulated = :from, quantity_limits = band) @@ -205,22 +215,6 @@ end end end -@testset "the VOLTAGE band is a voltage, not a squared voltage" begin - # A band well away from 1.0 pu separates the two readings: v ∈ [0.80, 0.82] is - # satisfied by v² ∈ [0.64, 0.67], so a builder comparing the raw band against v² - # would land the voltage near 0.9 pu instead. - band = (min = 0.80, max = 0.82) - sys, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) - @test vm[1, bus_name] >= band.min - 1e-6 - @test vm[1, bus_name] <= band.max + 1e-6 -end - ############################ REACTIVE_POWER_FLOW objective ############################# @testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its band (ACP)" begin @@ -248,39 +242,16 @@ end ################################### model invariants ################################### -@testset "the model is count-invariant across control objectives (ACP)" begin - function _container_for(objective) - sys, _, _, _ = _controlled_sys14(objective) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - return IOM.get_optimization_container(model) - end - - cv = _container_for(VOLTAGE_CONTROL) - cq = _container_for(Q_FLOW_CONTROL) - - var_v = IOM.get_variables(cv) - var_q = IOM.get_variables(cq) - @test Set(keys(var_v)) == Set(keys(var_q)) - for k in keys(var_v) - @test size(var_v[k]) == size(var_q[k]) - end - - con_v = IOM.get_constraints(cv) - con_q = IOM.get_constraints(cq) - @test Set(keys(con_v)) == Set(keys(con_q)) - for k in keys(con_v) - @test size(con_v[k]) == size(con_q[k]) - end -end - @testset "a tap pinned at nominal reproduces the uncontrolled model (ACP)" begin # White-box reduction gate: with the tap variable fixed at the circuit's nominal # ratio and a band too wide to bind, the controlled Ohm's law is term-by-term the # fixed-tap one, so both models must reach the same optimum and terminal flows. - wide = (min = 0.5, max = 1.5) + # The band is the regulated bus's own (0.94, 1.06) limits — the widest a VOLTAGE band + # may be — so the control constrains nothing the bus does not already. + band = (min = 0.94, max = 1.06) + tap_range = (min = 0.5, max = 1.5) - sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = wide) + sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) model_fixed, status_fixed = _build_controlled( sys_fixed, ACPNetworkModel; enable = false, optimizer = ipopt_optimizer, ) @@ -288,7 +259,7 @@ end @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED sys_var, transformer, circuit, _ = _controlled_sys14( - VOLTAGE_CONTROL; quantity_limits = wide, control_limits = wide, + VOLTAGE_CONTROL; quantity_limits = band, control_limits = tap_range, ) model_var, status_var = _build_controlled(sys_var, ACPNetworkModel; optimizer = ipopt_optimizer) @@ -331,7 +302,11 @@ end @test status == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(model) - pft = IOM.get_variable(container, FlowActivePowerFromToVariable, PSY.TwoWindingTransformer) + pft = IOM.get_variable( + container, + FlowActivePowerFromToVariable, + PSY.TwoWindingTransformer, + ) vm = IOM.get_variable(container, VoltageMagnitude, PSY.ACBus) va = IOM.get_variable(container, VoltageAngle, PSY.ACBus) tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) @@ -354,7 +329,7 @@ end ) lookup = z -> vals[z] - y = POM._tapped_admittance(adm, vals[tap[name, t]]) + y = POM._tapped_admittance(get_jump_model(container), adm, vals[tap[name, t]]) vmf = vals[vm[fr, t]] vmt = vals[vm[to, t]] θ = vals[va[fr, t]] - vals[va[to, t]] @@ -389,7 +364,11 @@ end ) magnitude = 1.0 + phi[1, bus_name] else - vr = read_variable(res, "VoltageReal__ACBus"; table_format = TableFormat.WIDE) + vr = read_variable( + res, + "VoltageReal__ACBus"; + table_format = TableFormat.WIDE, + ) vi = read_variable( res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE, ) @@ -434,21 +413,26 @@ end @testset "a controlled circuit merged with a parallel branch fails with a clear error" begin # PNM collapses parallel branches onto one equivalent arc before POM sees them, which # would leave the control acting on a flow that is not the transformer's own. - sys, transformer, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - arc = PSY.get_arc(PSY.get_circuit(transformer)) + # Trans1's arc spans a voltage change, so the parallel branch has to be another + # transformer: PSY rejects a Line whose endpoints differ in base voltage. + sys, transformer, circuit, _ = _controlled_sys14(VOLTAGE_CONTROL) + arc = PSY.get_arc(circuit) PSY.add_component!( sys, - PSY.Line(; + PSY.TwoWindingTransformer(; name = "parallel_to_Trans1", - available = true, - active_power_flow = 0.0, - reactive_power_flow = 0.0, - arc = arc, - r = 0.01, - x = 0.1, - b = (from = 0.0, to = 0.0), - rating = 2.0, - angle_limits = (min = -π / 2, max = π / 2), + circuit = PSY.TransformerCircuit(; + available = true, + arc = arc, + r = PSY.get_r(circuit, PSY.SU), + x = PSY.get_x(circuit, PSY.SU), + tap = 1.0, + α = 0.0, + rating = PSY.get_rating(circuit, PSY.SU), + base_power = PSY.get_base_power(sys, PSY.NU), + ), + magnetizing_shunt = 0.0 + 0.0im, + shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, ), ) template = _controlled_template(ACPNetworkModel) @@ -476,65 +460,88 @@ function _case11_with_forecast() return sys end -@testset "a controlled circuit absorbed by a series reduction fails with a clear error" begin - # "1-6-i_1" is one segment of the (1,2) series chain, so under reduction it has no - # direct-branch entry of its own and its tap would control an equivalent arc it only - # partly owns. - sys = _case11_with_forecast() - line = PSY.get_component(PSY.Line, sys, "1-6-i_1") - arc = PSY.get_arc(line) - transformer = PSY.TwoWindingTransformer(; - name = PSY.get_name(line), - circuit = PSY.TransformerCircuit(; - available = true, - arc = arc, - r = PSY.get_r(line, PSY.SU), - x = PSY.get_x(line, PSY.SU), - tap = 1.0, - α = 0.0, - rating = PSY.get_rating(line, PSY.SU), - control_objective = VOLTAGE_CONTROL, - regulated_bus_number = PSY.get_number(PSY.get_to(arc)), - control_limits = (min = 0.9, max = 1.1), - controlled_quantity_limits = (min = 0.95, max = 1.05), - base_power = PSY.get_base_power(sys, PSY.NU), - ), - magnetizing_shunt = 0.0 + 0.0im, - shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, - ) - PSY.remove_component!(sys, line) - PSY.add_component!(sys, transformer) +@testset "StaticBranch models transformer off-nominal tap under DCP (c_sys14)" begin + sys = PSB.build_system(PSITestSystems, "c_sys14") + template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) + set_device_model!(template, PSY.TwoWindingTransformer, StaticBranch) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - template = _controlled_template( - ACPNetworkModel; - reduce_radial_branches = true, - reduce_degree_two_branches = true, - ) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - out = mktempdir(; cleanup = true) - @test build!(model; output_dir = out, console_level = Logging.Error) == - IOM.ModelBuildStatus.FAILED - @test occursin( - "Controlled transformer circuit", - read(joinpath(out, "operation_problem.log"), String), + res = IOM.OptimizationProblemOutputs(model) + base = IOM.get_model_base_power(res) + # StaticBranch under DCP has no FlowActivePowerVariable: the flow IS the + # BThetaBranchFlow expression, reported in natural units (MW). VoltageAngle is + # unitless (radians, no conversion), so compare in per-unit. + pflow = read_expression( + res, + "BThetaBranchFlow__TwoWindingTransformer"; + table_format = TableFormat.WIDE, ) + va = read_variable(res, "VoltageAngle__ACBus"; table_format = TableFormat.WIDE) + + tested_a_real_tap = false + for tr in PSY.get_components(PSY.TwoWindingTransformer, sys) + name = PSY.get_name(tr) + @test name in names(pflow) + + # Recover the series reactance independently, from the π-model admittance, so the + # oracle does not simply re-call the susceptance helper the source uses. + adm = PNM.branch_admittance(tr) + x = -adm.b / (adm.g^2 + adm.b^2) + # The DC susceptance is tap-divided: b_dc == 1/(tap*x). Pin the equivalence of the + # independent recovery and PNM's DC entry point. + @test 1 / (x * adm.tap) ≈ PNM.get_series_susceptance(tr, PSY.SU) + + arc = PSY.get_arc(PSY.get_circuit(tr)) + fr = PSY.get_name(PSY.get_from(arc)) + to = PSY.get_name(PSY.get_to(arc)) + shift = PNM.get_series_phase_shift(tr) + if !isapprox(adm.tap, 1.0; atol = 1e-6) + tested_a_real_tap = true + end + for r in 1:nrow(pflow) + p_pu = pflow[r, name] / base + expected = (va[r, fr] - va[r, to] - shift) / (x * adm.tap) + @test isapprox(p_pu, expected; atol = 1e-5) + end + end + # Guard: the test system must actually carry a non-unit tap, else this proves nothing. + @test tested_a_real_tap end -@testset "ACP rejects two transformers regulating the same bus" begin - # Trans1 (4 → 9) and Trans3 (4 → 7) share bus 4. Under ACP both would drive the one - # shared VoltageMagnitude, so the conflicting bands must be caught at validation. +@testset "_tapped_admittance round-trips PNM.ybus_branch_entries" begin + function check_terms(y, ybus) + Y11, Y12, Y21, Y22 = ybus + @test isapprox(complex(y.g11, y.b11), Y11; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g12, y.b12), Y12; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g21, y.b21), Y21; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g22, y.b22), Y22; rtol = 1e-10, atol = 1e-12) + end + + model = JuMP.Model() sys = PSB.build_system(PSITestSystems, "c_sys14") - for name in ("Trans1", "Trans3") - circuit = PSY.get_circuit(PSY.get_component(PSY.TwoWindingTransformer, sys, name)) - PSY.set_control_objective!(circuit, VOLTAGE_CONTROL) - PSY.set_regulated_bus_number!(circuit, PSY.get_number(PSY.get_from(PSY.get_arc(circuit)))) - PSY.set_controlled_quantity_limits!(circuit, (min = 0.98, max = 1.02)) + for br in Iterators.flatten(( + PSY.get_components(PSY.Line, sys), + PSY.get_components(PSY.TwoWindingTransformer, sys), + )) + adm = PNM.branch_admittance(br) + check_terms(POM._tapped_admittance(model, adm, adm.tap), PNM.ybus_branch_entries(br)) end - template = _controlled_template(ACPNetworkModel) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - @test_throws IS.ConflictingInputsError POM.validate_template(model) -end + tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") + circuit = PSY.get_circuit(tr) + for shift in (-pi / 5, 0.0, pi / 6) + PSY.set_α!(circuit, shift) + PSY.set_tap!(circuit, 1.0) + adm = PNM.branch_admittance(tr) + for tap in (0.9, 1.0, 1.1, 1.25) + PSY.set_tap!(circuit, tap) + check_terms(POM._tapped_admittance(model, adm, tap), PNM.ybus_branch_entries(tr)) + end + end +end ######################################################################################### # Phase control (the ACTIVE_POWER_FLOW / ASYMMETRIC_ACTIVE_POWER_FLOW objectives, where the # phase shift α rather than the tap ratio is the decision variable) is NOT supported yet. From 97785c1bba0b8dc78349d725acdbc0526096b579 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 14 Aug 2026 23:08:41 -0400 Subject: [PATCH 07/19] claude finished the impl --- src/PowerOperationsModels.jl | 1 + src/ac_transmission_models/AC_branches.jl | 248 ++++++++---- .../branch_constructor.jl | 19 +- src/core/constraints.jl | 7 + src/core/network_formulations.jl | 23 -- test/test_transformer_controls.jl | 375 ++++++++++-------- test/test_transformer_fixed_tap.jl | 11 +- 7 files changed, 426 insertions(+), 258 deletions(-) diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 1795d54..cd5b7b6 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -777,6 +777,7 @@ export ActiveRangeICConstraint export NodalBalanceActiveConstraint export ReferenceBusConstraint export VoltageMagnitudeConstraint +export ReactivePowerFlowControlConstraint export RegulatedVoltageMagnitudeConstraint export CurrentLimitConstraint export AngleDifferenceConstraint diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 7e5076a..1b23f5e 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -69,24 +69,37 @@ function get_default_time_series_names( return Dict{Type{<:TimeSeriesParameter}, String}() end -ENABLE_CONTROLS_KEY = "enable_controls" +const ENABLE_CONTROLS_KEY = "enable_controls" _control_attribute( ::Union{Type{PSY.TwoWindingTransformer}, Type{PSY.ThreeWindingTransformer}}, ) = (ENABLE_CONTROLS_KEY => false,) _control_attribute(_) = () -_control_enabled(m::Union{DeviceModel{PSY.TwoWindingTransformer}, DeviceModel{PSY.ThreeWindingTransformer}}) = get_attribute(m, ENABLE_CONTROLS_KEY) === true -_control_enabled(c::PSY.TransformerCircuit) = PSY.get_available(c) && !(PSY.get_control_objective(c) in (PSY.TransformerControlObjective.UNDEFINED, PSY.TransformerControlObjective.FIXED)) +_TRANSFORMERS = Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer} + +_control_enabled(m::DeviceModel{<:_TRANSFORMERS}) = + get_attribute(m, ENABLE_CONTROLS_KEY) === true +_control_enabled(c::PSY.TransformerCircuit) = + PSY.get_available(c) && !(PSY.get_control_objective(c) in (PSY.TransformerControlObjective.UNDEFINED, PSY.TransformerControlObjective.FIXED)) _control_enabled(_) = false -_tap_controlled(c::PSY.TransformerControlObjective) = c in (PSY.TransformerControlObjective.VOLTAGE, PSY.TransformerControlObjective.REACTIVE_POWER_FLOW) -_tap_controlled(c::PSY.TransformerCircuit) = PSY.get_available(c) && _tap_controlled(PSY.get_control_objective(c)) +_tap_controlled(c::PSY.TransformerControlObjective) = c in ( + PSY.TransformerControlObjective.VOLTAGE, + PSY.TransformerControlObjective.REACTIVE_POWER_FLOW, +) +_tap_controlled(c::PSY.TransformerCircuit) = + PSY.get_available(c) && _tap_controlled(PSY.get_control_objective(c)) -_voltage_controlled(c::PSY.TransformerControlObjective) = c === PSY.TransformerControlObjective.VOLTAGE -_voltage_controlled(c::PSY.TransformerCircuit) = PSY.get_available(c) && _voltage_controlled(PSY.get_control_objective(c)) +_voltage_controlled(c::PSY.TransformerControlObjective) = + c === PSY.TransformerControlObjective.VOLTAGE +_voltage_controlled(c::PSY.TransformerCircuit) = + PSY.get_available(c) && _voltage_controlled(PSY.get_control_objective(c)) -_reactive_controlled(c::PSY.TransformerControlObjective) = c === PSY.TransformerControlObjective.REACTIVE_POWER_FLOW +_reactive_controlled(c::PSY.TransformerControlObjective) = + c === PSY.TransformerControlObjective.REACTIVE_POWER_FLOW +_reactive_controlled(c::PSY.TransformerCircuit) = + PSY.get_available(c) && _reactive_controlled(PSY.get_control_objective(c)) _tap_controlled(m::DeviceModel, d) = _control_enabled(m) && _tap_controlled(d) _voltage_controlled(m::DeviceModel, d) = _control_enabled(m) && _voltage_controlled(d) @@ -309,6 +322,10 @@ _add_tap_control_variables!( ::NetworkModel, ) = nothing +_warn_tap_control_nonconvexity(::NetworkModel{N}) where {N <: Union{LPACCNetworkModel, DCPNetworkModel, DCPLLNetworkModel}} = + @warn "Tap control makes $N network models non-convex. Use Ipopt or change circuit controls." +_warn_tap_control_nonconvexity(_) = nothing + function _add_tap_control_variables!( container::OptimizationContainer, model::DeviceModel{U, F}, @@ -319,6 +336,8 @@ function _add_tap_control_variables!( F <: AbstractBranchFormulation, } get_attribute(model, ENABLE_CONTROLS_KEY) === true || return + _warn_tap_control_nonconvexity(network_model) + names = String[] circuits = PSY.TransformerCircuit[] for d in devices, (i, c) in enumerate(PSY.get_circuits(d)) @@ -1220,8 +1239,11 @@ _get_circuit(_) = nothing _control_objective(branch) = _control_objective(_get_circuit(branch)) _control_objective(::Nothing) = PSY.TransformerControlObjective.UNDEFINED _control_objective(c::PSY.TransformerCircuit) = - PSY.get_available(c) ? PSY.get_control_objective(c) : - PSY.TransformerControlObjective.UNDEFINED + if PSY.get_available(c) + PSY.get_control_objective(c) + else + PSY.TransformerControlObjective.UNDEFINED + end _quantity_limits(branch) = _quantity_limits(_get_circuit(branch)) _quantity_limits(::Nothing) = (min = -Inf, max = Inf) @@ -1248,7 +1270,6 @@ Base.@kwdef struct BranchGeometry control::PSY.TransformerControlObjective quantity_limits::MinMax regulated_number::Int - end function BranchGeometry( @@ -1747,7 +1768,11 @@ function add_constraints!( for t in time_steps vp = _voltage_products(container, network_model, T, name, from_bus, to_bus, t) - tap = _tap_controlled(device_model, g_geom) ? get_variable(container, TapRatioVariable, T)[name, t] : adm.tap + tap = if _tap_controlled(device_model, g_geom) + get_variable(container, TapRatioVariable, T)[name, t] + else + adm.tap + end y = _tapped_admittance(jump_model, adm, tap) cons_pft[name, t] = JuMP.@constraint( @@ -1762,42 +1787,41 @@ function add_constraints!( y.g22 * vp.v2_to + y.g21 * vp.vv_cos - y.b21 * vp.vv_sin + _slack_term(slacks.p_tf, name, t), ) - qft_expr = JuMP.@expression( + cons_qft[name, t] = JuMP.@constraint( jump_model, + qft[name, t] == -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + _slack_term(slacks.q_ft, name, t), ) - qtf_expr = JuMP.@expression( + cons_qtf[name, t] = JuMP.@constraint( jump_model, + qtf[name, t] == -y.b22 * vp.v2_to - y.b21 * vp.vv_cos - y.g21 * vp.vv_sin + _slack_term(slacks.q_tf, name, t), ) - cons_qft[name, t] = JuMP.@constraint(jump_model, qft[name, t] == qft_expr) - cons_qtf[name, t] = JuMP.@constraint(jump_model, qtf[name, t] == qtf_expr) - - # TODO: register in containers - if _reactive_controlled(device_model, g_geom) - JuMP.@constraint(jump_model, qft_expr >= g_geom.quantity_limits.min) - JuMP.@constraint(jump_model, qft_expr <= g_geom.quantity_limits.max) - JuMP.@constraint(jump_model, qtf_expr >= g_geom.quantity_limits.min) - JuMP.@constraint(jump_model, qtf_expr <= g_geom.quantity_limits.max) - end end end return end -_voltage_magnitude(::Type{<:Union{ACPNetworkModel, IVRNetworkModel}}) = VoltageMagnitude -_voltage_magnitude(_) = RegulatedVoltageMagnitude +_voltage_magnitude(container, name, ::NetworkModel{ACPNetworkModel}) = + get_variable(container, VoltageMagnitude, PSY.ACBus)[name, :] +_voltage_magnitude(container, name, ::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}) = + JuMP.@expression(get_jump_model(container), [t in get_time_steps(container)], get_variable(container, VoltageReal, PSY.ACBus)[name, t]^2 + get_variable(container, VoltageImaginary, PSY.ACBus)[name, t]^2) +_voltage_magnitude(container, name, ::NetworkModel{LPACCNetworkModel}) = + get_variable(container, VoltageDeviation, PSY.ACBus)[name, :] + +_voltage_limits(limits, ::NetworkModel{ACPNetworkModel}) = limits +_voltage_limits(limits, ::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}) = (min = limits.min^2, max = limits.max^2) +_voltage_limits(limits, ::NetworkModel{LPACCNetworkModel}) = (min = limits.min - 1, max = limits.max - 1) -# TODO: It might benefit solvers to reset VariableRef bounds instead of using singleton constraints (or even to use fix()), but then we lose the dual. Worth it? function _add_voltage_control_constraints!( container::OptimizationContainer, sys::PSY.System, devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T}, - ::NetworkModel{N} -) where {T <: Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer}, N <: AbstractNetworkModel} + ::NetworkModel{<:NativeACNetworkModel} +) where {T <: _TRANSFORMER_CONTROL_TYPES} _control_enabled(device_model) || return cons = add_constraints_container!( @@ -1807,28 +1831,28 @@ function _add_voltage_control_constraints!( String[], Int[], Int[]; - sparse = true + sparse = true, ) - vm = get_variable(container, _voltage_magnitude(N), PSY.ACBus) time_steps = get_time_steps(container) jump_model = get_jump_model(container) for d in devices for (i, circuit) in enumerate(PSY.get_circuits(d)) _voltage_controlled(device_model, circuit) || continue - circuit_name = _circuit_arc_name(d, circuit, i) bus = PSY.get_bus(sys, PSY.get_regulated_bus_number(circuit)) bus_name = PSY.get_name(bus) bus_limits = PSY.get_voltage_limits(bus) ctl_limits = PSY.get_controlled_quantity_limits(circuit) - # TODO: temporary pending PSY#1755 + circuit_name = _circuit_arc_name(d, circuit, i) (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error("Bus limits for $bus_name disagree with control limits for circuit $circuit_name.") + lims = _voltage_limits(ctl_limits) + vm = _voltage_magnitude(container, bus_name, network_model) for t in time_steps - cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[bus_name, t] >= ctl_limits.min) - cons[circuit_name, 2, t] = JuMP.@constraint(jump_model, vm[bus_name, t] <= ctl_limits.max) + cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[t] >= lims.min) + cons[circuit_name, 2, t] = JuMP.@constraint(jump_model, vm[t] <= lims.max) end end end @@ -1843,6 +1867,68 @@ _add_voltage_control_constraints!( ::NetworkModel, ) where {T} = nothing +function _add_reactive_control_constraints!( + container::OptimizationContainer, + devices::IS.FlattenIteratorWrapper{T}, + device_model::DeviceModel{T}, + ::NetworkModel{<:NativeACNetworkModel}, +) where {T <: _TRANSFORMER_CONTROL_TYPES} + _control_enabled(device_model) || return + + cons = add_constraints_container!( + container, + ReactivePowerFlowControlConstraint, + T, + String[], + Int[], + Int[]; + sparse = true, + ) + qft = get_variable(container, FlowReactivePowerFromToVariable, T) + qtf = get_variable(container, FlowReactivePowerToFromVariable, T) + + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + for d in devices + for (i, circuit) in enumerate(PSY.get_circuits(d)) + _reactive_controlled(device_model, circuit) || continue + name = _circuit_arc_name(d, circuit, i) + lims = PSY.get_controlled_quantity_limits(circuit) + + for t in time_steps + cons[name, 1, t] = + JuMP.@constraint(jump_model, qft[name, t] >= lims.min) + cons[name, 2, t] = + JuMP.@constraint(jump_model, qft[name, t] <= lims.max) + cons[name, 3, t] = + JuMP.@constraint(jump_model, qtf[name, t] >= lims.min) + cons[name, 4, t] = + JuMP.@constraint(jump_model, qtf[name, t] <= lims.max) + end + end + end + return +end + +_add_reactive_control_constraints!( + ::OptimizationContainer, + ::IS.FlattenIteratorWrapper{T}, + ::DeviceModel{T}, + ::NetworkModel, +) where {T} = nothing + +function _add_transformer_control_constraints!( + container::OptimizationContainer, + sys::PSY.System, + devices::IS.FlattenIteratorWrapper{T}, + device_model::DeviceModel{T}, + network_model::NetworkModel, +) where {T <: PSY.ACTransmission} + _add_voltage_control_constraints!(container, sys, devices, device_model, network_model) + _add_reactive_control_constraints!(container, devices, device_model, network_model) + return +end + ################################## LPACCNetworkModel branch constraints ############### # Branch voltage-angle-difference bounds (angmin, angmax). Only Line / MonitoredLine @@ -2118,6 +2204,7 @@ Ten constraints per branch per time step: (9-10) Ohm's law across series impedance Z = r + jx = 1/(g + jb) (linear): vr_to·tm² = vr_fr·tr + vi_fr·ti - r·csr·tm² + x·csi·tm² vi_to·tm² = vi_fr·tr - vr_fr·ti - r·csi·tm² - x·csr·tm² + """ function add_constraints!( container::OptimizationContainer, @@ -2181,6 +2268,12 @@ function add_constraints!( jump_model = get_jump_model(container) slacks = _flow_equality_slacks(container, device_model, T) cslacks = _current_equality_slacks(container, device_model, T) + tap_var = + if has_container_key(container, TapRatioVariable, T) + get_variable(container, TapRatioVariable, T) + else + nothing + end for g_geom in geoms name = g_geom.name adm = g_geom.adm @@ -2190,20 +2283,20 @@ function add_constraints!( b_fr = adm.b_fr g_to = adm.g_to b_to = adm.b_to - tm = adm.tap from_bus = g_geom.from_name to_bus = g_geom.to_name - tr = tm * cos(adm.shift) - ti = tm * sin(adm.shift) - tm2 = tm^2 - # Series impedance Z = r + jx = conj(y)/|y|² ymag2 = g^2 + b^2 r = g / ymag2 x = -b / ymag2 for t in time_steps + tm = _tap_controlled(device_model, g_geom) ? tap_var[name, t] : adm.tap + tr = tm * cos(adm.shift) + ti = tm * sin(adm.shift) + tm2 = tm^2 + vr_f = vr[from_bus, t] vi_f = vi[from_bus, t] vr_t = vr[to_bus, t] @@ -2488,15 +2581,6 @@ function add_constraints!( return end -""" -Add branch Ohm's law (DC power flow) constraint for ACBranch under DCPNetworkModel: - - p_fr == b * (va_fr - va_to - shift) - -where `b` is the DC series susceptance `1/(a·x)` and `shift` is the DC phase-shift angle -(0 for non-PST branches) — the same pair PNM's `BA_Matrix` and `arc_dc_shift_injection` -use, not the π-recovery `adm.b`/`adm.shift`. -""" function add_constraints!( container::OptimizationContainer, sys::PSY.System, @@ -2517,25 +2601,40 @@ function add_constraints!( container, NetworkFlowConstraint, T, branch_names, time_steps, ) - # StaticBranchBounds relaxes the rating by slacking this defining equality: the bounded - # decision flow `p` stays within rating while the physical angle-implied flow may deviate - # by the signed slack. StaticBranch never reaches this method (it carries flow as the - # BThetaBranchFlow expression); only SBB does, so the slacks exist iff use_slacks. use_slacks = get_use_slacks(device_model) if use_slacks slack_ub = get_variable(container, FlowActivePowerSlackUpperBound, T) slack_lb = get_variable(container, FlowActivePowerSlackLowerBound, T) end - for g in geoms - for t in time_steps - rhs = g.b_dc * (va[g.from_name, t] - va[g.to_name, t] - g.shift_dc) + jump_model = get_jump_model(container) + tap_var = + if has_container_key(container, TapRatioVariable, T) + get_variable(container, TapRatioVariable, T) + else + nothing + end + + for g in geoms, t in time_steps + angle = va[g.from_name, t] - va[g.to_name, t] - g.shift_dc + flow = if use_slacks - rhs += slack_ub[g.name, t] - slack_lb[g.name, t] + JuMP.@expression( + jump_model, + p[g.name, t] - slack_ub[g.name, t] + slack_lb[g.name, t] + ) + else + p[g.name, t] + end + cons[g.name, t] = + if _tap_controlled(device_model, g) + JuMP.@constraint( + jump_model, + flow * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle + ) + else + JuMP.@constraint(jump_model, flow == g.b_dc * angle) end - cons[g.name, t] = - JuMP.@constraint(get_jump_model(container), p[g.name, t] == rhs) - end end return end @@ -2913,13 +3012,6 @@ function add_constraints!( return end -""" -Add the DC Ohm's law for the from-to directional flow under DCPLLNetworkModel: - - p_fr == b * (va_fr - va_to - shift) - -identical to the DCP law; the to-from flow is determined by the quadratic loss constraint. -""" function add_constraints!( container::OptimizationContainer, sys::PSY.System, @@ -2941,14 +3033,24 @@ function add_constraints!( ) jump_model = get_jump_model(container) - for g in geoms - for t in time_steps - cons[g.name, t] = JuMP.@constraint( + tap_var = + if has_container_key(container, TapRatioVariable, T) + get_variable(container, TapRatioVariable, T) + else + nothing + end + + for g in geoms, t in time_steps + angle = va[g.from_name, t] - va[g.to_name, t] - g.shift_dc + cons[g.name, t] = + if _tap_controlled(device_model, g) + JuMP.@constraint( jump_model, - pft[g.name, t] == - g.b_dc * (va[g.from_name, t] - va[g.to_name, t] - g.shift_dc), + pft[g.name, t] * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle ) - end + else + JuMP.@constraint(jump_model, pft[g.name, t] == g.b_dc * angle) + end end return end diff --git a/src/ac_transmission_models/branch_constructor.jl b/src/ac_transmission_models/branch_constructor.jl index 9a3c48b..a2b625d 100644 --- a/src/ac_transmission_models/branch_constructor.jl +++ b/src/ac_transmission_models/branch_constructor.jl @@ -320,7 +320,9 @@ function construct_device!( add_constraints!( container, sys, AngleDifferenceConstraint, devices, device_model, network_model, ) - _add_voltage_control_constraints!(container, sys, devices, device_model, network_model) + _add_transformer_control_constraints!( + container, sys, devices, device_model, network_model, + ) add_feedforward_constraints!(container, device_model, devices) add_to_objective_function!(container, devices, device_model, ACPNetworkModel) add_constraint_dual!(container, sys, device_model) @@ -415,6 +417,7 @@ function construct_device!( devices = get_available_components(device_model, sys) _add_static_branch_flow_variables!(container, devices, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) + _add_tap_control_variables!(container, device_model, devices, network_model) return end @@ -447,6 +450,9 @@ function construct_device!( add_constraints!( container, sys, AngleDifferenceConstraint, devices, device_model, network_model, ) + _add_transformer_control_constraints!( + container, sys, devices, device_model, network_model, + ) add_feedforward_constraints!(container, device_model, devices) add_to_objective_function!(container, devices, device_model, ACRNetworkModel) add_constraint_dual!(container, sys, device_model) @@ -509,6 +515,7 @@ ArgumentConstructStage for StaticBranch under LPACCNetworkModel. Creates the four directional flow variables, the bus-pair cosine variable (cs), optional slacks, and registers each flow's contribution to the per-bus ActivePowerBalance and ReactivePowerBalance expressions. + """ function construct_device!( container::OptimizationContainer, @@ -523,6 +530,7 @@ function construct_device!( _add_static_branch_flow_variables!(container, devices, network_model) add_variables!(container, CosineApproximation, devices, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) + _add_tap_control_variables!(container, device_model, devices, network_model) return end @@ -557,6 +565,9 @@ function construct_device!( add_constraints!( container, sys, AngleDifferenceConstraint, devices, device_model, network_model, ) + _add_transformer_control_constraints!( + container, sys, devices, device_model, network_model, + ) add_feedforward_constraints!(container, device_model, devices) add_to_objective_function!(container, devices, device_model, LPACCNetworkModel) add_constraint_dual!(container, sys, device_model) @@ -697,6 +708,7 @@ function construct_device!( network_model, ) end + _add_tap_control_variables!(container, device_model, devices, network_model) add_feedforward_arguments!(container, device_model, devices) return end @@ -734,6 +746,9 @@ function construct_device!( add_constraints!( container, sys, AngleDifferenceConstraint, devices, device_model, network_model, ) + _add_transformer_control_constraints!( + container, sys, devices, device_model, network_model, + ) add_feedforward_constraints!(container, device_model, devices) add_to_objective_function!(container, devices, device_model, IVRNetworkModel) add_constraint_dual!(container, sys, device_model) @@ -1125,6 +1140,7 @@ function construct_device!( container, ActivePowerBalance, FlowActivePowerToFromVariable, devices, device_model, network_model, ) + _add_tap_control_variables!(container, device_model, devices, network_model) add_feedforward_arguments!(container, device_model, devices) return end @@ -1197,6 +1213,7 @@ function construct_device!( device_model, network_model, ) + _add_tap_control_variables!(container, device_model, devices, network_model) add_feedforward_arguments!(container, device_model, devices) return end diff --git a/src/core/constraints.jl b/src/core/constraints.jl index b745c2b..27dfcc7 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -200,6 +200,13 @@ struct ReferenceBusConstraint <: ConstraintType end """Rectangular-coordinate voltage magnitude bounds: vmin² ≤ vr² + vi² ≤ vmax².""" struct VoltageMagnitudeConstraint <: ConstraintType end """ +Terminal reactive-flow band for a transformer circuit whose control objective is +`REACTIVE_POWER_FLOW`. Both directional flows are held inside the circuit's +`controlled_quantity_limits`. Sparse, indexed by (circuit name, side, time step) with +side ∈ 1:4 = (from-to lower, from-to upper, to-from lower, to-from upper). +""" +struct ReactivePowerFlowControlConstraint <: ConstraintType end +""" Ties a component-owned [`RegulatedVoltageMagnitude`](@ref) auxiliary variable to the rectangular voltage components at its regulated bus under ACR/IVR formulations. One entry per regulating device per time step: diff --git a/src/core/network_formulations.jl b/src/core/network_formulations.jl index b8363f4..587e321 100644 --- a/src/core/network_formulations.jl +++ b/src/core/network_formulations.jl @@ -184,26 +184,3 @@ voltage_form(::Type{DCPNetworkModel}) = AngleBasedVoltage() voltage_form(::Type{DCPLLNetworkModel}) = AngleBasedVoltage() voltage_form(::Type{ACPNetworkModel}) = AngleBasedVoltage() voltage_form(::Type{LPACCNetworkModel}) = AngleBasedVoltage() - -# --- How a regulated bus voltage magnitude is pinned by a controlling device --- -# Polar networks carry a scalar VoltageMagnitude that is fixed directly; rectangular -# networks (vr, vi) have no magnitude primitive, so a per-device RegulatedVoltageMagnitude -# aux variable is tied to the components and fixed instead. Selects the objective- -# application path for the voltage-controlling tap (and any future voltage regulator). -abstract type RegulatedVoltageForm end -struct PolarRegulatedVoltage <: RegulatedVoltageForm end -struct RectangularRegulatedVoltage <: RegulatedVoltageForm end - -regulated_voltage_form(::Type{<:AbstractNetworkModel}) = RectangularRegulatedVoltage() -regulated_voltage_form(::Type{ACPNetworkModel}) = PolarRegulatedVoltage() - -# --- Whether a tap branch is built with explicit current variables --- -# IVR carries branch terminal/series current variables (and a CurrentLimitConstraint); -# ACP/ACR model the branch in power only. Selects the tap-branch construction path. -abstract type TapBranchCurrentForm end -struct PowerOnlyTapBranch <: TapBranchCurrentForm end -struct CurrentInjectionTapBranch <: TapBranchCurrentForm end - -tap_branch_current_form(::Type{ACPNetworkModel}) = PowerOnlyTapBranch() -tap_branch_current_form(::Type{ACRNetworkModel}) = PowerOnlyTapBranch() -tap_branch_current_form(::Type{IVRNetworkModel}) = CurrentInjectionTapBranch() diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 90ccc1b..86b6361 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -43,22 +43,36 @@ function _controlled_sys14( return sys, transformer, circuit, PSY.get_name(bus) end -function _controlled_template(network_formulation; enable = true, kwargs...) +function _controlled_template( + network_formulation; + enable = true, + formulation = StaticBranch, + kwargs..., +) template = get_thermal_dispatch_template_network(NetworkModel(network_formulation; kwargs...)) set_device_model!( template, DeviceModel( PSY.TwoWindingTransformer, - StaticBranch; + formulation; attributes = _control_attributes(enable), ), ) return template end -function _build_controlled(sys, network_formulation; enable = true, optimizer, kwargs...) - template = _controlled_template(network_formulation; enable = enable, kwargs...) +function _build_controlled( + sys, + network_formulation; + enable = true, + optimizer, + formulation = StaticBranch, + kwargs..., +) + template = _controlled_template( + network_formulation; enable = enable, formulation = formulation, kwargs..., + ) model = DecisionModel(template, sys; optimizer = optimizer) status = build!(model; output_dir = mktempdir(; cleanup = true)) return model, status @@ -242,52 +256,66 @@ end ################################### model invariants ################################### -@testset "a tap pinned at nominal reproduces the uncontrolled model (ACP)" begin +@testset "a tap pinned at nominal reproduces the uncontrolled model" begin # White-box reduction gate: with the tap variable fixed at the circuit's nominal # ratio and a band too wide to bind, the controlled Ohm's law is term-by-term the # fixed-tap one, so both models must reach the same optimum and terminal flows. # The band is the regulated bus's own (0.94, 1.06) limits — the widest a VOLTAGE band # may be — so the control constrains nothing the bus does not already. + # + # IVR is the sharpest case: its law is multiplied through by the ratio, so every + # tm-bearing term has to reduce exactly for the flows to match. band = (min = 0.94, max = 1.06) tap_range = (min = 0.5, max = 1.5) - sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) - model_fixed, status_fixed = _build_controlled( - sys_fixed, ACPNetworkModel; enable = false, optimizer = ipopt_optimizer, + for network_formulation in ( + ACPNetworkModel, + ACRNetworkModel, + LPACCNetworkModel, + IVRNetworkModel, ) - @test status_fixed == IOM.ModelBuildStatus.BUILT - @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - sys_var, transformer, circuit, _ = _controlled_sys14( - VOLTAGE_CONTROL; quantity_limits = band, control_limits = tap_range, - ) - model_var, status_var = - _build_controlled(sys_var, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status_var == IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(model_var) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - for t in axes(tap, 2) - JuMP.fix(tap[PSY.get_name(transformer), t], PSY.get_tap(circuit); force = true) - end - @test solve!(model_var) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) + model_fixed, status_fixed = _build_controlled( + sys_fixed, network_formulation; enable = false, + optimizer = ipopt_optimizer, + ) + @test status_fixed == IOM.ModelBuildStatus.BUILT + @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res_fixed = IOM.OptimizationProblemOutputs(model_fixed) - res_var = IOM.OptimizationProblemOutputs(model_var) - @test isapprox( - IOM.get_objective_value(res_var), - IOM.get_objective_value(res_fixed); - rtol = 1e-3, - ) - for key in ( - "FlowActivePowerFromToVariable__TwoWindingTransformer", - "FlowReactivePowerFromToVariable__TwoWindingTransformer", - ) - flow_fixed = read_variable(res_fixed, key; table_format = TableFormat.WIDE) - flow_var = read_variable(res_var, key; table_format = TableFormat.WIDE) - for d in PSY.get_components(PSY.TwoWindingTransformer, sys_var) - name = PSY.get_name(d) - @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) + sys_var, transformer, circuit, _ = _controlled_sys14( + VOLTAGE_CONTROL; quantity_limits = band, control_limits = tap_range, + ) + model_var, status_var = _build_controlled( + sys_var, network_formulation; optimizer = ipopt_optimizer, + ) + @test status_var == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model_var) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + for t in axes(tap, 2) + JuMP.fix( + tap[PSY.get_name(transformer), t], PSY.get_tap(circuit); force = true, + ) + end + @test solve!(model_var) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res_fixed = IOM.OptimizationProblemOutputs(model_fixed) + res_var = IOM.OptimizationProblemOutputs(model_var) + @test isapprox( + IOM.get_objective_value(res_var), + IOM.get_objective_value(res_fixed); + rtol = 1e-3, + ) + for key in ( + "FlowActivePowerFromToVariable__TwoWindingTransformer", + "FlowReactivePowerFromToVariable__TwoWindingTransformer", + ) + flow_fixed = read_variable(res_fixed, key; table_format = TableFormat.WIDE) + flow_var = read_variable(res_var, key; table_format = TableFormat.WIDE) + for d in PSY.get_components(PSY.TwoWindingTransformer, sys_var) + name = PSY.get_name(d) + @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) + end end end end @@ -346,49 +374,160 @@ end @testset "VOLTAGE control is wired on every voltage-carrying AC network" begin for network_formulation in (ACRNetworkModel, IVRNetworkModel, LPACCNetworkModel) - @testset "$network_formulation" begin - _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) - band = (min = 1.00, max = 1.02) - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) - model, status = - _build_controlled(sys, network_formulation; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test _has_tap_variable(IOM.get_optimization_container(model)) - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - if network_formulation == LPACCNetworkModel - phi = read_variable( - res, "VoltageDeviation__ACBus"; table_format = TableFormat.WIDE, - ) - magnitude = 1.0 + phi[1, bus_name] - else - vr = read_variable( - res, - "VoltageReal__ACBus"; - table_format = TableFormat.WIDE, - ) - vi = read_variable( - res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE, - ) - magnitude = sqrt(vr[1, bus_name]^2 + vi[1, bus_name]^2) - end - @test magnitude >= band.min - 1e-4 - @test magnitude <= band.max + 1e-4 + _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) + band = (min = 1.00, max = 1.02) + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) + model, status = + _build_controlled(sys, network_formulation; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test _has_tap_variable(IOM.get_optimization_container(model)) + @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + if network_formulation == LPACCNetworkModel + phi = read_variable( + res, "VoltageDeviation__ACBus"; table_format = TableFormat.WIDE, + ) + magnitude = 1.0 + phi[1, bus_name] + else + vr = read_variable( + res, + "VoltageReal__ACBus"; + table_format = TableFormat.WIDE, + ) + vi = read_variable( + res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE, + ) + magnitude = sqrt(vr[1, bus_name]^2 + vi[1, bus_name]^2) end + @test magnitude >= band.min - 1e-4 + @test magnitude <= band.max + 1e-4 + + # The band is written on the network's own voltage variables, so no per-device + # RegulatedVoltageMagnitude aux is introduced on any of these networks. + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key( + container, RegulatedVoltageMagnitude, PSY.TwoWindingTransformer, + ) + # One controlled circuit, two rows (lower/upper) per time step. + @test length( + IOM.get_constraint( + container, VoltageMagnitudeConstraint, PSY.TwoWindingTransformer, + ), + ) == 2 * length(IOM.get_time_steps(container)) end end -@testset "DC networks carry no tap control" begin - # The DC network has no voltage magnitude or reactive flow to regulate, so an enabled - # control switch has nothing to build; the tap stays the component property that the - # tap-divided DC susceptance already carries. - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - model, status = _build_controlled(sys, DCPNetworkModel; optimizer = HiGHS_optimizer) +@testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its band (IVR)" begin + # IVR builds its own current-based flow constraints, so the band has to be applied + # there too and not only on the shared pi-model path. + band = (min = -0.05, max = 0.05) + sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = band) + model, status = _build_controlled(sys, IVRNetworkModel; optimizer = ipopt_optimizer) @test status == IOM.ModelBuildStatus.BUILT - @test !_has_tap_variable(IOM.get_optimization_container(model)) @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + base = IOM.get_model_base_power(res) + for key in ( + "FlowReactivePowerFromToVariable__TwoWindingTransformer", + "FlowReactivePowerToFromVariable__TwoWindingTransformer", + ) + flow = read_variable(res, key; table_format = TableFormat.WIDE) + for r in 1:nrow(flow) + @test flow[r, "Trans1"] / base >= band.min - 1e-6 + @test flow[r, "Trans1"] / base <= band.max + 1e-6 + end + end +end + +################################### DC networks ######################################## + +@testset "DC tap control needs StaticBranchBounds; StaticBranch is rejected" begin + # StaticBranch under DCP carries its flow as the BThetaBranchFlow expression, which the + # affine nodal balance cannot hold once the ratio is a variable. The pair is refused up + # front rather than letting the control go silently inert. + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + template = _controlled_template(DCPNetworkModel) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + out = mktempdir(; cleanup = true) + @test build!(model; output_dir = out, console_level = Logging.Error) == + IOM.ModelBuildStatus.FAILED + @test occursin( + "builds no tap ratio variable", + read(joinpath(out, "operation_problem.log"), String), + ) +end + +@testset "DC networks build a variable tap under StaticBranchBounds" begin + tap_range = (min = 0.9, max = 1.1) + for network_formulation in (DCPNetworkModel, DCPLLNetworkModel) + sys, _, _, _ = + _controlled_sys14(VOLTAGE_CONTROL; control_limits = tap_range) + model, status = _build_controlled( + sys, + network_formulation; + formulation = StaticBranchBounds, + optimizer = ipopt_optimizer, + ) + @test status == IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + @test axes(tap)[1] == ["Trans1"] + @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) + # Neither quantity exists on a DC network, so no band is built either way. + @test !IOM.has_container_key( + container, VoltageMagnitudeConstraint, PSY.TwoWindingTransformer, + ) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + end +end + +@testset "the DC tap law reduces to the fixed-tap law at the nominal ratio" begin + # The bilinear DC law is multiplied through by the ratio, so evaluating the built row at + # the nominal ratio must reproduce `nominal * (p - b_dc * (va_fr - va_to - shift))`. + # Checked on the constraint itself rather than by comparing two solves: c_sys14's + # transformer ratings make a fixed-tap StaticBranchBounds DCP model infeasible (that + # formulation enforces the rating as hard variable bounds), so there is no fixed-tap + # reference solution to compare against on this system. + sys, transformer, circuit, _ = _controlled_sys14(VOLTAGE_CONTROL) + model, status = _build_controlled( + sys, DCPNetworkModel; + formulation = StaticBranchBounds, optimizer = ipopt_optimizer, + ) + @test status == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model) + p = IOM.get_variable(container, FlowActivePowerVariable, PSY.TwoWindingTransformer) + va = IOM.get_variable(container, VoltageAngle, PSY.ACBus) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + cons = + IOM.get_constraint(container, POM.NetworkFlowConstraint, PSY.TwoWindingTransformer) + + t = 1 + name = PSY.get_name(transformer) + arc = PSY.get_arc(circuit) + fr = PSY.get_name(PSY.get_from(arc)) + to = PSY.get_name(PSY.get_to(arc)) + nominal = PNM.branch_admittance(transformer).tap + b_dc = PNM.get_series_susceptance(transformer, PSY.SU) + shift = PNM.get_series_phase_shift(transformer) + @test !isapprox(nominal, 1.0; atol = 1e-6) + + vals = Dict{JuMP.VariableRef, Float64}( + va[fr, t] => 0.05, va[to, t] => -0.03, + tap[name, t] => nominal, + p[name, t] => 0.7, + ) + lookup = z -> vals[z] + + angle = vals[va[fr, t]] - vals[va[to, t]] - shift + @test isapprox( + JuMP.value(lookup, JuMP.constraint_object(cons[name, t]).func), + nominal * (vals[p[name, t]] - b_dc * angle); + atol = 1e-10, + ) end ################################ reductions and conflicts ############################## @@ -460,88 +599,6 @@ function _case11_with_forecast() return sys end -@testset "StaticBranch models transformer off-nominal tap under DCP (c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, StaticBranch) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - base = IOM.get_model_base_power(res) - # StaticBranch under DCP has no FlowActivePowerVariable: the flow IS the - # BThetaBranchFlow expression, reported in natural units (MW). VoltageAngle is - # unitless (radians, no conversion), so compare in per-unit. - pflow = read_expression( - res, - "BThetaBranchFlow__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - va = read_variable(res, "VoltageAngle__ACBus"; table_format = TableFormat.WIDE) - - tested_a_real_tap = false - for tr in PSY.get_components(PSY.TwoWindingTransformer, sys) - name = PSY.get_name(tr) - @test name in names(pflow) - - # Recover the series reactance independently, from the π-model admittance, so the - # oracle does not simply re-call the susceptance helper the source uses. - adm = PNM.branch_admittance(tr) - x = -adm.b / (adm.g^2 + adm.b^2) - # The DC susceptance is tap-divided: b_dc == 1/(tap*x). Pin the equivalence of the - # independent recovery and PNM's DC entry point. - @test 1 / (x * adm.tap) ≈ PNM.get_series_susceptance(tr, PSY.SU) - - arc = PSY.get_arc(PSY.get_circuit(tr)) - fr = PSY.get_name(PSY.get_from(arc)) - to = PSY.get_name(PSY.get_to(arc)) - shift = PNM.get_series_phase_shift(tr) - if !isapprox(adm.tap, 1.0; atol = 1e-6) - tested_a_real_tap = true - end - for r in 1:nrow(pflow) - p_pu = pflow[r, name] / base - expected = (va[r, fr] - va[r, to] - shift) / (x * adm.tap) - @test isapprox(p_pu, expected; atol = 1e-5) - end - end - # Guard: the test system must actually carry a non-unit tap, else this proves nothing. - @test tested_a_real_tap -end - -@testset "_tapped_admittance round-trips PNM.ybus_branch_entries" begin - function check_terms(y, ybus) - Y11, Y12, Y21, Y22 = ybus - @test isapprox(complex(y.g11, y.b11), Y11; rtol = 1e-10, atol = 1e-12) - @test isapprox(complex(y.g12, y.b12), Y12; rtol = 1e-10, atol = 1e-12) - @test isapprox(complex(y.g21, y.b21), Y21; rtol = 1e-10, atol = 1e-12) - @test isapprox(complex(y.g22, y.b22), Y22; rtol = 1e-10, atol = 1e-12) - end - - model = JuMP.Model() - sys = PSB.build_system(PSITestSystems, "c_sys14") - for br in Iterators.flatten(( - PSY.get_components(PSY.Line, sys), - PSY.get_components(PSY.TwoWindingTransformer, sys), - )) - adm = PNM.branch_admittance(br) - check_terms(POM._tapped_admittance(model, adm, adm.tap), PNM.ybus_branch_entries(br)) - end - - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - circuit = PSY.get_circuit(tr) - for shift in (-pi / 5, 0.0, pi / 6) - PSY.set_α!(circuit, shift) - PSY.set_tap!(circuit, 1.0) - adm = PNM.branch_admittance(tr) - for tap in (0.9, 1.0, 1.1, 1.25) - PSY.set_tap!(circuit, tap) - check_terms(POM._tapped_admittance(model, adm, tap), PNM.ybus_branch_entries(tr)) - end - end -end ######################################################################################### # Phase control (the ACTIVE_POWER_FLOW / ASYMMETRIC_ACTIVE_POWER_FLOW objectives, where the # phase shift α rather than the tap ratio is the decision variable) is NOT supported yet. diff --git a/test/test_transformer_fixed_tap.jl b/test/test_transformer_fixed_tap.jl index a9bd882..a363c80 100644 --- a/test/test_transformer_fixed_tap.jl +++ b/test/test_transformer_fixed_tap.jl @@ -69,13 +69,17 @@ end @test isapprox(complex(y.g22, y.b22), Y22; rtol = 1e-10, atol = 1e-12) end + model = JuMP.Model() sys = PSB.build_system(PSITestSystems, "c_sys14") for br in Iterators.flatten(( PSY.get_components(PSY.Line, sys), PSY.get_components(PSY.TwoWindingTransformer, sys), )) adm = PNM.branch_admittance(br) - check_terms(POM._pi_flow_terms(adm, adm.tap), PNM.ybus_branch_entries(br)) + check_terms( + POM._tapped_admittance(model, adm, adm.tap), + PNM.ybus_branch_entries(br), + ) end tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") @@ -86,7 +90,10 @@ end adm = PNM.branch_admittance(tr) for tap in (0.9, 1.0, 1.1, 1.25) PSY.set_tap!(circuit, tap) - check_terms(POM._pi_flow_terms(adm, tap), PNM.ybus_branch_entries(tr)) + check_terms( + POM._tapped_admittance(model, adm, tap), + PNM.ybus_branch_entries(tr), + ) end end end From e11cd7bd32c18662523ff2f58343df3af157ad35 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 14 Aug 2026 23:18:21 -0400 Subject: [PATCH 08/19] remove unnecessary test --- src/ac_transmission_models/AC_branches.jl | 14 ++++++-------- test/test_transformer_controls.jl | 16 ---------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 1b23f5e..542f68b 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -332,7 +332,7 @@ function _add_tap_control_variables!( devices::IS.FlattenIteratorWrapper{U}, network_model::NetworkModel, ) where { - U <: Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer}, + U <: _TRANSFORMERS, F <: AbstractBranchFormulation, } get_attribute(model, ENABLE_CONTROLS_KEY) === true || return @@ -1231,9 +1231,7 @@ _dc_phase_shift(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReduction _dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = PNM.get_series_phase_shift(branch) -_get_circuit( - b::Union{PSY.TwoWindingTransformer, PNM.ThreeWindingTransformerCircuit}, -) = PSY.get_circuit(b) +_get_circuit(b::_TRANSFORMERS) = PSY.get_circuit(b) _get_circuit(_) = nothing _control_objective(branch) = _control_objective(_get_circuit(branch)) @@ -1820,8 +1818,8 @@ function _add_voltage_control_constraints!( sys::PSY.System, devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T}, - ::NetworkModel{<:NativeACNetworkModel} -) where {T <: _TRANSFORMER_CONTROL_TYPES} + network_model::NetworkModel{<:NativeACNetworkModel} +) where {T <: _TRANSFORMERS} _control_enabled(device_model) || return cons = add_constraints_container!( @@ -1848,7 +1846,7 @@ function _add_voltage_control_constraints!( circuit_name = _circuit_arc_name(d, circuit, i) (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error("Bus limits for $bus_name disagree with control limits for circuit $circuit_name.") - lims = _voltage_limits(ctl_limits) + lims = _voltage_limits(ctl_limits, network_model) vm = _voltage_magnitude(container, bus_name, network_model) for t in time_steps cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[t] >= lims.min) @@ -1872,7 +1870,7 @@ function _add_reactive_control_constraints!( devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T}, ::NetworkModel{<:NativeACNetworkModel}, -) where {T <: _TRANSFORMER_CONTROL_TYPES} +) where {T <: _TRANSFORMERS} _control_enabled(device_model) || return cons = add_constraints_container!( diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 86b6361..93b6986 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -444,22 +444,6 @@ end ################################### DC networks ######################################## -@testset "DC tap control needs StaticBranchBounds; StaticBranch is rejected" begin - # StaticBranch under DCP carries its flow as the BThetaBranchFlow expression, which the - # affine nodal balance cannot hold once the ratio is a variable. The pair is refused up - # front rather than letting the control go silently inert. - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - template = _controlled_template(DCPNetworkModel) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - out = mktempdir(; cleanup = true) - @test build!(model; output_dir = out, console_level = Logging.Error) == - IOM.ModelBuildStatus.FAILED - @test occursin( - "builds no tap ratio variable", - read(joinpath(out, "operation_problem.log"), String), - ) -end - @testset "DC networks build a variable tap under StaticBranchBounds" begin tap_range = (min = 0.9, max = 1.1) for network_formulation in (DCPNetworkModel, DCPLLNetworkModel) From 7368dcd36402f65a491a210c1d0c3f81691ad694 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 14 Aug 2026 23:18:52 -0400 Subject: [PATCH 09/19] formatting --- src/ac_transmission_models/AC_branches.jl | 52 +++++++++++++++-------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 542f68b..cdc1bfc 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -81,7 +81,10 @@ _TRANSFORMERS = Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer} _control_enabled(m::DeviceModel{<:_TRANSFORMERS}) = get_attribute(m, ENABLE_CONTROLS_KEY) === true _control_enabled(c::PSY.TransformerCircuit) = - PSY.get_available(c) && !(PSY.get_control_objective(c) in (PSY.TransformerControlObjective.UNDEFINED, PSY.TransformerControlObjective.FIXED)) + PSY.get_available(c) && !( + PSY.get_control_objective(c) in + (PSY.TransformerControlObjective.UNDEFINED, PSY.TransformerControlObjective.FIXED) + ) _control_enabled(_) = false _tap_controlled(c::PSY.TransformerControlObjective) = c in ( @@ -322,7 +325,9 @@ _add_tap_control_variables!( ::NetworkModel, ) = nothing -_warn_tap_control_nonconvexity(::NetworkModel{N}) where {N <: Union{LPACCNetworkModel, DCPNetworkModel, DCPLLNetworkModel}} = +_warn_tap_control_nonconvexity( + ::NetworkModel{N}, +) where {N <: Union{LPACCNetworkModel, DCPNetworkModel, DCPLLNetworkModel}} = @warn "Tap control makes $N network models non-convex. Use Ipopt or change circuit controls." _warn_tap_control_nonconvexity(_) = nothing @@ -1804,21 +1809,32 @@ end _voltage_magnitude(container, name, ::NetworkModel{ACPNetworkModel}) = get_variable(container, VoltageMagnitude, PSY.ACBus)[name, :] -_voltage_magnitude(container, name, ::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}) = - JuMP.@expression(get_jump_model(container), [t in get_time_steps(container)], get_variable(container, VoltageReal, PSY.ACBus)[name, t]^2 + get_variable(container, VoltageImaginary, PSY.ACBus)[name, t]^2) +_voltage_magnitude( + container, + name, + ::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}, +) = + JuMP.@expression( + get_jump_model(container), + [t in get_time_steps(container)], + get_variable(container, VoltageReal, PSY.ACBus)[name, t]^2 + + get_variable(container, VoltageImaginary, PSY.ACBus)[name, t]^2 + ) _voltage_magnitude(container, name, ::NetworkModel{LPACCNetworkModel}) = get_variable(container, VoltageDeviation, PSY.ACBus)[name, :] _voltage_limits(limits, ::NetworkModel{ACPNetworkModel}) = limits -_voltage_limits(limits, ::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}) = (min = limits.min^2, max = limits.max^2) -_voltage_limits(limits, ::NetworkModel{LPACCNetworkModel}) = (min = limits.min - 1, max = limits.max - 1) +_voltage_limits(limits, ::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}) = + (min = limits.min^2, max = limits.max^2) +_voltage_limits(limits, ::NetworkModel{LPACCNetworkModel}) = + (min = limits.min - 1, max = limits.max - 1) function _add_voltage_control_constraints!( container::OptimizationContainer, sys::PSY.System, devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T}, - network_model::NetworkModel{<:NativeACNetworkModel} + network_model::NetworkModel{<:NativeACNetworkModel}, ) where {T <: _TRANSFORMERS} _control_enabled(device_model) || return @@ -1844,7 +1860,9 @@ function _add_voltage_control_constraints!( ctl_limits = PSY.get_controlled_quantity_limits(circuit) # TODO: temporary pending PSY#1755 circuit_name = _circuit_arc_name(d, circuit, i) - (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error("Bus limits for $bus_name disagree with control limits for circuit $circuit_name.") + (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error( + "Bus limits for $bus_name disagree with control limits for circuit $circuit_name.", + ) lims = _voltage_limits(ctl_limits, network_model) vm = _voltage_magnitude(container, bus_name, network_model) @@ -2618,18 +2636,18 @@ function add_constraints!( flow = if use_slacks JuMP.@expression( - jump_model, - p[g.name, t] - slack_ub[g.name, t] + slack_lb[g.name, t] - ) + jump_model, + p[g.name, t] - slack_ub[g.name, t] + slack_lb[g.name, t] + ) else p[g.name, t] end cons[g.name, t] = if _tap_controlled(device_model, g) JuMP.@constraint( - jump_model, - flow * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle - ) + jump_model, + flow * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle + ) else JuMP.@constraint(jump_model, flow == g.b_dc * angle) end @@ -3043,9 +3061,9 @@ function add_constraints!( cons[g.name, t] = if _tap_controlled(device_model, g) JuMP.@constraint( - jump_model, - pft[g.name, t] * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle - ) + jump_model, + pft[g.name, t] * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle + ) else JuMP.@constraint(jump_model, pft[g.name, t] == g.b_dc * angle) end From fc4726f67995d65ab700de3a75ea31343c14d61c Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 17 Aug 2026 14:46:29 -0400 Subject: [PATCH 10/19] readded bus reduction protection; again removed pfs for now --- src/ac_transmission_models/AC_branches.jl | 8 +-- src/network_models/reduction_exceptions.jl | 79 +++++++++------------- test/Project.toml | 8 +-- 3 files changed, 37 insertions(+), 58 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index cdc1bfc..0c20b15 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -69,15 +69,13 @@ function get_default_time_series_names( return Dict{Type{<:TimeSeriesParameter}, String}() end +const _TRANSFORMERS = Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer} + const ENABLE_CONTROLS_KEY = "enable_controls" -_control_attribute( - ::Union{Type{PSY.TwoWindingTransformer}, Type{PSY.ThreeWindingTransformer}}, -) = (ENABLE_CONTROLS_KEY => false,) +_control_attribute(::Union{Type{<:_TRANSFORMERS}}) = (ENABLE_CONTROLS_KEY => false,) _control_attribute(_) = () -_TRANSFORMERS = Union{PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer} - _control_enabled(m::DeviceModel{<:_TRANSFORMERS}) = get_attribute(m, ENABLE_CONTROLS_KEY) === true _control_enabled(c::PSY.TransformerCircuit) = diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index 8696ac3..6c315ce 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -1,7 +1,6 @@ #= Buses that must survive PNM network reductions because something the template models -is pinned to them. One rule per method, dispatched on the DeviceModel, so a new rule -is a new method rather than another branch in a growing loop. +is pinned to them. One rule per method. This set is the sole authority on reduction exceptions: the buses the caller pinned on the `NetworkModel` plus the buses these rules derive. PNM's own `_collect_protected_buses` @@ -16,18 +15,6 @@ function _push_component_buses!(buses::Set{Int}, branch::PSY.Branch) return end -function _push_component_buses!(buses::Set{Int}, branch::PSY.ThreeWindingTransformer) - for arc in ( - PSY.get_primary_star_arc(branch), - PSY.get_secondary_star_arc(branch), - PSY.get_tertiary_star_arc(branch), - ) - push!(buses, PSY.get_number(PSY.get_from(arc))) - push!(buses, PSY.get_number(PSY.get_to(arc))) - end - return -end - function _push_component_buses!(buses::Set{Int}, device::PSY.StaticInjection) push!(buses, PSY.get_number(PSY.get_bus(device))) return @@ -66,12 +53,15 @@ function _collect_reduction_exceptions( buses = Set{Int}(get_reduction_exceptions(model)) _pin_dc_converter_buses!(buses, sys) for m in values(branch_models) - _pin_irreducible_buses!(buses, m, sys) + _pin_time_series_branch_buses!(buses, m, sys) + _pin_outage_buses!(buses, m, sys) + _pin_model_all_branches(buses, m) + _pin_transformer_controls(buses, m) end return collect(buses) end -# Rule 0: a converter's AC terminal must survive the reduction. Merging one away drops the +# A converter's AC terminal must survive the reduction. Merging one away drops the # converter from the model without a word, so this is keyed on the system rather than on a # DeviceModel — the exposure exists whether or not the template happens to model the # converter's type. Unconditional, unlike PowerFlows' matching set, which skips `g == 0` @@ -87,30 +77,7 @@ function _pin_dc_converter_buses!(buses::Set{Int}, sys::PSY.System) return end -_pin_irreducible_buses!(::Set{Int}, ::DeviceModel, ::PSY.System) = nothing - -function _pin_irreducible_buses!( - buses::Set{Int}, - m::DeviceModel{T}, - sys::PSY.System, -) where {T <: PSY.ACTransmission} - _pin_time_series_branch_buses!(buses, m, sys) - _pin_outage_buses!(buses, m, sys) - return -end - -function _pin_irreducible_buses!( - buses::Set{Int}, - m::DeviceModel{PSY.MonitoredLine}, - sys::PSY.System, -) - _pin_time_series_branch_buses!(buses, m, sys) - _pin_outage_buses!(buses, m, sys) - _pin_model_all_branches!(buses, m) - return -end - -# Rule 1: a branch carrying a rating time series pins both its endpoints, so the +# A branch carrying a rating time series pins both its endpoints, so the # reduction cannot merge away the bus a time-varying limit is applied at. function _pin_time_series_branch_buses!( ::Set{Int}, @@ -119,7 +86,7 @@ function _pin_time_series_branch_buses!( ) haskey(get_time_series_names(m), BranchRatingTimeSeriesParameter) || return - _warn_three_winding_rating_unsupported() + @warn "Dynamic branch ratings for ThreeWindingTransformers are not implemented yet. Its windings may be reduced from the network." return end @@ -140,12 +107,7 @@ function _pin_time_series_branch_buses!( return end -function _warn_three_winding_rating_unsupported() - @warn "Dynamic branch ratings for ThreeWindingTransformers are not implemented yet. Skipping it." - return -end - -# Rule 2: an outage registered on an outage-aware branch model pins both its +# An outage registered on an outage-aware branch model pins both its # monitored and its outaged endpoints. The MODF column for a contingency is keyed by # the outaged arc's endpoints, and post-contingency flow constraints reference the # monitored components' real bus numbers. @@ -171,7 +133,7 @@ function _pin_outage_buses!(buses::Set{Int}, m::DeviceModel, sys::PSY.System) return end -# Rule 3: a `model_all_branches` MonitoredLine model pins its lines so zero-impedance +# A `model_all_branches` MonitoredLine model pins its lines so zero-impedance # ones survive the reduction instead of being merged away. function _pin_model_all_branches!( buses::Set{Int}, @@ -183,3 +145,24 @@ function _pin_model_all_branches!( end return end + +_pin_model_all_branches!(::Set{Int}, ::DeviceModel) = nothing + +# A transformer circuit with a defined control objective on a transformer with +# controls enabled must not be reduced away, nor can its regulated bus. +function _pin_transformer_controls!( + buses::Set{Int}, + m::DeviceModel{_TRANSFORMERS}, +) + _control_enabled(m) || return + for transformer in get_device_cache(m) + for circuit in PSY.get_circuits(transformer) + _control_enabled(m) || continue + _push_component_buses!(buses, circuit) + push!(buses, get_regulated_bus(circuit)) + end + end + return +end + +_pin_transformer_controls!(::Set{Int}, ::DeviceModel) = nothing diff --git a/test/Project.toml b/test/Project.toml index d64e035..44e145b 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -19,7 +19,6 @@ ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" PowerFlowFileParser = "bed98974-b02e-5e2f-9ee0-a103f5c450dd" -PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" PowerOperationsModels = "bed98974-b02a-5e2f-9ee0-a103f5c450dd" PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" @@ -32,18 +31,17 @@ Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TestSetExtensions = "98d24dd4-01ad-11ea-1b02-c9a08f80db04" -TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" +TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [sources] InfrastructureOptimizationModels = {rev = "jd/network-sources", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} +PowerCoreOpenAPIModels = {rev = "main", subdir = "PowerCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} PowerFlowFileParser = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl"} -PowerFlows = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {rev = "main", subdir = "PowerOperationsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} PowerSystemCaseBuilder = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl"} PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} PowerTableDataParser = {rev = "psy6", url = "https://github.com/NREL-Sienna/PowerTableDataParser.jl"} From abc4c2cf15c7706b05e5d30563266a61105e39a8 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 17 Aug 2026 16:32:46 -0400 Subject: [PATCH 11/19] address review comments; refactor IVR, rename helpers --- src/ac_transmission_models/AC_branches.jl | 215 +++++++++------------- src/core/constraints.jl | 6 +- src/utils/psy_utils.jl | 10 - 3 files changed, 87 insertions(+), 144 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 0c20b15..23f414e 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -53,10 +53,6 @@ get_variable_upper_bound(::Type{FlowActivePowerFromToVariable}, d::PSY.Monitored get_variable_lower_bound(::Type{FlowActivePowerFromToVariable}, d::PSY.MonitoredLine, ::Type{<:AbstractBranchFormulation}) = -1 * PSY.get_flow_limits(d, PSY.SU).from_to get_variable_upper_bound(::Type{FlowActivePowerToFromVariable}, d::PSY.MonitoredLine, ::Type{<:AbstractBranchFormulation}) = PSY.get_flow_limits(d, PSY.SU).to_from get_variable_lower_bound(::Type{FlowActivePowerToFromVariable}, d::PSY.MonitoredLine, ::Type{<:AbstractBranchFormulation}) = -1 * PSY.get_flow_limits(d, PSY.SU).to_from -get_variable_upper_bound(::Type{FlowActivePowerFromToVariable}, d::PSY.TwoWindingTransformer, ::Type{<:AbstractBranchFormulation}) = _branch_rating(d) -get_variable_lower_bound(::Type{FlowActivePowerFromToVariable}, d::PSY.TwoWindingTransformer, ::Type{<:AbstractBranchFormulation}) = _negated_rating(_branch_rating(d)) -get_variable_upper_bound(::Type{FlowActivePowerToFromVariable}, d::PSY.TwoWindingTransformer, ::Type{<:AbstractBranchFormulation}) = _branch_rating(d) -get_variable_lower_bound(::Type{FlowActivePowerToFromVariable}, d::PSY.TwoWindingTransformer, ::Type{<:AbstractBranchFormulation}) = _negated_rating(_branch_rating(d)) #! format: on function get_default_time_series_names( @@ -172,7 +168,7 @@ end # the constituent branches may carry different DeviceModel preferences and there is # no defensible way to pick one. The PNM aggregators return system-base values # (no `PSY.SU`). -function _get_parallel_branch_max_rating(model::DeviceModel, bp::PNM.BranchesParallel) +function _parallel_branches_rating(model::DeviceModel, bp::PNM.BranchesParallel) method = get_attribute(model, PARALLEL_BRANCH_MAX_RATING_KEY) if method == "single_element_contingency" return PNM.get_single_element_contingency_rating(bp) @@ -188,7 +184,7 @@ function _get_parallel_branch_max_rating(model::DeviceModel, bp::PNM.BranchesPar end end -function _get_parallel_branch_max_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel) +function _parallel_branches_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel) return PNM.get_sum_of_max_rating(mbp) end #################################### Flow Variable Bounds ################################################## @@ -338,8 +334,7 @@ function _add_tap_control_variables!( U <: _TRANSFORMERS, F <: AbstractBranchFormulation, } - get_attribute(model, ENABLE_CONTROLS_KEY) === true || return - _warn_tap_control_nonconvexity(network_model) + _control_enabled(model) || return names = String[] circuits = PSY.TransformerCircuit[] @@ -349,6 +344,7 @@ function _add_tap_control_variables!( push!(circuits, c) end isempty(names) && return + _warn_tap_control_nonconvexity(network_model) _validate_controlled_branch_not_reduced(network_model, U, names) time_steps = get_time_steps(container) @@ -437,7 +433,7 @@ function branch_rate_bounds!( # It might have performance implications. Possibly separate this into other functions reduction_entry = all_branch_maps_by_type[reduction][B][arc] flow_limits = min_max_flow_limits(reduction_entry, device_model) - rating = branch_rating(reduction_entry, device_model) + rating = _reduction_rating(reduction_entry, device_model) rating_limits = (min = -rating, max = rating) for (V, var) in zip(variable_types, variables) limits = _directional_flow_limits(V, flow_limits, rating_limits) @@ -460,19 +456,10 @@ end ################################## Rate Limits constraint_infos ############################ -""" -Scalar branch rating for a reduction entry — the single source of truth for -branch flow ratings. Parallel groups use the `PARALLEL_BRANCH_MAX_RATING_KEY` -attribute; every other entry uses `PNM.get_equivalent_rating`. Extend that (not -this) for new types. The PNM aggregators are system-base (no `PSY.SU`). -""" -function branch_rating(double_circuit::PNM.AbstractBranchesParallel, model::DeviceModel) - return _get_parallel_branch_max_rating(model, double_circuit) -end +_reduction_rating(entry::PNM.AbstractBranchesParallel, model::DeviceModel) = + _parallel_branches_rating(model, entry) -function branch_rating(entry, ::DeviceModel) - return PNM.get_equivalent_rating(entry) -end +_reduction_rating(entry::PNM.BranchesSeries, ::DeviceModel) = PNM.get_equivalent_rating(entry) """ Symmetric `(min, max)` flow limits from [`branch_rating`](@ref). Prefer this @@ -480,7 +467,7 @@ over the formulation-only `get_min_max_limits` when the `DeviceModel` is in scope. """ function min_max_flow_limits(entry, model::DeviceModel) - rating = branch_rating(entry, model) + rating = _reduction_rating(entry, model) return (min = -rating, max = rating) end @@ -1341,7 +1328,7 @@ end # variables. Zero is a data error rather than "unlimited" as in MATPOWER-style data: `p² + # q² ≤ 0` would silently pin the branch to zero flow, deleting it from the network. function _directional_flow_rating(d::PSY.ACTransmission, ::DeviceModel) - rating = _branch_rating(d) + rating = PSY.get_rating(d) iszero(rating) && error( "Branch $(PSY.get_name(d)) has a zero rating; the flow limit would force zero \ flow. Assign a non-zero thermal rating or use an unbounded formulation.", @@ -1353,7 +1340,7 @@ function _directional_flow_rating( entry::PNM.AbstractReductionAggregate, device_model::DeviceModel, ) - rating = branch_rating(entry, device_model) + rating = _reduction_rating(entry, device_model) iszero(rating) && error( "A reduced arc has a zero equivalent rating; the flow limit would force zero \ flow. Assign non-zero thermal ratings to its member branches.", @@ -1656,18 +1643,18 @@ function _voltage_products( ::String, from_bus::String, to_bus::String, - t::Int, ) jump_model = get_jump_model(container) vm = get_variable(container, VoltageMagnitude, PSY.ACBus) va = get_variable(container, VoltageAngle, PSY.ACBus) - vmf, vmt = vm[from_bus, t], vm[to_bus, t] - vaf, vat = va[from_bus, t], va[to_bus, t] + vmf, vmt = vm[from_bus, :], vm[to_bus, :] + vaf, vat = va[from_bus, :], va[to_bus, :] + T = length(get_time_steps(container)) return ( - v2_fr = JuMP.@expression(jump_model, vmf^2), - v2_to = JuMP.@expression(jump_model, vmt^2), - vv_cos = JuMP.@expression(jump_model, vmf * vmt * cos(vaf - vat)), - vv_sin = JuMP.@expression(jump_model, vmf * vmt * sin(vaf - vat)), + v2_fr = JuMP.@expression(jump_model, [t=1:T], vmf[t]^2), + v2_to = JuMP.@expression(jump_model, [t=1:T], vmt[t]^2), + vv_cos = JuMP.@expression(jump_model, [t=1:T], vmf[t] * vmt[t] * cos(vaf[t] - vat[t])), + vv_sin = JuMP.@expression(jump_model, [t=1:T], vmf[t] * vmt[t] * sin(vaf[t] - vat[t])), ) end @@ -1678,40 +1665,40 @@ function _voltage_products( ::String, from_bus::String, to_bus::String, - t::Int, ) jump_model = get_jump_model(container) vr = get_variable(container, VoltageReal, PSY.ACBus) vi = get_variable(container, VoltageImaginary, PSY.ACBus) - vr_fr, vr_to = vr[from_bus, t], vr[to_bus, t] - vi_fr, vi_to = vi[from_bus, t], vi[to_bus, t] + vr_fr, vr_to = vr[from_bus, :], vr[to_bus, :] + vi_fr, vi_to = vi[from_bus, :], vi[to_bus, :] + T = length(get_time_steps(container)) return ( - v2_fr = JuMP.@expression(jump_model, vr_fr^2 + vi_fr^2), - v2_to = JuMP.@expression(jump_model, vr_to^2 + vi_to^2), - vv_cos = JuMP.@expression(jump_model, vr_fr * vr_to + vi_fr * vi_to), - vv_sin = JuMP.@expression(jump_model, vi_fr * vr_to - vr_fr * vi_to), + v2_fr = JuMP.@expression(jump_model, [t=1:T], vr_fr[t]^2 + vi_fr[t]^2), + v2_to = JuMP.@expression(jump_model, [t=1:T], vr_to[t]^2 + vi_to[t]^2), + vv_cos = JuMP.@expression(jump_model, [t=1:T], vr_fr[t] * vr_to[t] + vi_fr[t] * vi_to[t]), + vv_sin = JuMP.@expression(jump_model, [t=1:T], vi_fr[t] * vr_to[t] - vr_fr[t] * vi_to[t]), ) end function _voltage_products( container::OptimizationContainer, ::NetworkModel{LPACCNetworkModel}, - ::Type{T}, + ::Type{D}, name::String, from_bus::String, to_bus::String, - t::Int, -) where {T <: PSY.ACTransmission} +) where {D <: PSY.ACTransmission} jump_model = get_jump_model(container) va = get_variable(container, VoltageAngle, PSY.ACBus) phi = get_variable(container, VoltageDeviation, PSY.ACBus) - cs = get_variable(container, CosineApproximation, T) + cs = get_variable(container, CosineApproximation, D) phi_fr, phi_to = phi[from_bus, t], phi[to_bus, t] + T = length(get_time_steps(container)) return ( - v2_fr = JuMP.@expression(jump_model, 1.0 + 2.0 * phi_fr), - v2_to = JuMP.@expression(jump_model, 1.0 + 2.0 * phi_to), - vv_cos = JuMP.@expression(jump_model, cs[name, t] + phi_fr + phi_to), - vv_sin = JuMP.@expression(jump_model, va[from_bus, t] - va[to_bus, t]), + v2_fr = JuMP.@expression(jump_model, [t=1:T], 1.0 + 2.0 * phi_fr[t]), + v2_to = JuMP.@expression(jump_model, [t=1:T], 1.0 + 2.0 * phi_to[t]), + vv_cos = JuMP.@expression(jump_model, [t=1:T], cs[name, t] + phi_fr[t] + phi_to[t]), + vv_sin = JuMP.@expression(jump_model, [t=1:T], va[from_bus, t] - va[to_bus, t]), ) end @@ -1732,7 +1719,6 @@ function _tapped_admittance(jump_model, adm, tap) ) end -# Voltage-only AC networks. function add_constraints!( container::OptimizationContainer, sys::PSY.System, @@ -1767,37 +1753,34 @@ function add_constraints!( from_bus = g_geom.from_name to_bus = g_geom.to_name + vp = _voltage_products(container, network_model, T, name, from_bus, to_bus) + tap_var = _tap_controlled(device_model, g_geom) ? get_variable(container, TapRatioVariable, T) : nothing for t in time_steps - vp = _voltage_products(container, network_model, T, name, from_bus, to_bus, t) - tap = if _tap_controlled(device_model, g_geom) - get_variable(container, TapRatioVariable, T)[name, t] - else - adm.tap - end + tap = isnothing(tap_var) ? adm.tap : tap_var[name, t] y = _tapped_admittance(jump_model, adm, tap) cons_pft[name, t] = JuMP.@constraint( jump_model, pft[name, t] == - y.g11 * vp.v2_fr + y.g12 * vp.vv_cos + y.b12 * vp.vv_sin + + y.g11 * vp.v2_fr[t] + y.g12 * vp.vv_cos[t] + y.b12 * vp.vv_sin[t] + _slack_term(slacks.p_ft, name, t) ) cons_ptf[name, t] = JuMP.@constraint( jump_model, ptf[name, t] == - y.g22 * vp.v2_to + y.g21 * vp.vv_cos - y.b21 * vp.vv_sin + + y.g22 * vp.v2_to[t] + y.g21 * vp.vv_cos[t] - y.b21 * vp.vv_sin[t] + _slack_term(slacks.p_tf, name, t), ) cons_qft[name, t] = JuMP.@constraint( jump_model, qft[name, t] == - -y.b11 * vp.v2_fr - y.b12 * vp.vv_cos + y.g12 * vp.vv_sin + + -y.b11 * vp.v2_fr[t] - y.b12 * vp.vv_cos[t] + y.g12 * vp.vv_sin[t] + _slack_term(slacks.q_ft, name, t), ) cons_qtf[name, t] = JuMP.@constraint( jump_model, qtf[name, t] == - -y.b22 * vp.v2_to - y.b21 * vp.vv_cos - y.g21 * vp.vv_sin + + -y.b22 * vp.v2_to[t] - y.b21 * vp.vv_cos[t] - y.g21 * vp.vv_sin[t] + _slack_term(slacks.q_tf, name, t), ) end @@ -1805,6 +1788,9 @@ function add_constraints!( return end +_iter_branches(ts::_TRANSFORMERS) = ((c, _circuit_arc_name(t, c, i)) for t in ts for (i, c) in enumerate(PSY.get_circuits(t))) +_iter_branches(ds) = ((d, PSY.get_name(d)) for d in ds) + _voltage_magnitude(container, name, ::NetworkModel{ACPNetworkModel}) = get_variable(container, VoltageMagnitude, PSY.ACBus)[name, :] _voltage_magnitude( @@ -1814,7 +1800,7 @@ _voltage_magnitude( ) = JuMP.@expression( get_jump_model(container), - [t in get_time_steps(container)], + [t=1:length(get_time_steps(container))], get_variable(container, VoltageReal, PSY.ACBus)[name, t]^2 + get_variable(container, VoltageImaginary, PSY.ACBus)[name, t]^2 ) @@ -1848,26 +1834,23 @@ function _add_voltage_control_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - for d in devices - for (i, circuit) in enumerate(PSY.get_circuits(d)) - _voltage_controlled(device_model, circuit) || continue - - bus = PSY.get_bus(sys, PSY.get_regulated_bus_number(circuit)) - bus_name = PSY.get_name(bus) - bus_limits = PSY.get_voltage_limits(bus) - ctl_limits = PSY.get_controlled_quantity_limits(circuit) - # TODO: temporary pending PSY#1755 - circuit_name = _circuit_arc_name(d, circuit, i) - (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error( - "Bus limits for $bus_name disagree with control limits for circuit $circuit_name.", - ) + for (circuit, circuit_name) in _iter_branches(devices) + _voltage_controlled(device_model, circuit) || continue + + bus = PSY.get_bus(sys, PSY.get_regulated_bus_number(circuit)) + bus_name = PSY.get_name(bus) + bus_limits = PSY.get_voltage_limits(bus) + ctl_limits = PSY.get_controlled_quantity_limits(circuit) + # TODO: temporary pending PSY#1755 + (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error( + "Bus voltage limits for $bus_name disagree with control limits for circuit $circuit_name.", + ) - lims = _voltage_limits(ctl_limits, network_model) - vm = _voltage_magnitude(container, bus_name, network_model) - for t in time_steps - cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[t] >= lims.min) - cons[circuit_name, 2, t] = JuMP.@constraint(jump_model, vm[t] <= lims.max) - end + lims = _voltage_limits(ctl_limits, network_model) + vm = _voltage_magnitude(container, bus_name, network_model) + for t in time_steps + cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[t] >= lims.min) + cons[circuit_name, 2, t] = JuMP.@constraint(jump_model, vm[t] <= lims.max) end end return @@ -1903,22 +1886,19 @@ function _add_reactive_control_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - for d in devices - for (i, circuit) in enumerate(PSY.get_circuits(d)) - _reactive_controlled(device_model, circuit) || continue - name = _circuit_arc_name(d, circuit, i) - lims = PSY.get_controlled_quantity_limits(circuit) + for (circuit, name) in _iter_branches(devices) + _reactive_controlled(device_model, circuit) || continue + lims = PSY.get_controlled_quantity_limits(circuit) - for t in time_steps - cons[name, 1, t] = - JuMP.@constraint(jump_model, qft[name, t] >= lims.min) - cons[name, 2, t] = - JuMP.@constraint(jump_model, qft[name, t] <= lims.max) - cons[name, 3, t] = - JuMP.@constraint(jump_model, qtf[name, t] >= lims.min) - cons[name, 4, t] = - JuMP.@constraint(jump_model, qtf[name, t] <= lims.max) - end + for t in time_steps + cons[name, 1, t] = + JuMP.@constraint(jump_model, qft[name, t] >= lims.min) + cons[name, 2, t] = + JuMP.@constraint(jump_model, qft[name, t] <= lims.max) + cons[name, 3, t] = + JuMP.@constraint(jump_model, qtf[name, t] >= lims.min) + cons[name, 4, t] = + JuMP.@constraint(jump_model, qtf[name, t] <= lims.max) end end return @@ -2091,53 +2071,29 @@ end ################################## IVRNetworkModel branch constraints ################## -_branch_arc(d::PSY.ACTransmission) = PSY.get_arc(d) -_branch_arc(d::PSY.TwoWindingTransformer) = PSY.get_arc(PSY.get_circuit(d)) - function _min_endpoint_voltage_limit(branch::PSY.ACTransmission) - arc = _branch_arc(branch) + arc = PSY.get_arc(branch) # bus voltage limits are already per-unit vmin_fr = PSY.get_voltage_limits(PSY.get_from(arc)).min vmin_to = PSY.get_voltage_limits(PSY.get_to(arc)).min return min(vmin_fr, vmin_to) end -# Series segments may themselves be parallel groups; recursion bottoms out at devices. function _min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) return minimum(_min_endpoint_voltage_limit(member) for member in entry) end -# Compute the per-unit current rating bound for an IVR branch variable. -# c_rating_a = rate_a / vmin (system-base power / per-unit voltage → per-unit current). -function _ivr_current_rating(branch::PSY.ACTransmission) - rate_a = _branch_rating(branch) - iszero(rate_a) && error( - "IVR: branch $(PSY.get_name(branch)) has zero rating — assign a non-zero thermal rating", - ) - vmin = _min_endpoint_voltage_limit(branch) - vmin <= 0.0 && error( - "IVR: branch $(PSY.get_name(branch)) has non-positive endpoint voltage minimum ($vmin)", - ) - return rate_a / vmin -end - -_ivr_current_rating(branch::PSY.ACTransmission, ::DeviceModel, ::String) = - _ivr_current_rating(branch) - -# Reduced-arc twin: equivalent rating from PNM (min over a series chain; the -# device-model attribute rule for parallel groups) over the minimum voltage bound -# across every member terminal — the corridor current traverses all of them. -function _ivr_current_rating( - entry::Union{PNM.BranchesSeries, PNM.AbstractBranchesParallel}, +function _current_rating_reduced( + entry::PNM.AbstractReductionAggregate, device_model::DeviceModel, entry_name::String, ) - rate_a = branch_rating(entry, device_model) + rate_a = _reduction_rating(entry, device_model) iszero(rate_a) && error( "IVR: reduced arc $(entry_name) has zero equivalent rating — assign non-zero \ thermal ratings to its member branches", ) - vmin = _min_endpoint_voltage_limit(entry) + vmin = min_endpoint_voltage_limit(entry) vmin <= 0.0 && error( "IVR: reduced arc $(entry_name) has a non-positive member voltage minimum ($vmin)", ) @@ -2159,9 +2115,8 @@ function add_variables!( if isempty(network_reduction) names = [PSY.get_name(d) for d in devices] var = add_variable_container!(container, V, T, names, time_steps) - for d in devices - c_rating = _ivr_current_rating(d) - name = PSY.get_name(d) + for (branch, name) in _iter_branches(devices) + c_rating = _current_rating(d, device_model, name) for t in time_steps var[name, t] = JuMP.@variable( jump_model, @@ -2183,7 +2138,7 @@ function add_variables!( for (name, (arc, reduction)) in get_name_to_arc_map_entries(network_reduction, T) entry = all_branch_maps_by_type[reduction][T][arc] has_entry, tracker_container = search_for_reduced_branch_variable!(tracker, arc, V) - c_rating = _ivr_current_rating(entry, device_model, name) + c_rating = _current_rating_reduced(entry, device_model, name) for t in time_steps if !has_entry tracker_container[t] = JuMP.@variable( @@ -2630,7 +2585,7 @@ function add_constraints!( end for g in geoms, t in time_steps - angle = va[g.from_name, t] - va[g.to_name, t] - g.shift_dc + angle = JuMP.@expression(jump_model, va[g.from_name, t] - va[g.to_name, t] - g.shift_dc) flow = if use_slacks JuMP.@expression( @@ -2644,7 +2599,7 @@ function add_constraints!( if _tap_controlled(device_model, g) JuMP.@constraint( jump_model, - flow * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle + flow * tap_var[g.name, t] == g.b_dc * angle * g.adm.tap ) else JuMP.@constraint(jump_model, flow == g.b_dc * angle) @@ -2940,7 +2895,7 @@ function _set_dcpll_flow_bounds!( if isempty(network_reduction) for d in devices name = PSY.get_name(d) - rate = _branch_rating(d) + rate = PSY.get_rating(d) iszero(rate) && error("Branch $name has a zero rating; cannot bound DCPLL flows.") for t in time_steps @@ -3055,12 +3010,12 @@ function add_constraints!( end for g in geoms, t in time_steps - angle = va[g.from_name, t] - va[g.to_name, t] - g.shift_dc + angle = JuMP.@expression(jump_model, va[g.from_name, t] - va[g.to_name, t] - g.shift_dc) cons[g.name, t] = if _tap_controlled(device_model, g) JuMP.@constraint( jump_model, - pft[g.name, t] * tap_var[g.name, t] == g.b_dc * g.adm.tap * angle + pft[g.name, t] * tap_var[g.name, t] == g.b_dc * angle * g.adm.tap ) else JuMP.@constraint(jump_model, pft[g.name, t] == g.b_dc * angle) diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 27dfcc7..1adc1d0 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -200,10 +200,8 @@ struct ReferenceBusConstraint <: ConstraintType end """Rectangular-coordinate voltage magnitude bounds: vmin² ≤ vr² + vi² ≤ vmax².""" struct VoltageMagnitudeConstraint <: ConstraintType end """ -Terminal reactive-flow band for a transformer circuit whose control objective is -`REACTIVE_POWER_FLOW`. Both directional flows are held inside the circuit's -`controlled_quantity_limits`. Sparse, indexed by (circuit name, side, time step) with -side ∈ 1:4 = (from-to lower, from-to upper, to-from lower, to-from upper). +Imposed by transformer circuits with REACTIVE_POWER_FLOW control on a branch +formulation with controls enabled. """ struct ReactivePowerFlowControlConstraint <: ConstraintType end """ diff --git a/src/utils/psy_utils.jl b/src/utils/psy_utils.jl index ca21f51..c201984 100644 --- a/src/utils/psy_utils.jl +++ b/src/utils/psy_utils.jl @@ -32,15 +32,5 @@ function get_available_turbines( ) end -_branch_rating(d::PSY.ACTransmission) = PSY.get_rating(d, PSY.SU) -_branch_rating(d::PSY.TwoWindingTransformer) = - PSY.get_rating(PSY.get_circuit(d), PSY.SU) -_branch_rating(d::PNM.ThreeWindingTransformerCircuit) = - PSY.get_rating(PSY.get_circuit(d), PSY.SU) - -_branch_rating_b(d::PSY.ACTransmission) = PSY.get_rating_b(d, PSY.SU) -_branch_rating_b(d::PSY.TwoWindingTransformer) = - PSY.get_rating_b(PSY.get_circuit(d), PSY.SU) - _negated_rating(rating::Float64) = -rating _negated_rating(::Nothing) = nothing From 69ce8fe30cbd49972c62f65ba150678801726fc3 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 18 Aug 2026 10:24:35 -0400 Subject: [PATCH 12/19] rest of review comments --- src/ac_transmission_models/AC_branches.jl | 6 +- src/network_models/reduction_exceptions.jl | 7 +- test/test_native_network_reductions.jl | 56 +- test/test_transformer_controls.jl | 576 ++++++--------------- test/test_transformer_fixed_tap.jl | 99 ---- 5 files changed, 201 insertions(+), 543 deletions(-) delete mode 100644 test/test_transformer_fixed_tap.jl diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 23f414e..252aeac 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -1702,8 +1702,8 @@ function _voltage_products( ) end -# Ybus terms, supporting Float64 and VariableRef taps. PNM's ybus functions -# use imaginary numbers which VariableRef doesn't support. +# PNM.ybus_branch_entries-adjacent: supports variable tap, separates +# condutance and susceptance. function _tapped_admittance(jump_model, adm, tap) g_cos, g_sin = adm.g * cos(adm.shift), adm.g * sin(adm.shift) b_cos, b_sin = adm.b * cos(adm.shift), adm.b * sin(adm.shift) @@ -1712,7 +1712,7 @@ function _tapped_admittance(jump_model, adm, tap) b11 = JuMP.@expression(jump_model, adm.b / tap^2 + adm.b_fr), g12 = JuMP.@expression(jump_model, (-g_cos + b_sin) / tap), b12 = JuMP.@expression(jump_model, (-b_cos - g_sin) / tap), - g21 = JuMP.@expression(jump_model, (-g_cos - b_sin) / tap), + g22 = JuMP.@expression(jump_model, (-g_cos - b_sin) / tap), b21 = JuMP.@expression(jump_model, (g_sin - b_cos) / tap), g22 = adm.g + adm.g_to, b22 = adm.b + adm.b_to, diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index 6c315ce..bef53fd 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -148,8 +148,8 @@ end _pin_model_all_branches!(::Set{Int}, ::DeviceModel) = nothing -# A transformer circuit with a defined control objective on a transformer with -# controls enabled must not be reduced away, nor can its regulated bus. +# A transformer circuit with a bus-based control objective on a transformer +# with controls enabled must not be reduced away, nor can its regulated bus. function _pin_transformer_controls!( buses::Set{Int}, m::DeviceModel{_TRANSFORMERS}, @@ -157,7 +157,8 @@ function _pin_transformer_controls!( _control_enabled(m) || return for transformer in get_device_cache(m) for circuit in PSY.get_circuits(transformer) - _control_enabled(m) || continue + PSY.get_available(m) || continue + PSY.get_control_objective(circuit) in (PSY.TransformerControlObjective.VOLTAGE,) || continue _push_component_buses!(buses, circuit) push!(buses, get_regulated_bus(circuit)) end diff --git a/test/test_native_network_reductions.jl b/test/test_native_network_reductions.jl index 28cae94..69fcea7 100644 --- a/test/test_native_network_reductions.jl +++ b/test/test_native_network_reductions.jl @@ -411,16 +411,52 @@ end # @test occursin("absorbed by a network reduction", log) end -# TODO: reenable with tap control -@testset "tap regulated-bus resolution errors for non-retained bus numbers" begin - # sys = PSB.build_system(PSITestSystems, "c_sys14") - # tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - # PSY.set_regulated_bus_number!(PSY.get_circuit(tr), 999) - # geom = POM._branch_geometry(tr) - # number_to_name = Dict(1 => "Bus 1") - # @test_throws ErrorException POM._tap_regulated_bus_name(tr, geom, number_to_name) - # bus_by_number = Dict(1 => PSY.get_from(PSY.get_arc(tr))) - # @test_throws ErrorException POM._tap_regulated_bus(tr, bus_by_number) +@testset "a controlled circuit survives the network reduction" begin + # Controlled transformers pin their endpoint buses irreducible, so the circuit keeps + # its own arc (and therefore its own tap variable) even with reductions requested. + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) + model, status = _build_controlled( + sys, + ACPNetworkModel; + optimizer = ipopt_optimizer, + reduce_radial_branches = true, + reduce_degree_two_branches = true, + ) + @test status == IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == + ["Trans1"] +end + +@testset "a controlled circuit merged with a parallel branch fails with a clear error" begin + sys, transformer, circuit, _ = _controlled_sys14(VOLTAGE_CONTROL) + arc = PSY.get_arc(circuit) + PSY.add_component!( + sys, + PSY.TwoWindingTransformer(; + name = "parallel_to_Trans1", + circuit = PSY.TransformerCircuit(; + available = true, + arc = arc, + r = PSY.get_r(circuit, PSY.SU), + x = PSY.get_x(circuit, PSY.SU), + tap = 1.0, + α = 0.0, + rating = PSY.get_rating(circuit, PSY.SU), + base_power = PSY.get_base_power(sys, PSY.NU), + ), + magnetizing_shunt = 0.0 + 0.0im, + shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, + ), + ) + template = _controlled_template(ACPNetworkModel) + model = DecisionModel(template, sys; optimizer = ipopt_optimizer) + out = mktempdir(; cleanup = true) + @test build!(model; output_dir = out, console_level = Logging.Error) == + IOM.ModelBuildStatus.FAILED + log = read(joinpath(out, "operation_problem.log"), String) + @test occursin("Controlled transformer circuit", log) + @test occursin(PSY.get_name(transformer), log) end @testset "ACP + StaticBranchBounds use_slacks wires flow-definition slacks per reduced arc" begin diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 93b6986..8132f73 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -1,43 +1,26 @@ -######################################################################################### -# Transformer tap controls: every `TransformerControlObjective` other than FIXED / -# UNDEFINED. Control is opted into per `DeviceModel` with the `enable_controls` attribute; -# when it is on, each `TransformerCircuit`'s `control_objective` decides what is built. -# A controlled circuit gets a `TapRatioVariable` bounded by `control_limits`, and the -# controlled quantity is held inside `controlled_quantity_limits`. -# -# Fixed / off-nominal tap physics (tap as a constant component property) lives in -# `test_transformer_fixed_tap.jl` — do not duplicate it here. -######################################################################################### - const VOLTAGE_CONTROL = PSY.TransformerControlObjective.VOLTAGE const Q_FLOW_CONTROL = PSY.TransformerControlObjective.REACTIVE_POWER_FLOW -const P_FLOW_CONTROL = PSY.TransformerControlObjective.ACTIVE_POWER_FLOW +const TAP_CONTROLS = (VOLTAGE_CONTROL, Q_FLOW_CONTROL) + +const VOLTAGE_NETWORKS = (ACPNetworkModel, ACRNetworkModel, LPACCNetworkModel) +const AC_NETWORKS = (VOLTAGE_NETWORKS..., IVRNetworkModel) +const DC_NETWORKS = (DCPNetworkModel, DCPLLNetworkModel) +const ALL_NETWORKS = (AC_NETWORKS..., DC_NETWORKS...) -_control_attributes(enable::Bool) = - Dict{String, Any}(POM.ENABLE_CONTROLS_KEY => enable) +const TRANFORMER_NAMES = ["Trans1", "Trans2", "Trans3", "Trans4"] -""" -`c_sys14` with one transformer circuit put under `objective`. `regulated` picks which end -of the circuit's arc is regulated (the bus number, not a sentinel — the API takes the -number of either the from or the to bus). Returns the system, the transformer, its -circuit, and the regulated bus name. -""" function _controlled_sys14( objective; name = "Trans1", - regulated = :to, - # c_sys14 buses carry (0.94, 1.06) voltage limits; a VOLTAGE band has to sit inside - # the regulated bus's own limits. + regulated = 9, quantity_limits = (min = 0.95, max = 1.05), control_limits = (min = 0.9, max = 1.1), ) sys = PSB.build_system(PSITestSystems, "c_sys14") transformer = PSY.get_component(PSY.TwoWindingTransformer, sys, name) circuit = PSY.get_circuit(transformer) - arc = PSY.get_arc(circuit) - bus = regulated == :from ? PSY.get_from(arc) : PSY.get_to(arc) PSY.set_control_objective!(circuit, objective) - PSY.set_regulated_bus_number!(circuit, PSY.get_number(bus)) + PSY.set_regulated_bus_number!(circuit, regulated) PSY.set_controlled_quantity_limits!(circuit, quantity_limits) PSY.set_control_limits!(circuit, control_limits) return sys, transformer, circuit, PSY.get_name(bus) @@ -56,7 +39,9 @@ function _controlled_template( DeviceModel( PSY.TwoWindingTransformer, formulation; - attributes = _control_attributes(enable), + attributes = Dict( + POM.ENABLE_CONTROLS_KEY => enable + ) ), ) return template @@ -83,29 +68,6 @@ _has_tap_variable(container) = ################################### attribute plumbing ################################# -@testset "enable_controls is a transformer-only attribute defaulting to false" begin - for T in (PSY.TwoWindingTransformer, PSY.ThreeWindingTransformer) - attributes = POM.get_default_attributes(T, StaticBranch) - @test haskey(attributes, POM.ENABLE_CONTROLS_KEY) - @test attributes[POM.ENABLE_CONTROLS_KEY] === false - end - # Non-transformer branches carry no control switch at all. - @test !haskey( - POM.get_default_attributes(PSY.Line, StaticBranch), - POM.ENABLE_CONTROLS_KEY, - ) - - # The attribute survives onto the DeviceModel and merges with the other defaults. - device_model = DeviceModel( - PSY.TwoWindingTransformer, - StaticBranch; - attributes = _control_attributes(true), - ) - @test IOM.get_attribute(device_model, POM.ENABLE_CONTROLS_KEY) === true - @test IOM.get_attribute(device_model, POM.PARALLEL_BRANCH_MAX_RATING_KEY) == - "single_element_contingency" -end - @testset "a controlled circuit builds no tap variable while enable_controls is off" begin sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) model, status = @@ -116,52 +78,23 @@ end @testset "TapRatioVariable is created only for controlled circuits, bounded by control_limits" begin limits = (min = 0.95, max = 1.05) - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; control_limits = limits) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT + for mode in TAP_CONTROLS + sys, _, _, _ = _controlled_sys14(mode; control_limits = limits) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT - container = IOM.get_optimization_container(model) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - # Trans2 / Trans3 are left UNDEFINED, so only the controlled circuit gets a variable. - @test axes(tap)[1] == ["Trans1"] - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) - for v in tap - @test JuMP.lower_bound(v) == limits.min - @test JuMP.upper_bound(v) == limits.max + container = IOM.get_optimization_container(model) + tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + # Trans2 / Trans3 are left UNDEFINED, so only the controlled circuit gets a variable. + @test axes(tap)[1] == ["Trans1"] + @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) end end -@testset "REACTIVE_POWER_FLOW control also creates a tap variable" begin - sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - container = IOM.get_optimization_container(model) - @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == - ["Trans1"] -end - -@testset "ACTIVE_POWER_FLOW is a phase-shift objective, not a tap control" begin - # The tap controls cover the voltage / reactive-power objectives; an active-power - # (phase-shifting) circuit must not silently acquire a tap variable. - sys, _, _, _ = _controlled_sys14(P_FLOW_CONTROL) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test !_has_tap_variable(IOM.get_optimization_container(model)) -end - -@testset "a DISABLED objective builds no control" begin - sys, _, _, _ = - _controlled_sys14(PSY.TransformerControlObjective.VOLTAGE_DISABLED) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test !_has_tap_variable(IOM.get_optimization_container(model)) -end - ################################### VOLTAGE objective ################################## -# Solve `c_sys14` once with the transformer uncontrolled and report the regulated bus -# voltage, so each control test can aim its band away from the free-running solution and -# prove the constraint actually bites. +# Solve system with no controls to get bus voltage reference to make sure our +# control constraint tests are doing something. function _uncontrolled_voltage(bus_name; network_formulation = ACPNetworkModel) sys = PSB.build_system(PSITestSystems, "c_sys14") model, status = @@ -178,78 +111,52 @@ function _uncontrolled_voltage(bus_name; network_formulation = ACPNetworkModel) return vm[1, bus_name] end -@testset "VOLTAGE control holds the regulated bus inside its band (ACP, to-side)" begin - _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) - free_vm = _uncontrolled_voltage(bus_name) - # A band the free-running solution violates, so holding it requires the tap to move. - # It sits below `free_vm`: the free-running voltage rides near the bus's 1.06 upper - # limit, and the band may not reach outside the bus's own limits. - band = (min = free_vm - 0.02, max = free_vm - 0.01) +@testset "VOLTAGE control holds the regulated bus inside its limits" begin + rawsys = PSB.build_system(PSITestSystems, "c_sys14") + buses = PSY.get_components(PSY.ACBus, rawsys) + for network_formulation in VOLTAGE_NETWORKS, bus in buses + bus_name = PSY.get_name(bus) + free_vm = _uncontrolled_voltage(bus_name) + limits = (min = free_vm - 0.02, max = free_vm - 0.01) - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) - @test bus_name in names(vm) - for r in 1:nrow(vm) - @test vm[r, bus_name] >= band.min - 1e-6 - @test vm[r, bus_name] <= band.max + 1e-6 - end - # The band is on the voltage itself, not on its square. - @test !(free_vm >= band.min - 1e-6 && free_vm <= band.max + 1e-6) - - tap = read_variable( - res, "TapRatioVariable__TwoWindingTransformer"; table_format = TableFormat.WIDE, - ) - for r in 1:nrow(tap) - @test tap[r, "Trans1"] >= 0.9 - 1e-6 - @test tap[r, "Trans1"] <= 1.1 + 1e-6 - end -end - -@testset "VOLTAGE control regulates the from-side bus when its number is given" begin - _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL; regulated = :from) - free_vm = _uncontrolled_voltage(bus_name) - band = (min = free_vm - 0.02, max = free_vm - 0.01) - - sys, _, _, _ = - _controlled_sys14(VOLTAGE_CONTROL; regulated = :from, quantity_limits = band) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; regulated = PSY.get_number(bus), quantity_limits = limits) + model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = IOM.OptimizationProblemOutputs(model) - vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) - for r in 1:nrow(vm) - @test vm[r, bus_name] >= band.min - 1e-6 - @test vm[r, bus_name] <= band.max + 1e-6 + res = IOM.OptimizationProblemOutputs(model) + vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) + @test bus_name in names(vm) + for r in 1:nrow(vm) + @test vm[r, bus_name] >= limits.min - 1e-6 + @test vm[r, bus_name] <= limits.max + 1e-6 + end end end ############################ REACTIVE_POWER_FLOW objective ############################# -@testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its band (ACP)" begin - # `controlled_quantity_limits` reaches the constraint builder unconverted, so it is - # read as system-base pu here; the reported flow is MVAR and divided back down. - band = (min = -0.05, max = 0.05) - sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = band) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +@testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its limits" begin + limits = (min = -0.05, max = 0.05) - res = IOM.OptimizationProblemOutputs(model) - base = IOM.get_model_base_power(res) - for key in ( - "FlowReactivePowerFromToVariable__TwoWindingTransformer", - "FlowReactivePowerToFromVariable__TwoWindingTransformer", - ) - flow = read_variable(res, key; table_format = TableFormat.WIDE) - for r in 1:nrow(flow) - @test flow[r, "Trans1"] / base >= band.min - 1e-6 - @test flow[r, "Trans1"] / base <= band.max + 1e-6 + # TODO: Is this excessive to be looping all networks and transformers? (I also do this later) + for network_formulation in AC_NETWORKS, name in TRANFORMER_NAMES + sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = limits, name = name) + model, status = _build_controlled(sys, network; optimizer = ipopt_optimizer) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + base = IOM.get_model_base_power(res) + for key in ( + "FlowReactivePowerFromToVariable__TwoWindingTransformer", + "FlowReactivePowerToFromVariable__TwoWindingTransformer", + ) + flow = read_variable(res, key; table_format = TableFormat.WIDE) + for r in 1:nrow(flow) + @test flow[r, name] >= limits.min - 1e-6 + @test flow[r, name] <= limits.max + 1e-6 + end end end end @@ -257,44 +164,32 @@ end ################################### model invariants ################################### @testset "a tap pinned at nominal reproduces the uncontrolled model" begin - # White-box reduction gate: with the tap variable fixed at the circuit's nominal - # ratio and a band too wide to bind, the controlled Ohm's law is term-by-term the - # fixed-tap one, so both models must reach the same optimum and terminal flows. - # The band is the regulated bus's own (0.94, 1.06) limits — the widest a VOLTAGE band - # may be — so the control constrains nothing the bus does not already. - # - # IVR is the sharpest case: its law is multiplied through by the ratio, so every - # tm-bearing term has to reduce exactly for the flows to match. - band = (min = 0.94, max = 1.06) + limits = (min = 0.94, max = 1.06) tap_range = (min = 0.5, max = 1.5) - for network_formulation in ( - ACPNetworkModel, - ACRNetworkModel, - LPACCNetworkModel, - IVRNetworkModel, - ) - sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) + branch_formulation(::Union{DCPNetworkModel, DCPLLNetworkModel}) = StaticBranchBounds + branch_formulation(_) = StaticBranch + + for network_formulation in ALL_NETWORKS, name in TRANFORMER_NAMES + sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = limits, name = name) model_fixed, status_fixed = _build_controlled( sys_fixed, network_formulation; enable = false, - optimizer = ipopt_optimizer, + optimizer = ipopt_optimizer, formulation = branch_formulation(network_formulation) ) @test status_fixed == IOM.ModelBuildStatus.BUILT @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED sys_var, transformer, circuit, _ = _controlled_sys14( - VOLTAGE_CONTROL; quantity_limits = band, control_limits = tap_range, - ) - model_var, status_var = _build_controlled( - sys_var, network_formulation; optimizer = ipopt_optimizer, + VOLTAGE_CONTROL; quantity_limits = limits, control_limits = tap_range, name = name ) + model_var, status_var = _build_controlled(sys_var, network_formulation; optimizer = ipopt_optimizer, formulation = branch_formulation(network_formulation)) @test status_var == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(model_var) tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - for t in axes(tap, 2) + for t in get_time_steps(container) JuMP.fix( - tap[PSY.get_name(transformer), t], PSY.get_tap(circuit); force = true, + tap[name, t], PSY.get_tap(circuit); force = true, ) end @test solve!(model_var) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -312,275 +207,100 @@ end ) flow_fixed = read_variable(res_fixed, key; table_format = TableFormat.WIDE) flow_var = read_variable(res_var, key; table_format = TableFormat.WIDE) - for d in PSY.get_components(PSY.TwoWindingTransformer, sys_var) - name = PSY.get_name(d) - @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) - end + @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) end end end -@testset "NetworkFlowConstraint carries the live tap variable (ACP coefficients)" begin - # Ground truth: the built from-to flow constraint must use exactly the - # `_tapped_admittance` terms evaluated at the tap VARIABLE. Evaluate - # `constraint_object(con).func` at an arbitrary point and compare against the - # hand-assembled right-hand side. - sys, transformer, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(model) - pft = IOM.get_variable( - container, - FlowActivePowerFromToVariable, - PSY.TwoWindingTransformer, - ) - vm = IOM.get_variable(container, VoltageMagnitude, PSY.ACBus) - va = IOM.get_variable(container, VoltageAngle, PSY.ACBus) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - con_pft = IOM.get_constraint( - container, POM.NetworkFlowConstraint, PSY.TwoWindingTransformer, "p_ft", - ) - - t = 1 - name = PSY.get_name(transformer) - arc = PSY.get_arc(PSY.get_circuit(transformer)) - fr = PSY.get_name(PSY.get_from(arc)) - to = PSY.get_name(PSY.get_to(arc)) - adm = PNM.branch_admittance(transformer) - - vals = Dict{JuMP.VariableRef, Float64}( - vm[fr, t] => 1.02, vm[to, t] => 0.98, - va[fr, t] => 0.05, va[to, t] => -0.03, - tap[name, t] => 1.05, - pft[name, t] => 0.7, - ) - lookup = z -> vals[z] - - y = POM._tapped_admittance(get_jump_model(container), adm, vals[tap[name, t]]) - vmf = vals[vm[fr, t]] - vmt = vals[vm[to, t]] - θ = vals[va[fr, t]] - vals[va[to, t]] - rhs = y.g11 * vmf^2 + y.g12 * vmf * vmt * cos(θ) + y.b12 * vmf * vmt * sin(θ) - # `func` is stored as (lhs - rhs). - @test isapprox( - JuMP.value(lookup, JuMP.constraint_object(con_pft[name, t]).func), - vals[pft[name, t]] - rhs; - atol = 1e-10, - ) -end - -################################### network coverage ################################### +@testset "_tapped_admittance round-trips PNM.ybus_branch_entries" begin + function check_terms(y, ybus) + Y11, Y12, Y21, Y22 = ybus + @test isapprox(complex(y.g11, y.b11), Y11; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g12, y.b12), Y12; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g21, y.b21), Y21; rtol = 1e-10, atol = 1e-12) + @test isapprox(complex(y.g22, y.b22), Y22; rtol = 1e-10, atol = 1e-12) + end -@testset "VOLTAGE control is wired on every voltage-carrying AC network" begin - for network_formulation in (ACRNetworkModel, IVRNetworkModel, LPACCNetworkModel) - _, _, _, bus_name = _controlled_sys14(VOLTAGE_CONTROL) - band = (min = 1.00, max = 1.02) - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = band) - model, status = - _build_controlled(sys, network_formulation; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test _has_tap_variable(IOM.get_optimization_container(model)) - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + model = JuMP.Model() + sys = PSB.build_system(PSITestSystems, "c_sys14") + for br in Iterators.flatten(( + PSY.get_components(PSY.Line, sys), + PSY.get_components(PSY.TwoWindingTransformer, sys), + )) + adm = PNM.branch_admittance(br) + check_terms( + POM._tapped_admittance(model, adm, adm.tap), + PNM.ybus_branch_entries(br), + ) + end - res = IOM.OptimizationProblemOutputs(model) - if network_formulation == LPACCNetworkModel - phi = read_variable( - res, "VoltageDeviation__ACBus"; table_format = TableFormat.WIDE, - ) - magnitude = 1.0 + phi[1, bus_name] - else - vr = read_variable( - res, - "VoltageReal__ACBus"; - table_format = TableFormat.WIDE, + tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") + circuit = PSY.get_circuit(tr) + for shift in (-pi / 5, 0.0, pi / 6) + PSY.set_α!(circuit, shift) + PSY.set_tap!(circuit, 1.0) + adm = PNM.branch_admittance(tr) + for tap in (0.9, 1.0, 1.1, 1.25) + PSY.set_tap!(circuit, tap) + check_terms( + POM._tapped_admittance(model, adm, tap), + PNM.ybus_branch_entries(tr), ) - vi = read_variable( - res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE, - ) - magnitude = sqrt(vr[1, bus_name]^2 + vi[1, bus_name]^2) end - @test magnitude >= band.min - 1e-4 - @test magnitude <= band.max + 1e-4 - - # The band is written on the network's own voltage variables, so no per-device - # RegulatedVoltageMagnitude aux is introduced on any of these networks. - container = IOM.get_optimization_container(model) - @test !IOM.has_container_key( - container, RegulatedVoltageMagnitude, PSY.TwoWindingTransformer, - ) - # One controlled circuit, two rows (lower/upper) per time step. - @test length( - IOM.get_constraint( - container, VoltageMagnitudeConstraint, PSY.TwoWindingTransformer, - ), - ) == 2 * length(IOM.get_time_steps(container)) end end -@testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its band (IVR)" begin - # IVR builds its own current-based flow constraints, so the band has to be applied - # there too and not only on the shared pi-model path. - band = (min = -0.05, max = 0.05) - sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = band) - model, status = _build_controlled(sys, IVRNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT +################################ static tap ############################################ + +@testset "StaticBranch models transformer off-nominal tap under DCP (c_sys14)" begin + sys = PSB.build_system(PSITestSystems, "c_sys14") + template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) + set_device_model!(template, PSY.TwoWindingTransformer, StaticBranch) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = IOM.OptimizationProblemOutputs(model) base = IOM.get_model_base_power(res) - for key in ( - "FlowReactivePowerFromToVariable__TwoWindingTransformer", - "FlowReactivePowerToFromVariable__TwoWindingTransformer", + # StaticBranch under DCP has no FlowActivePowerVariable: the flow IS the + # BThetaBranchFlow expression, reported in natural units (MW). VoltageAngle is + # unitless (radians, no conversion), so compare in per-unit. + pflow = read_expression( + res, + "BThetaBranchFlow__TwoWindingTransformer"; + table_format = TableFormat.WIDE, ) - flow = read_variable(res, key; table_format = TableFormat.WIDE) - for r in 1:nrow(flow) - @test flow[r, "Trans1"] / base >= band.min - 1e-6 - @test flow[r, "Trans1"] / base <= band.max + 1e-6 + va = read_variable(res, "VoltageAngle__ACBus"; table_format = TableFormat.WIDE) + + tested_a_real_tap = false + for tr in PSY.get_components(PSY.TwoWindingTransformer, sys) + name = PSY.get_name(tr) + @test name in names(pflow) + + # Recover the series reactance independently, from the π-model admittance, so the + # oracle does not simply re-call the susceptance helper the source uses. + adm = PNM.branch_admittance(tr) + x = -adm.b / (adm.g^2 + adm.b^2) + # The DC susceptance is tap-divided: b_dc == 1/(tap*x). Pin the equivalence of the + # independent recovery and PNM's DC entry point. + @test 1 / (x * adm.tap) ≈ PNM.get_series_susceptance(tr, PSY.SU) + + arc = PSY.get_arc(PSY.get_circuit(tr)) + fr = PSY.get_name(PSY.get_from(arc)) + to = PSY.get_name(PSY.get_to(arc)) + shift = PNM.get_series_phase_shift(tr) + if !isapprox(adm.tap, 1.0; atol = 1e-6) + tested_a_real_tap = true + end + for r in 1:nrow(pflow) + p_pu = pflow[r, name] / base + expected = (va[r, fr] - va[r, to] - shift) / (x * adm.tap) + @test isapprox(p_pu, expected; atol = 1e-5) end end -end - -################################### DC networks ######################################## - -@testset "DC networks build a variable tap under StaticBranchBounds" begin - tap_range = (min = 0.9, max = 1.1) - for network_formulation in (DCPNetworkModel, DCPLLNetworkModel) - sys, _, _, _ = - _controlled_sys14(VOLTAGE_CONTROL; control_limits = tap_range) - model, status = _build_controlled( - sys, - network_formulation; - formulation = StaticBranchBounds, - optimizer = ipopt_optimizer, - ) - @test status == IOM.ModelBuildStatus.BUILT - container = IOM.get_optimization_container(model) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - @test axes(tap)[1] == ["Trans1"] - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) - # Neither quantity exists on a DC network, so no band is built either way. - @test !IOM.has_container_key( - container, VoltageMagnitudeConstraint, PSY.TwoWindingTransformer, - ) - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - end -end - -@testset "the DC tap law reduces to the fixed-tap law at the nominal ratio" begin - # The bilinear DC law is multiplied through by the ratio, so evaluating the built row at - # the nominal ratio must reproduce `nominal * (p - b_dc * (va_fr - va_to - shift))`. - # Checked on the constraint itself rather than by comparing two solves: c_sys14's - # transformer ratings make a fixed-tap StaticBranchBounds DCP model infeasible (that - # formulation enforces the rating as hard variable bounds), so there is no fixed-tap - # reference solution to compare against on this system. - sys, transformer, circuit, _ = _controlled_sys14(VOLTAGE_CONTROL) - model, status = _build_controlled( - sys, DCPNetworkModel; - formulation = StaticBranchBounds, optimizer = ipopt_optimizer, - ) - @test status == IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(model) - p = IOM.get_variable(container, FlowActivePowerVariable, PSY.TwoWindingTransformer) - va = IOM.get_variable(container, VoltageAngle, PSY.ACBus) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - cons = - IOM.get_constraint(container, POM.NetworkFlowConstraint, PSY.TwoWindingTransformer) - - t = 1 - name = PSY.get_name(transformer) - arc = PSY.get_arc(circuit) - fr = PSY.get_name(PSY.get_from(arc)) - to = PSY.get_name(PSY.get_to(arc)) - nominal = PNM.branch_admittance(transformer).tap - b_dc = PNM.get_series_susceptance(transformer, PSY.SU) - shift = PNM.get_series_phase_shift(transformer) - @test !isapprox(nominal, 1.0; atol = 1e-6) - - vals = Dict{JuMP.VariableRef, Float64}( - va[fr, t] => 0.05, va[to, t] => -0.03, - tap[name, t] => nominal, - p[name, t] => 0.7, - ) - lookup = z -> vals[z] - - angle = vals[va[fr, t]] - vals[va[to, t]] - shift - @test isapprox( - JuMP.value(lookup, JuMP.constraint_object(cons[name, t]).func), - nominal * (vals[p[name, t]] - b_dc * angle); - atol = 1e-10, - ) -end - -################################ reductions and conflicts ############################## - -@testset "a controlled circuit survives the network reduction" begin - # Controlled transformers pin their endpoint buses irreducible, so the circuit keeps - # its own arc (and therefore its own tap variable) even with reductions requested. - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - model, status = _build_controlled( - sys, - ACPNetworkModel; - optimizer = ipopt_optimizer, - reduce_radial_branches = true, - reduce_degree_two_branches = true, - ) - @test status == IOM.ModelBuildStatus.BUILT - container = IOM.get_optimization_container(model) - @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == - ["Trans1"] -end - -@testset "a controlled circuit merged with a parallel branch fails with a clear error" begin - # PNM collapses parallel branches onto one equivalent arc before POM sees them, which - # would leave the control acting on a flow that is not the transformer's own. - # Trans1's arc spans a voltage change, so the parallel branch has to be another - # transformer: PSY rejects a Line whose endpoints differ in base voltage. - sys, transformer, circuit, _ = _controlled_sys14(VOLTAGE_CONTROL) - arc = PSY.get_arc(circuit) - PSY.add_component!( - sys, - PSY.TwoWindingTransformer(; - name = "parallel_to_Trans1", - circuit = PSY.TransformerCircuit(; - available = true, - arc = arc, - r = PSY.get_r(circuit, PSY.SU), - x = PSY.get_x(circuit, PSY.SU), - tap = 1.0, - α = 0.0, - rating = PSY.get_rating(circuit, PSY.SU), - base_power = PSY.get_base_power(sys, PSY.NU), - ), - magnetizing_shunt = 0.0 + 0.0im, - shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, - ), - ) - template = _controlled_template(ACPNetworkModel) - model = DecisionModel(template, sys; optimizer = ipopt_optimizer) - out = mktempdir(; cleanup = true) - @test build!(model; output_dir = out, console_level = Logging.Error) == - IOM.ModelBuildStatus.FAILED - log = read(joinpath(out, "operation_problem.log"), String) - @test occursin("Controlled transformer circuit", log) - @test occursin(PSY.get_name(transformer), log) -end - -# `case11_network_reductions` is the purpose-built reducible system (c_sys14 reduces -# nothing); it carries no forecast, which a DecisionModel build requires. -function _case11_with_forecast() - sys = PSB.build_system(PSITestSystems, "case11_network_reductions") - dummy_data = Dict( - DateTime("2020-01-01T08:00:00") => [5.0, 6, 7, 7, 7], - DateTime("2020-01-01T08:30:00") => [9.0, 9, 9, 9, 8], - DateTime("2020-01-01T09:00:00") => [6.0, 6, 5, 5, 4], - ) - dummy_forecast = Deterministic("max_active_power", dummy_data, Dates.Minute(5)) - load = first(PSY.get_components(PSY.StandardLoad, sys)) - PSY.add_time_series!(sys, load, dummy_forecast) - return sys + # Guard: the test system must actually carry a non-unit tap, else this proves nothing. + @test tested_a_real_tap end ######################################################################################### diff --git a/test/test_transformer_fixed_tap.jl b/test/test_transformer_fixed_tap.jl deleted file mode 100644 index a363c80..0000000 --- a/test/test_transformer_fixed_tap.jl +++ /dev/null @@ -1,99 +0,0 @@ -######################################################################################### -# Fixed off-nominal transformer tap under the native network models: the tap as a constant -# component property, with no control block (FIXED / UNDEFINED control objectives). Covers -# the DC susceptance `b_dc = 1/(tap*x)` used by `BThetaBranchFlow`/`NetworkFlowConstraint` -# and the Ybus two-port terms in `_tapped_admittance` (both in -# `ac_transmission_models/AC_branches.jl`). -# -# Tap CONTROL — the tap as a decision variable under a `TransformerControlObjective` — is -# covered by `test_transformer_controls.jl`. -######################################################################################### - -@testset "StaticBranch models transformer off-nominal tap under DCP (c_sys14)" begin - sys = PSB.build_system(PSITestSystems, "c_sys14") - template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) - set_device_model!(template, PSY.TwoWindingTransformer, StaticBranch) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = IOM.OptimizationProblemOutputs(model) - base = IOM.get_model_base_power(res) - # StaticBranch under DCP has no FlowActivePowerVariable: the flow IS the - # BThetaBranchFlow expression, reported in natural units (MW). VoltageAngle is - # unitless (radians, no conversion), so compare in per-unit. - pflow = read_expression( - res, - "BThetaBranchFlow__TwoWindingTransformer"; - table_format = TableFormat.WIDE, - ) - va = read_variable(res, "VoltageAngle__ACBus"; table_format = TableFormat.WIDE) - - tested_a_real_tap = false - for tr in PSY.get_components(PSY.TwoWindingTransformer, sys) - name = PSY.get_name(tr) - @test name in names(pflow) - - # Recover the series reactance independently, from the π-model admittance, so the - # oracle does not simply re-call the susceptance helper the source uses. - adm = PNM.branch_admittance(tr) - x = -adm.b / (adm.g^2 + adm.b^2) - # The DC susceptance is tap-divided: b_dc == 1/(tap*x). Pin the equivalence of the - # independent recovery and PNM's DC entry point. - @test 1 / (x * adm.tap) ≈ PNM.get_series_susceptance(tr, PSY.SU) - - arc = PSY.get_arc(PSY.get_circuit(tr)) - fr = PSY.get_name(PSY.get_from(arc)) - to = PSY.get_name(PSY.get_to(arc)) - shift = PNM.get_series_phase_shift(tr) - if !isapprox(adm.tap, 1.0; atol = 1e-6) - tested_a_real_tap = true - end - for r in 1:nrow(pflow) - p_pu = pflow[r, name] / base - expected = (va[r, fr] - va[r, to] - shift) / (x * adm.tap) - @test isapprox(p_pu, expected; atol = 1e-5) - end - end - # Guard: the test system must actually carry a non-unit tap, else this proves nothing. - @test tested_a_real_tap -end - -@testset "_tapped_admittance round-trips PNM.ybus_branch_entries" begin - function check_terms(y, ybus) - Y11, Y12, Y21, Y22 = ybus - @test isapprox(complex(y.g11, y.b11), Y11; rtol = 1e-10, atol = 1e-12) - @test isapprox(complex(y.g12, y.b12), Y12; rtol = 1e-10, atol = 1e-12) - @test isapprox(complex(y.g21, y.b21), Y21; rtol = 1e-10, atol = 1e-12) - @test isapprox(complex(y.g22, y.b22), Y22; rtol = 1e-10, atol = 1e-12) - end - - model = JuMP.Model() - sys = PSB.build_system(PSITestSystems, "c_sys14") - for br in Iterators.flatten(( - PSY.get_components(PSY.Line, sys), - PSY.get_components(PSY.TwoWindingTransformer, sys), - )) - adm = PNM.branch_admittance(br) - check_terms( - POM._tapped_admittance(model, adm, adm.tap), - PNM.ybus_branch_entries(br), - ) - end - - tr = PSY.get_component(PSY.TwoWindingTransformer, sys, "Trans1") - circuit = PSY.get_circuit(tr) - for shift in (-pi / 5, 0.0, pi / 6) - PSY.set_α!(circuit, shift) - PSY.set_tap!(circuit, 1.0) - adm = PNM.branch_admittance(tr) - for tap in (0.9, 1.0, 1.1, 1.25) - PSY.set_tap!(circuit, tap) - check_terms( - POM._tapped_admittance(model, adm, tap), - PNM.ybus_branch_entries(tr), - ) - end - end -end From 7ea2ee99a32215b84f9314c63b3f7e5589ee35e2 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 18 Aug 2026 12:13:41 -0400 Subject: [PATCH 13/19] claude refactor add_variables! --- src/ac_transmission_models/AC_branches.jl | 287 +++++------------- .../branch_constructor.jl | 173 ++++------- .../security_constrained_branch.jl | 34 +-- src/network_models/reduction_exceptions.jl | 4 +- test/test_transformer_controls.jl | 2 +- 5 files changed, 133 insertions(+), 367 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 252aeac..5ffa046 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -189,117 +189,94 @@ function _parallel_branches_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel end #################################### Flow Variable Bounds ################################################## -function add_variables!( - container::OptimizationContainer, - ::Type{T}, - network_model::NetworkModel{<:AbstractPTDFNetworkModel}, - devices::IS.FlattenIteratorWrapper{U}, - ::Type{F}, -) where { - T <: AbstractACActivePowerFlow, - U <: PSY.ACTransmission, - F <: AbstractBranchFormulation} - time_steps = get_time_steps(container) - net_reduction_data = get_network_reduction(network_model) - branch_names = get_branch_argument_variable_axis(net_reduction_data, devices) - reduced_branch_tracker = get_reduced_branch_tracker(network_model) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) +# Bounds are the only thing that varies across the branch variable families, so they are the +# only thing `add_variables!` dispatches on. `entry` is the device itself for a direct branch +# or PNM's equivalent for a reduced arc — the same contract `_directional_flow_rating` uses. +_branch_variable_bounds( + ::Type{V}, + entry, + ::DeviceModel{T, F}, +) where {V <: VariableType, T <: PSY.ACTransmission, F <: AbstractBranchFormulation} = + (get_variable_lower_bound(V, entry, F), get_variable_upper_bound(V, entry, F)) - variable_container = add_variable_container!( - container, - T, - U, - branch_names, - time_steps, - ) +_branch_variable_bounds( + ::Type{CosineApproximation}, + entry, + ::DeviceModel{T, F}, +) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} = + _lpacc_cosine_bounds(entry) - for (name, (arc, reduction)) in PNM.get_name_to_arc_map(net_reduction_data, U) - # TODO: entry is not type stable here, it can return any type ACTransmission. - # It might have performance implications. Possibly separate this into other functions - reduction_entry = all_branch_maps_by_type[reduction][U][arc] - has_entry, tracker_container = search_for_reduced_branch_argument!( - reduced_branch_tracker, - arc, - T, - ) - if has_entry - @assert !isempty(tracker_container) name arc reduction - end - ub = get_variable_upper_bound(T, reduction_entry, F) - lb = get_variable_lower_bound(T, reduction_entry, F) - for t in time_steps - if !has_entry - tracker_container[t] = JuMP.@variable( - get_jump_model(container), - base_name = "$(T)_$(U)_$(reduction)_{$(name), $(t)}", - ) - ub !== nothing && JuMP.set_upper_bound(tracker_container[t], ub) - lb !== nothing && JuMP.set_lower_bound(tracker_container[t], lb) - end - variable_container[name, t] = tracker_container[t] - end - end - return +function _branch_variable_bounds( + ::Type{<:AbstractBranchCurrentVariable}, + entry, + device_model::DeviceModel{T, F}, +) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} + rating = _current_rating(entry, device_model) + return (-rating, rating) end +# LPAC's cosine variable needs a feasible starting point for the NLP solve; the flow and +# current families take JuMP's default. +_branch_variable_start(::Type{<:VariableType}) = nothing +_branch_variable_start(::Type{CosineApproximation}) = 1.0 + """ -Branch flow (and flow-slack) variables for the native nodal network models. +Branch variables for the PTDF and native nodal network models. -Without an active network reduction this delegates to the generic per-device -`add_variables!`. Under a reduction it mirrors the PTDF tracker pattern: the container -axis is the reduction-entry names (`PNM` `name_to_arc_map`), and every entry of a reduced -arc — series segments, parallel equivalents, across branch types — aliases the SAME -underlying JuMP variable, registered once per arc on the branch-reduction tracker. The +The container axis is the reduction-entry names (`PNM`'s `name_to_arc_map`), and every entry +of a reduced arc — series segments, parallel equivalents, across branch types — aliases the +SAME underlying JuMP variable, registered once per arc on the branch-reduction tracker. The matching balance wiring and constraint builders then treat each arc exactly once. + +`devices` is dispatch-only: the axis comes from the reduction entries, which PNM builds from +the branches surviving the `DeviceModel` filters. """ function add_variables!( container::OptimizationContainer, - ::Type{T}, - network_model::NetworkModel{<:NativeNodalNetworkModel}, - devices::IS.FlattenIteratorWrapper{U}, - ::Type{F}, -) where { - T <: Union{AbstractACActivePowerFlow, AbstractACReactivePowerFlow}, - U <: PSY.ACTransmission, - F <: AbstractBranchFormulation, -} - net_reduction_data = get_network_reduction(network_model) - if isempty(net_reduction_data) - add_variables!(container, T, devices, F) - return - end + ::Type{V}, + ::IS.FlattenIteratorWrapper{T}, + device_model::DeviceModel{T, F}, + network_model::NetworkModel{ + <:Union{AbstractPTDFNetworkModel, NativeNodalNetworkModel}, + }, +) where {V <: VariableType, T <: PSY.ACTransmission, F <: AbstractBranchFormulation} time_steps = get_time_steps(container) - branch_names = get_branch_argument_variable_axis(net_reduction_data, devices) - reduced_branch_tracker = get_reduced_branch_tracker(network_model) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) jump_model = get_jump_model(container) + network_reduction = get_network_reduction(network_model) + reduced_branch_tracker = get_reduced_branch_tracker(network_model) + all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(network_reduction) + arc_map = get_name_to_arc_map_entries(network_reduction, T) + start = _branch_variable_start(V) variable_container = add_variable_container!( container, + V, T, - U, - branch_names, + collect(keys(arc_map)), time_steps, ) - for (name, (arc, reduction)) in get_name_to_arc_map_entries(net_reduction_data, U) - reduction_entry = all_branch_maps_by_type[reduction][U][arc] + for (name, (arc, reduction)) in arc_map + entry = all_branch_maps_by_type[reduction][T][arc] has_entry, tracker_container = search_for_reduced_branch_variable!( reduced_branch_tracker, arc, - T, + V, ) - ub = get_variable_upper_bound(T, reduction_entry, F) - lb = get_variable_lower_bound(T, reduction_entry, F) - for t in time_steps - if !has_entry - tracker_container[t] = JuMP.@variable( + if !has_entry + (lb, ub) = _branch_variable_bounds(V, entry, device_model) + for t in time_steps + var = JuMP.@variable( jump_model, - base_name = "$(T)_$(U)_$(reduction)_{$(name), $(t)}", + base_name = "$(nameof(V))_$(nameof(T))_$(reduction)_{$(name), $(t)}", ) - ub !== nothing && JuMP.set_upper_bound(tracker_container[t], ub) - lb !== nothing && JuMP.set_lower_bound(tracker_container[t], lb) + lb !== nothing && JuMP.set_lower_bound(var, lb) + ub !== nothing && JuMP.set_upper_bound(var, ub) + start !== nothing && JuMP.set_start_value(var, start) + tracker_container[t] = var end + end + for t in time_steps variable_container[name, t] = tracker_container[t] end end @@ -1328,7 +1305,7 @@ end # variables. Zero is a data error rather than "unlimited" as in MATPOWER-style data: `p² + # q² ≤ 0` would silently pin the branch to zero flow, deleting it from the network. function _directional_flow_rating(d::PSY.ACTransmission, ::DeviceModel) - rating = PSY.get_rating(d) + rating = PSY.get_rating(d, PSY.SU) iszero(rating) && error( "Branch $(PSY.get_name(d)) has a zero rating; the flow limit would force zero \ flow. Assign a non-zero thermal rating or use an unbounded formulation.", @@ -1712,7 +1689,7 @@ function _tapped_admittance(jump_model, adm, tap) b11 = JuMP.@expression(jump_model, adm.b / tap^2 + adm.b_fr), g12 = JuMP.@expression(jump_model, (-g_cos + b_sin) / tap), b12 = JuMP.@expression(jump_model, (-b_cos - g_sin) / tap), - g22 = JuMP.@expression(jump_model, (-g_cos - b_sin) / tap), + g21 = JuMP.@expression(jump_model, (-g_cos - b_sin) / tap), b21 = JuMP.@expression(jump_model, (g_sin - b_cos) / tap), g22 = adm.g + adm.g_to, b22 = adm.b + adm.b_to, @@ -1948,67 +1925,6 @@ function _lpacc_cosine_bounds(d::PSY.ACTransmission) end end -""" -Create the bus-pair cosine variable (`cs`) for ACBranch under LPACCNetworkModel, -indexed by branch name. Bounded by the cosine of the branch angle limits (Principle 0), -start 1.0. -""" -function add_variables!( - container::OptimizationContainer, - ::Type{CosineApproximation}, - devices::IS.FlattenIteratorWrapper{T}, - network_model::NetworkModel{LPACCNetworkModel}, -) where {T <: PSY.ACTransmission} - time_steps = get_time_steps(container) - jump_model = get_jump_model(container) - network_reduction = get_network_reduction(network_model) - if isempty(network_reduction) - names = [PSY.get_name(d) for d in devices] - var = add_variable_container!(container, CosineApproximation, T, names, time_steps) - for d in devices - name = PSY.get_name(d) - (cmin, cmax) = _lpacc_cosine_bounds(d) - for t in time_steps - var[name, t] = JuMP.@variable( - jump_model, - base_name = "CosineApproximation_$(T)_{$(name), $(t)}", - lower_bound = cmin, - upper_bound = cmax, - start = 1.0, - ) - end - end - return - end - # Reduced case: `cs` approximates cos(θ_fr - θ_to) of the reduced arc, so all entries - # of an arc (across branch types) alias one tracker-registered variable, mirroring the - # flow variables. Equivalent entries have no angle-limit data and use the ±π/2 default. - names = get_branch_argument_variable_axis(network_reduction, devices) - tracker = get_reduced_branch_tracker(network_model) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(network_reduction) - var = add_variable_container!(container, CosineApproximation, T, names, time_steps) - for (name, (arc, reduction)) in get_name_to_arc_map_entries(network_reduction, T) - entry = all_branch_maps_by_type[reduction][T][arc] - has_entry, tracker_container = search_for_reduced_branch_variable!( - tracker, arc, CosineApproximation, - ) - (cmin, cmax) = _lpacc_cosine_bounds(entry) - for t in time_steps - if !has_entry - tracker_container[t] = JuMP.@variable( - jump_model, - base_name = "CosineApproximation_$(T)_$(reduction)_{$(name), $(t)}", - lower_bound = cmin, - upper_bound = cmax, - start = 1.0, - ) - end - var[name, t] = tracker_container[t] - end - end - return -end - """ Add the LPAC convex cosine relaxation for ACBranch under LPACCNetworkModel: @@ -2083,77 +1999,18 @@ function _min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) return minimum(_min_endpoint_voltage_limit(member) for member in entry) end -function _current_rating_reduced( - entry::PNM.AbstractReductionAggregate, - device_model::DeviceModel, - entry_name::String, -) - rate_a = _reduction_rating(entry, device_model) - iszero(rate_a) && error( - "IVR: reduced arc $(entry_name) has zero equivalent rating — assign non-zero \ - thermal ratings to its member branches", - ) - vmin = min_endpoint_voltage_limit(entry) +# Current rating of one entry: apparent-power rating over the lowest endpoint voltage it can +# see, so the bound holds across the whole voltage band. Entry-dispatched like +# `_directional_flow_rating`, so the direct-branch and reduced-arc cases share a caller. +function _current_rating(entry, device_model::DeviceModel) + rate_a = _directional_flow_rating(entry, device_model) + vmin = _min_endpoint_voltage_limit(entry) vmin <= 0.0 && error( - "IVR: reduced arc $(entry_name) has a non-positive member voltage minimum ($vmin)", + "IVR: $(PNM.get_name(entry)) has a non-positive endpoint voltage minimum ($vmin)", ) return rate_a / vmin end -function add_variables!( - container::OptimizationContainer, - ::Type{V}, - devices::IS.FlattenIteratorWrapper{T}, - device_model::DeviceModel, - network_model::NetworkModel{IVRNetworkModel}, -) where {V <: AbstractBranchCurrentVariable, T <: PSY.ACTransmission} - time_steps = get_time_steps(container) - jump_model = get_jump_model(container) - network_reduction = get_network_reduction(network_model) - # base-name prefix built once (unqualified via nameof) instead of per (name, t) - var_prefix = "$(nameof(V))_$(nameof(T))" - if isempty(network_reduction) - names = [PSY.get_name(d) for d in devices] - var = add_variable_container!(container, V, T, names, time_steps) - for (branch, name) in _iter_branches(devices) - c_rating = _current_rating(d, device_model, name) - for t in time_steps - var[name, t] = JuMP.@variable( - jump_model, - base_name = "$(var_prefix)_{$(name), $(t)}", - lower_bound = -c_rating, - upper_bound = c_rating, - ) - end - end - return - end - # Reduced case: branch currents are per-reduced-arc quantities like the flows, so all - # entries of an arc (across branch types) alias one tracker-registered variable, with - # the current rating derived from the reduction entry's equivalent parameters. - names = get_branch_argument_variable_axis(network_reduction, devices) - tracker = get_reduced_branch_tracker(network_model) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(network_reduction) - var = add_variable_container!(container, V, T, names, time_steps) - for (name, (arc, reduction)) in get_name_to_arc_map_entries(network_reduction, T) - entry = all_branch_maps_by_type[reduction][T][arc] - has_entry, tracker_container = search_for_reduced_branch_variable!(tracker, arc, V) - c_rating = _current_rating_reduced(entry, device_model, name) - for t in time_steps - if !has_entry - tracker_container[t] = JuMP.@variable( - jump_model, - base_name = "$(var_prefix)_$(reduction)_{$(name), $(t)}", - lower_bound = -c_rating, - upper_bound = c_rating, - ) - end - var[name, t] = tracker_container[t] - end - end - return -end - """ Add IVR branch constraints for ACBranch under IVRNetworkModel. @@ -2360,7 +2217,7 @@ function add_constraints!( ) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} entries = _branch_rating_entries(network_model, devices, T, CurrentLimitConstraint) rating2 = [ - name => _rate_rhs_squared(_ivr_current_rating(entry, device_model, name)) for + name => _rate_rhs_squared(_current_rating(entry, device_model)) for (name, entry) in entries ] _add_current_magnitude_limits!( diff --git a/src/ac_transmission_models/branch_constructor.jl b/src/ac_transmission_models/branch_constructor.jl index a2b625d..200fef4 100644 --- a/src/ac_transmission_models/branch_constructor.jl +++ b/src/ac_transmission_models/branch_constructor.jl @@ -89,7 +89,7 @@ function construct_device!( ) where {T <: PSY.ACTransmission} devices = get_available_components(device_model, sys) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranch) + _add_flow_slacks!(container, devices, device_model, network_model) end add_feedforward_arguments!(container, device_model, devices) return @@ -115,30 +115,25 @@ end ################################## ACPNetworkModel branch constructors ################# -# Shared directional flow-variable block for the StaticBranch family: the dominant -# add_variables! form across the raw call sites passes network_model (reduction-aware -# dispatch), so it is threaded through rather than dropped. -function _add_static_branch_flow_variables!( - container::OptimizationContainer, - devices, - network_model::NetworkModel, - ::Type{F}, -) where {F <: AbstractBranchFormulation} - add_variables!(container, FlowActivePowerFromToVariable, network_model, devices, F) - add_variables!(container, FlowActivePowerToFromVariable, network_model, devices, F) - add_variables!(container, FlowReactivePowerFromToVariable, network_model, devices, F) - add_variables!(container, FlowReactivePowerToFromVariable, network_model, devices, F) - return -end - -# Shared StaticBranch ArgumentConstructStage steps for the AC network models -# (ACP/ACR/LPACC). LPACC inserts its CosineApproximation variable between the two calls. +# Shared directional flow-variable block for the StaticBranch family. function _add_static_branch_flow_variables!( container::OptimizationContainer, devices, + device_model::DeviceModel, network_model::NetworkModel, ) - _add_static_branch_flow_variables!(container, devices, network_model, StaticBranch) + add_variables!( + container, FlowActivePowerFromToVariable, devices, device_model, network_model, + ) + add_variables!( + container, FlowActivePowerToFromVariable, devices, device_model, network_model, + ) + add_variables!( + container, FlowReactivePowerFromToVariable, devices, device_model, network_model, + ) + add_variables!( + container, FlowReactivePowerToFromVariable, devices, device_model, network_model, + ) return end @@ -169,16 +164,19 @@ function _wire_static_branch_flow_to_balance!( return end -# Shared paired flow-slack block: upper then lower, both via the network_model-aware -# add_variables! form used at every paired call site. +# Shared paired flow-slack block: upper then lower. function _add_flow_slacks!( container::OptimizationContainer, devices, + device_model::DeviceModel, network_model::NetworkModel, - ::Type{F}, -) where {F} - add_variables!(container, FlowActivePowerSlackUpperBound, network_model, devices, F) - add_variables!(container, FlowActivePowerSlackLowerBound, network_model, devices, F) +) + add_variables!( + container, FlowActivePowerSlackUpperBound, devices, device_model, network_model, + ) + add_variables!( + container, FlowActivePowerSlackLowerBound, devices, device_model, network_model, + ) return end @@ -256,7 +254,7 @@ function _add_static_branch_balance_arguments!( ) where {T <: PSY.ACTransmission} if get_use_slacks(device_model) add_variables!( - container, FlowActivePowerSlackUpperBound, network_model, devices, StaticBranch, + container, FlowActivePowerSlackUpperBound, devices, device_model, network_model, ) end _wire_static_branch_flow_to_balance!(container, devices, device_model, network_model) @@ -286,7 +284,7 @@ function construct_device!( @debug "construct_device ACP StaticBranch (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - _add_static_branch_flow_variables!(container, devices, network_model) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) _add_tap_control_variables!(container, device_model, devices, network_model) return @@ -346,12 +344,7 @@ function construct_device!( @debug "construct_device $U StaticBranchBounds (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - _add_static_branch_flow_variables!( - container, - devices, - network_model, - StaticBranchBounds, - ) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) _add_flow_definition_slacks!( container, device_model, devices, network_model, get_pair_metas(slack_spec(StaticBranchBounds, U)), @@ -415,7 +408,7 @@ function construct_device!( @debug "construct_device ACR StaticBranch (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - _add_static_branch_flow_variables!(container, devices, network_model) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) _add_tap_control_variables!(container, device_model, devices, network_model) return @@ -527,8 +520,8 @@ function construct_device!( @debug "construct_device LPACC StaticBranch (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - _add_static_branch_flow_variables!(container, devices, network_model) - add_variables!(container, CosineApproximation, devices, network_model) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) + add_variables!(container, CosineApproximation, devices, device_model, network_model) _add_static_branch_balance_arguments!(container, device_model, devices, network_model) _add_tap_control_variables!(container, device_model, devices, network_model) return @@ -590,17 +583,12 @@ function construct_device!( @debug "construct_device LPACC StaticBranchBounds (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - _add_static_branch_flow_variables!( - container, - devices, - network_model, - StaticBranchBounds, - ) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) _add_flow_definition_slacks!( container, device_model, devices, network_model, get_pair_metas(slack_spec(StaticBranchBounds, LPACCNetworkModel)), ) - add_variables!(container, CosineApproximation, devices, network_model) + add_variables!(container, CosineApproximation, devices, device_model, network_model) _wire_static_branch_flow_to_balance!(container, devices, device_model, network_model) add_feedforward_arguments!(container, device_model, devices) return @@ -663,7 +651,7 @@ function construct_device!( @debug "construct_device IVR StaticBranch (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - _add_static_branch_flow_variables!(container, devices, network_model, StaticBranch) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) add_variables!(container, BranchCurrentFromToReal, devices, device_model, network_model) add_variables!( container, @@ -690,11 +678,7 @@ function construct_device!( ) if get_use_slacks(device_model) add_variables!( - container, - FlowActivePowerSlackUpperBound, - network_model, - devices, - StaticBranch, + container, FlowActivePowerSlackUpperBound, devices, device_model, network_model, ) _add_current_magnitude_slacks!(container, devices, network_model) end @@ -772,18 +756,7 @@ function construct_device!( @debug "construct_device IVR StaticBranchBounds (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!( - container, FlowActivePowerFromToVariable, devices, StaticBranchBounds, - ) - add_variables!( - container, FlowActivePowerToFromVariable, devices, StaticBranchBounds, - ) - add_variables!( - container, FlowReactivePowerFromToVariable, devices, StaticBranchBounds, - ) - add_variables!( - container, FlowReactivePowerToFromVariable, devices, StaticBranchBounds, - ) + _add_static_branch_flow_variables!(container, devices, device_model, network_model) _add_flow_definition_slacks!( container, device_model, devices, network_model, get_pair_metas(slack_spec(StaticBranchBounds, IVRNetworkModel)), @@ -865,7 +838,7 @@ function construct_device!( LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranch) + _add_flow_slacks!(container, devices, device_model, network_model) end if haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter) add_branch_parameters!( @@ -927,9 +900,11 @@ function construct_device!( @debug "construct_device NFA (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!(container, FlowActivePowerVariable, network_model, devices, StaticBranch) + add_variables!( + container, FlowActivePowerVariable, devices, device_model, network_model, + ) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranch) + _add_flow_slacks!(container, devices, device_model, network_model) end add_to_expression!( container, @@ -993,13 +968,7 @@ function construct_device!( LOG_GROUP_BRANCH_CONSTRUCTIONS _check_flow_slack_support(device_model, network_model) devices = get_available_components(device_model, sys) - add_variables!( - container, - FlowActivePowerVariable, - network_model, - devices, - StaticBranchBounds, - ) + add_variables!(container, FlowActivePowerVariable, devices, device_model, network_model) add_to_expression!( container, ActivePowerBalance, @@ -1031,25 +1000,13 @@ function construct_device!( @debug "construct_device DCPLL (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!( - container, - FlowActivePowerFromToVariable, - network_model, - devices, - StaticBranch, - ) - add_variables!( - container, - FlowActivePowerToFromVariable, - network_model, - devices, - StaticBranch, - ) + add_variables!(container, FlowActivePowerFromToVariable, devices, device_model, network_model) + add_variables!(container, FlowActivePowerToFromVariable, devices, device_model, network_model) # Slacks turn the rating into a soft limit, so the two enforcement styles are # mutually exclusive: hard variable bounds without slacks (tighter QCP), slacked # FlowRateConstraint pairs (ModelConstructStage) with them. if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranch) + _add_flow_slacks!(container, devices, device_model, network_model) else _set_dcpll_flow_bounds!(container, sys, devices, device_model, network_model) end @@ -1113,22 +1070,10 @@ function construct_device!( @debug "construct_device DCPLL StaticBranchBounds (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!( - container, - FlowActivePowerFromToVariable, - network_model, - devices, - StaticBranchBounds, - ) - add_variables!( - container, - FlowActivePowerToFromVariable, - network_model, - devices, - StaticBranchBounds, - ) + add_variables!(container, FlowActivePowerFromToVariable, devices, device_model, network_model) + add_variables!(container, FlowActivePowerToFromVariable, devices, device_model, network_model) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranchBounds) + _add_flow_slacks!(container, devices, device_model, network_model) else _set_dcpll_flow_bounds!(container, sys, devices, device_model, network_model) end @@ -1195,15 +1140,9 @@ function construct_device!( @debug "construct_device DCP StaticBranchBounds (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!( - container, - FlowActivePowerVariable, - network_model, - devices, - StaticBranchBounds, - ) + add_variables!(container, FlowActivePowerVariable, devices, device_model, network_model) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranchBounds) + _add_flow_slacks!(container, devices, device_model, network_model) end add_to_expression!( container, @@ -1268,7 +1207,7 @@ function construct_device!( ) where {T <: PSY.ACTransmission} devices = get_available_components(device_model, sys) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranch) + _add_flow_slacks!(container, devices, device_model, network_model) end if haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter) @@ -1348,16 +1287,10 @@ function construct_device!( ) where {T <: PSY.ACTransmission} devices = get_available_components(device_model, sys) - add_variables!( - container, - FlowActivePowerVariable, - network_model, - devices, - StaticBranchBounds, - ) + add_variables!(container, FlowActivePowerVariable, devices, device_model, network_model) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, StaticBranchBounds) + _add_flow_slacks!(container, devices, device_model, network_model) end add_feedforward_arguments!(container, device_model, devices) @@ -2507,7 +2440,7 @@ function construct_device!( devices = get_available_components(device_model, sys) has_ts = PSY.has_time_series.(devices) if get_use_slacks(device_model) - _add_flow_slacks!(container, devices, network_model, T) + _add_flow_slacks!(container, devices, device_model, network_model) end if any(has_ts) && !all(has_ts) error( diff --git a/src/ac_transmission_models/security_constrained_branch.jl b/src/ac_transmission_models/security_constrained_branch.jl index a973abd..8fc0719 100644 --- a/src/ac_transmission_models/security_constrained_branch.jl +++ b/src/ac_transmission_models/security_constrained_branch.jl @@ -856,20 +856,7 @@ function construct_device!( ) where {T <: PSY.ACTransmission, F <: AbstractSecurityConstrainedStaticBranch} devices = get_available_components(device_model, sys) if get_use_slacks(device_model) - add_variables!( - container, - FlowActivePowerSlackUpperBound, - network_model, - devices, - F, - ) - add_variables!( - container, - FlowActivePowerSlackLowerBound, - network_model, - devices, - F, - ) + _add_flow_slacks!(container, devices, device_model, network_model) end if haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter) @@ -957,22 +944,11 @@ function construct_device!( network_model::NetworkModel{DCPNetworkModel}, ) where {T <: PSY.ACTransmission, F <: AbstractSecurityConstrainedStaticBranch} devices = get_available_components(device_model, sys) - add_variables!(container, FlowActivePowerVariable, network_model, devices, F) + add_variables!( + container, FlowActivePowerVariable, devices, device_model, network_model, + ) if get_use_slacks(device_model) - add_variables!( - container, - FlowActivePowerSlackUpperBound, - network_model, - devices, - F, - ) - add_variables!( - container, - FlowActivePowerSlackLowerBound, - network_model, - devices, - F, - ) + _add_flow_slacks!(container, devices, device_model, network_model) end add_to_expression!( container, diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index bef53fd..ea8d399 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -55,8 +55,8 @@ function _collect_reduction_exceptions( for m in values(branch_models) _pin_time_series_branch_buses!(buses, m, sys) _pin_outage_buses!(buses, m, sys) - _pin_model_all_branches(buses, m) - _pin_transformer_controls(buses, m) + _pin_model_all_branches!(buses, m) + _pin_transformer_controls!(buses, m) end return collect(buses) end diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 8132f73..a82b7ed 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -23,7 +23,7 @@ function _controlled_sys14( PSY.set_regulated_bus_number!(circuit, regulated) PSY.set_controlled_quantity_limits!(circuit, quantity_limits) PSY.set_control_limits!(circuit, control_limits) - return sys, transformer, circuit, PSY.get_name(bus) + return sys, transformer, circuit, PSY.get_name(PSY.get_bus(sys, regulated)) end function _controlled_template( From 808402625d9ca6eddde08bfd35cd88f6579f97d6 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 18 Aug 2026 14:36:43 -0400 Subject: [PATCH 14/19] claude refactor to RepresentativeBranch --- src/PowerOperationsModels.jl | 1 + src/ac_transmission_models/AC_branches.jl | 959 +++++------------- .../RepresentativeBranch.jl | 331 ++++++ 3 files changed, 611 insertions(+), 680 deletions(-) create mode 100644 src/ac_transmission_models/RepresentativeBranch.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index cd5b7b6..9f8cd37 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -302,6 +302,7 @@ include("energy_storage_models/storage_constructor.jl") include("common_models/market_bid_overrides.jl") # AC Transmission Models +include("ac_transmission_models/RepresentativeBranch.jl") include("ac_transmission_models/AC_branches.jl") include("ac_transmission_models/security_constrained_branch.jl") include("ac_transmission_models/branch_constructor.jl") diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 5ffa046..48cd60c 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -163,73 +163,52 @@ function get_default_attributes( ) end -# Resolve the per-DeviceModel attribute to one of the explicit PNM rating functions. -# `MixedBranchesParallel` ignores the attribute and always uses the plain sum, since -# the constituent branches may carry different DeviceModel preferences and there is -# no defensible way to pick one. The PNM aggregators return system-base values -# (no `PSY.SU`). -function _parallel_branches_rating(model::DeviceModel, bp::PNM.BranchesParallel) - method = get_attribute(model, PARALLEL_BRANCH_MAX_RATING_KEY) - if method == "single_element_contingency" - return PNM.get_single_element_contingency_rating(bp) - elseif method == "sum_of_max" - return PNM.get_sum_of_max_rating(bp) - elseif method == "impedance_averaged" - return PNM.get_impedance_averaged_rating(bp) - else - error( - "Unknown $PARALLEL_BRANCH_MAX_RATING_KEY value: $(repr(method)). " * - "Valid: \"single_element_contingency\", \"sum_of_max\", \"impedance_averaged\".", - ) - end -end - -function _parallel_branches_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel) - return PNM.get_sum_of_max_rating(mbp) -end #################################### Flow Variable Bounds ################################################## -# Bounds are the only thing that varies across the branch variable families, so they are the -# only thing `add_variables!` dispatches on. `entry` is the device itself for a direct branch -# or PNM's equivalent for a reduced arc — the same contract `_directional_flow_rating` uses. _branch_variable_bounds( ::Type{V}, - entry, + rep::RepresentativeBranch, ::DeviceModel{T, F}, ) where {V <: VariableType, T <: PSY.ACTransmission, F <: AbstractBranchFormulation} = - (get_variable_lower_bound(V, entry, F), get_variable_upper_bound(V, entry, F)) + ( + get_variable_lower_bound(V, rep.branch, F), + get_variable_upper_bound(V, rep.branch, F), + ) -_branch_variable_bounds( +_angle_limits(d::PSY.Line) = PSY.get_angle_limits(d) +_angle_limits(d::PSY.MonitoredLine) = PSY.get_angle_limits(d) +_angle_limits(::PSY.ACTransmission) = (min = -π / 2, max = π / 2) + +function _branch_variable_bounds( ::Type{CosineApproximation}, - entry, + rep::RepresentativeBranch, ::DeviceModel{T, F}, -) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} = - _lpacc_cosine_bounds(entry) +) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} + lims = _angle_limits(rep) + if lims.min >= 0 + return (cos(lims.max), cos(lims.min)) + elseif lims.max <= 0 + return (cos(lims.min), cos(lims.max)) + else + return (min(cos(lims.min), cos(lims.max)), 1.0) + end +end function _branch_variable_bounds( ::Type{<:AbstractBranchCurrentVariable}, - entry, + rep::RepresentativeBranch, device_model::DeviceModel{T, F}, ) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} - rating = _current_rating(entry, device_model) + rating = _current_rating(rep, device_model) return (-rating, rating) end -# LPAC's cosine variable needs a feasible starting point for the NLP solve; the flow and -# current families take JuMP's default. -_branch_variable_start(::Type{<:VariableType}) = nothing _branch_variable_start(::Type{CosineApproximation}) = 1.0 +_branch_variable_start(_) = nothing """ -Branch variables for the PTDF and native nodal network models. - -The container axis is the reduction-entry names (`PNM`'s `name_to_arc_map`), and every entry -of a reduced arc — series segments, parallel equivalents, across branch types — aliases the -SAME underlying JuMP variable, registered once per arc on the branch-reduction tracker. The -matching balance wiring and constraint builders then treat each arc exactly once. - -`devices` is dispatch-only: the axis comes from the reduction entries, which PNM builds from -the branches surviving the `DeviceModel` filters. +Branch variables for reduction-aware networks. Every entry of a reduced arc +aliases the same underlying JuMP variable. """ function add_variables!( container::OptimizationContainer, @@ -242,33 +221,33 @@ function add_variables!( ) where {V <: VariableType, T <: PSY.ACTransmission, F <: AbstractBranchFormulation} time_steps = get_time_steps(container) jump_model = get_jump_model(container) - network_reduction = get_network_reduction(network_model) reduced_branch_tracker = get_reduced_branch_tracker(network_model) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(network_reduction) - arc_map = get_name_to_arc_map_entries(network_reduction, T) start = _branch_variable_start(V) + # Every device name, not one per arc: members merged into a shared arc must still be + # registered on the variable axis, aliasing the arc's variables via the tracker. + branches = _all_branches(network_model, T) + variable_container = add_variable_container!( container, V, T, - collect(keys(arc_map)), + [b.name for b in branches], time_steps, ) - for (name, (arc, reduction)) in arc_map - entry = all_branch_maps_by_type[reduction][T][arc] + _for_each_branch(branches) do rep has_entry, tracker_container = search_for_reduced_branch_variable!( reduced_branch_tracker, - arc, + rep.arc, V, ) if !has_entry - (lb, ub) = _branch_variable_bounds(V, entry, device_model) + (lb, ub) = _branch_variable_bounds(V, rep, device_model) for t in time_steps var = JuMP.@variable( jump_model, - base_name = "$(nameof(V))_$(nameof(T))_$(reduction)_{$(name), $(t)}", + base_name = "$(nameof(V))_$(nameof(T))_$(rep.reduction)_{$(rep.name), $(t)}", ) lb !== nothing && JuMP.set_lower_bound(var, lb) ub !== nothing && JuMP.set_upper_bound(var, ub) @@ -277,25 +256,12 @@ function add_variables!( end end for t in time_steps - variable_container[name, t] = tracker_container[t] + variable_container[rep.name, t] = tracker_container[t] end end return end -# Matches the names returned by _branch_geometries -_circuit_arc_name(d::PSY.TwoWindingTransformer, ::PSY.TransformerCircuit, ::Int) = - PSY.get_name(d) -_circuit_arc_name(d::PSY.ThreeWindingTransformer, c::PSY.TransformerCircuit, i::Int) = - PNM.get_name(PNM.ThreeWindingTransformerCircuit(d, c, i)) - -_add_tap_control_variables!( - ::OptimizationContainer, - ::DeviceModel, - ::IS.FlattenIteratorWrapper, - ::NetworkModel, -) = nothing - _warn_tap_control_nonconvexity( ::NetworkModel{N}, ) where {N <: Union{LPACCNetworkModel, DCPNetworkModel, DCPLLNetworkModel}} = @@ -312,30 +278,8 @@ function _add_tap_control_variables!( F <: AbstractBranchFormulation, } _control_enabled(model) || return - - names = String[] - circuits = PSY.TransformerCircuit[] - for d in devices, (i, c) in enumerate(PSY.get_circuits(d)) - _tap_controlled(c) || continue - push!(names, _circuit_arc_name(d, c, i)) - push!(circuits, c) - end - isempty(names) && return _warn_tap_control_nonconvexity(network_model) - _validate_controlled_branch_not_reduced(network_model, U, names) - - time_steps = get_time_steps(container) - jump_model = get_jump_model(container) - tap_var = add_variable_container!(container, TapRatioVariable, U, names, time_steps) - for (i, name) in enumerate(names), t in time_steps - bounds = PSY.get_control_limits(circuits[i]) - tap_var[name, t] = JuMP.@variable( - jump_model, - base_name = "TapRatioVariable_$(U)_{$(name), $(t)}", - lower_bound = bounds.min, - upper_bound = bounds.max - ) - end + add_variables!(container, TapRatioVariable, devices, device_model, network_model) return end @@ -401,29 +345,24 @@ function branch_rate_bounds!( network_model::NetworkModel{<:AbstractNetworkModel}, ) where {B <: PSY.ACTransmission, T <: AbstractBranchFormulation} time_steps = get_time_steps(container) - net_reduction_data = get_network_reduction(network_model) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) variable_types = _flow_variable_types(network_model) variables = map(V -> get_variable(container, V, B), variable_types) - for (name, (arc, reduction)) in PNM.get_name_to_arc_map(net_reduction_data, B) - # TODO: entry is not type stable here, it can return any type ACTransmission. - # It might have performance implications. Possibly separate this into other functions - reduction_entry = all_branch_maps_by_type[reduction][B][arc] - flow_limits = min_max_flow_limits(reduction_entry, device_model) - rating = _reduction_rating(reduction_entry, device_model) + _for_each_branch(_all_branches(network_model, B)) do rep + flow_limits = min_max_flow_limits(rep, device_model) + rating = _branch_rating(rep, device_model) rating_limits = (min = -rating, max = rating) for (V, var) in zip(variable_types, variables) limits = _directional_flow_limits(V, flow_limits, rating_limits) - @assert limits.min <= limits.max "Infeasible rate limits for branch $(name)" + @assert limits.min <= limits.max "Infeasible rate limits for branch $(rep.name)" for t in time_steps # Variable-creation defaults (MonitoredLine asymmetric limits, # TwoWindingTransformer ratings) are authoritative — never clobber # an existing bound. - if !JuMP.has_upper_bound(var[name, t]) - JuMP.set_upper_bound(var[name, t], limits.max) + if !JuMP.has_upper_bound(var[rep.name, t]) + JuMP.set_upper_bound(var[rep.name, t], limits.max) end - if !JuMP.has_lower_bound(var[name, t]) - JuMP.set_lower_bound(var[name, t], limits.min) + if !JuMP.has_lower_bound(var[rep.name, t]) + JuMP.set_lower_bound(var[rep.name, t], limits.min) end end end @@ -433,33 +372,12 @@ end ################################## Rate Limits constraint_infos ############################ -_reduction_rating(entry::PNM.AbstractBranchesParallel, model::DeviceModel) = - _parallel_branches_rating(model, entry) - -_reduction_rating(entry::PNM.BranchesSeries, ::DeviceModel) = PNM.get_equivalent_rating(entry) - -""" -Symmetric `(min, max)` flow limits from [`branch_rating`](@ref). Prefer this -over the formulation-only `get_min_max_limits` when the `DeviceModel` is in -scope. -""" -function min_max_flow_limits(entry, model::DeviceModel) - rating = _reduction_rating(entry, model) - return (min = -rating, max = rating) -end - -# `MonitoredLine` has explicit, possibly asymmetric `flow_limits`; defer to its -# own `get_min_max_limits` instead of the symmetric `branch_rating` path. -function min_max_flow_limits(device::PSY.MonitoredLine, ::DeviceModel) - return get_min_max_limits(device, FlowRateConstraint, AbstractBranchFormulation) -end - # Branch-rating time-series multiplier at build time. Non-parallel entries use # the same aggregation as the static `branch_rating` path. Parallel groups are # the exception: a series on one member can't be split across the group, so the # summed (emergency) rating is used regardless of the attribute. Every PNM # reduction wrapper is `<: PSY.ACTransmission`; the parallel methods are more -# specific (`<: AbstractBranchesParallel`), so they win for groups. +# specific (`<: AbstractBranchesParallel`), so they win for reproups. _resolve_branch_multiplier(p, d, f, ::DeviceModel) = get_multiplier_value(p, d, f) function _resolve_branch_multiplier( @@ -519,22 +437,20 @@ end function _add_flow_rate_constraint!( container::OptimizationContainer, - arc::Tuple{Int, Int}, + rep::RepresentativeBranch, use_slacks::Bool, con_lb::DenseAxisArray, con_ub::DenseAxisArray, var::DenseAxisArray, - branch_maps_by_type::Dict, - name::String, device_model::DeviceModel{T}, ) where {T <: PSY.ACTransmission} - reduction_entry = branch_maps_by_type[arc] + name = rep.name time_steps = get_time_steps(container) if use_slacks slack_ub = get_variable(container, FlowActivePowerSlackUpperBound, T)[name, :] slack_lb = get_variable(container, FlowActivePowerSlackLowerBound, T)[name, :] end - limits = min_max_flow_limits(reduction_entry, device_model) + limits = min_max_flow_limits(rep, device_model) for t in time_steps if use_slacks ub_lhs = var[name, t] - slack_ub[t] @@ -566,15 +482,8 @@ function add_constraints!( V <: AbstractActivePowerModel, } time_steps = get_time_steps(container) - net_reduction_data = get_network_reduction(network_model) - reduced_branch_tracker = get_reduced_branch_tracker(network_model) - branch_names = get_branch_argument_constraint_axis( - net_reduction_data, - reduced_branch_tracker, - devices, - cons_type, - ) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) + reps = _representative_branches(network_model, T, cons_type) + branch_names = [rep.name for rep in reps] con_lb = add_constraints_container!( @@ -598,17 +507,14 @@ function add_constraints!( array = get_variable(container, FlowActivePowerVariable, T) use_slacks = get_use_slacks(device_model) - for (name, (arc, reduction)) in - get_constraint_map_by_type(reduced_branch_tracker)[FlowRateConstraint][T] + _for_each_branch(reps) do rep _add_flow_rate_constraint!( container, - arc, + rep, use_slacks, con_lb, con_ub, array, - all_branch_maps_by_type[reduction][T], - name, device_model, ) end @@ -627,15 +533,8 @@ function add_constraints!( V <: AbstractPTDFNetworkModel, } time_steps = get_time_steps(container) - net_reduction_data = get_network_reduction(network_model) - reduced_branch_tracker = get_reduced_branch_tracker(network_model) - branch_names = get_branch_argument_constraint_axis( - net_reduction_data, - reduced_branch_tracker, - devices, - cons_type, - ) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) + reps = _representative_branches(network_model, T, cons_type) + branch_names = [rep.name for rep in reps] con_lb = add_constraints_container!( @@ -659,17 +558,14 @@ function add_constraints!( array = get_expression(container, PTDFBranchFlow, T) use_slacks = get_use_slacks(device_model) - for (name, (arc, reduction)) in - get_constraint_map_by_type(reduced_branch_tracker)[FlowRateConstraint][T] + _for_each_branch(reps) do rep _add_flow_rate_constraint!( container, - arc, + rep, use_slacks, con_lb, con_ub, array, - all_branch_maps_by_type[reduction][T], - name, device_model, ) end @@ -679,14 +575,11 @@ end function _add_flow_rate_constraint_with_parameters!( container::OptimizationContainer, ::Type{T}, - arc::Tuple{Int, Int}, use_slacks::Bool, con_lb::DenseAxisArray, con_ub::DenseAxisArray, var::DenseAxisArray, - branch_maps_by_type::Dict, name::String, - ts_name::String, ) where {T <: PSY.ACTransmission} param_container = get_parameter(container, BranchRatingTimeSeriesParameter, T) @@ -718,22 +611,8 @@ function add_flow_rate_constraint_with_parameters!( V <: AbstractPTDFNetworkModel, } time_steps = get_time_steps(container) - net_reduction_data = get_network_reduction(network_model) - reduced_branch_tracker = get_reduced_branch_tracker(network_model) - - # POM's `get_branch_argument_constraint_axis` already performs per-arc claim - # dedup as a side effect (populating the tracker's constraint_dict), so the - # iteration below over `get_constraint_map_by_type` walks the already-deduped - # arc set. There is no need for the upstream PSI manual `name_to_arc_map` - # walk + arc-claim push here. - branch_names = get_branch_argument_constraint_axis( - net_reduction_data, - reduced_branch_tracker, - devices, - cons_type, - ) - - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) + reps = _representative_branches(network_model, T, cons_type) + branch_names = [rep.name for rep in reps] con_lb = add_constraints_container!( @@ -759,32 +638,25 @@ function add_flow_rate_constraint_with_parameters!( ts_name = get_time_series_names(device_model)[BranchRatingTimeSeriesParameter] ts_type = get_default_time_series_type(container) use_slacks = get_use_slacks(device_model) - for (name, (arc, reduction)) in - get_constraint_map_by_type(reduced_branch_tracker)[FlowRateConstraint][T] - branch_map_T = all_branch_maps_by_type[reduction][T] - if PNM.has_time_series(branch_map_T[arc], ts_type, ts_name) + _for_each_branch(reps) do rep + if PNM.has_time_series(rep.branch, ts_type, ts_name) _add_flow_rate_constraint_with_parameters!( container, T, - arc, use_slacks, con_lb, con_ub, var_array, - branch_map_T, - name, - ts_name, + rep.name, ) else _add_flow_rate_constraint!( container, - arc, + rep, use_slacks, con_lb, con_ub, var_array, - branch_map_T, - name, device_model, ) end @@ -1140,32 +1012,6 @@ function _price_slack_upper!( return end -# (name, rating-entry) pairs for a rating/limit constraint family: one pair per device -# when no reduction is active (the entry IS the device), or one pair per reduced arc of -# `T` not yet claimed for `C` (the entry is the direct branch or PNM's series/parallel -# equivalent). Rating constraints bind the arc's shared flow variables, so like the -# Ohm's-law builders they must cover each reduced arc exactly once. -function _branch_rating_entries( - network_model::NetworkModel, - devices::IS.FlattenIteratorWrapper{T}, - ::Type{T}, - ::Type{C}, -) where {T <: PSY.ACTransmission, C <: ConstraintType} - network_reduction = get_network_reduction(network_model) - if isempty(network_reduction) - return Tuple{String, Any}[(PSY.get_name(d), d) for d in devices] - end - tracker = get_reduced_branch_tracker(network_model) - representative_names = - get_branch_argument_constraint_axis(network_reduction, tracker, T, C) - arc_map = get_name_to_arc_map_entries(network_reduction, T) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(network_reduction) - return Tuple{String, Any}[ - (name, all_branch_maps_by_type[arc_map[name][2]][T][arc_map[name][1]]) for - name in representative_names - ] -end - function _validate_controlled_branch_not_reduced( network_model::NetworkModel, ::Type{T}, @@ -1176,7 +1022,7 @@ function _validate_controlled_branch_not_reduced( arc_map = get_name_to_arc_map_entries(network_reduction, T) for name in controlled_names entry = get(arc_map, name, nothing) - if entry === nothing || entry[2] != "direct_branch_map" + if entry === nothing || entry[2] != DIRECT_BRANCH_MAP error( "Controlled transformer circuit $(name) was merged with a parallel branch. Either remove the parallel branch or disable control for this circuit.", ) @@ -1185,145 +1031,7 @@ function _validate_controlled_branch_not_reduced( return end -_is_aggregate(::PNM.AbstractReductionAggregate) = true -_is_aggregate(::PSY.ACTransmission) = false - -_branch_admittance(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = - PNM.branch_admittance(branch, nr) -_branch_admittance(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = - PNM.branch_admittance(branch) - -_dc_phase_shift(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = - PNM.get_series_phase_shift(branch, nr) -_dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = - PNM.get_series_phase_shift(branch) - -_get_circuit(b::_TRANSFORMERS) = PSY.get_circuit(b) -_get_circuit(_) = nothing - -_control_objective(branch) = _control_objective(_get_circuit(branch)) -_control_objective(::Nothing) = PSY.TransformerControlObjective.UNDEFINED -_control_objective(c::PSY.TransformerCircuit) = - if PSY.get_available(c) - PSY.get_control_objective(c) - else - PSY.TransformerControlObjective.UNDEFINED - end - -_quantity_limits(branch) = _quantity_limits(_get_circuit(branch)) -_quantity_limits(::Nothing) = (min = -Inf, max = Inf) -_quantity_limits(c::PSY.TransformerCircuit) = PSY.get_controlled_quantity_limits(c) - -_regulated_number(branch) = _regulated_number(_get_circuit(branch)) -_regulated_number(::Nothing) = -1 -_regulated_number(c::PSY.TransformerCircuit) = PSY.get_regulated_bus_number(c) - -Base.@kwdef struct BranchGeometry - name::String - from_name::String - to_name::String - from_number::Int - to_number::Int - adm::NamedTuple{ - (:g, :b, :g_fr, :b_fr, :g_to, :b_to, :tap, :shift), - NTuple{8, Float64}, - } - b_dc::Float64 - shift_dc::Float64 - r_dc::Float64 - direct::Bool - control::PSY.TransformerControlObjective - quantity_limits::MinMax - regulated_number::Int -end - -function BranchGeometry( - nr::PNM.NetworkReductionData, - number_to_name::Dict{Int, String}, - name::String, - arc_tuple::Tuple{Int, Int}, - branch, -) - from_no = arc_tuple[1] - to_no = arc_tuple[2] - return BranchGeometry(; - name = name, - from_name = number_to_name[from_no], - to_name = number_to_name[to_no], - from_number = from_no, - to_number = to_no, - adm = _branch_admittance(branch, nr), - b_dc = PNM.get_series_susceptance(branch, PSY.SU), - shift_dc = _dc_phase_shift(branch, nr), - r_dc = PNM.arc_dc_resistance(nr, arc_tuple), - direct = !_is_aggregate(branch), - control = _control_objective(branch), - quantity_limits = _quantity_limits(branch), - regulated_number = _regulated_number(branch), - ) -end -_tap_controlled(g::BranchGeometry) = _tap_controlled(g.control) -_voltage_controlled(g::BranchGeometry) = _voltage_controlled(g.control) -_reactive_controlled(g::BranchGeometry) = _reactive_controlled(g.control) - -""" -One [`BranchGeometry`](@ref) per arc of `T` not yet claimed for the constraint family `C` — -the representative axis from [`get_branch_argument_constraint_axis`](@ref) — with PNM's -reduction-aware equivalent admittance. - -Every member of a reduced arc (series segments, parallel groups, across branch types) -shares one set of flow variables, so each arc's physics must be built exactly once; -the tracker-backed axis guarantees that across `construct_device!` calls. Constraint -containers must be sized with the returned geometry names. -""" -function _branch_geometries( - number_to_name::Dict{Int, String}, - network_model, - devices, - ::Type{T}, - ::Type{C}, -) where {T <: PSY.ACTransmission, C <: ConstraintType} - nr = get_network_reduction(network_model) - tracker = get_reduced_branch_tracker(network_model) - representative_names = get_branch_argument_constraint_axis(nr, tracker, T, C) - arc_map = get_name_to_arc_map_entries(nr, T) - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(nr) - geoms = BranchGeometry[ - BranchGeometry( - nr, - number_to_name, - name, - arc_map[name][1], - all_branch_maps_by_type[arc_map[name][2]][T][arc_map[name][1]], - ) for name in representative_names - ] - return geoms -end - ################################## ACP apparent-power rate constraints ###################### -# Apparent-power rating in system base (PSY.SU) so `rating^2` matches the per-unit flow -# variables. Zero is a data error rather than "unlimited" as in MATPOWER-style data: `p² + -# q² ≤ 0` would silently pin the branch to zero flow, deleting it from the network. -function _directional_flow_rating(d::PSY.ACTransmission, ::DeviceModel) - rating = PSY.get_rating(d, PSY.SU) - iszero(rating) && error( - "Branch $(PSY.get_name(d)) has a zero rating; the flow limit would force zero \ - flow. Assign a non-zero thermal rating or use an unbounded formulation.", - ) - return rating -end - -function _directional_flow_rating( - entry::PNM.AbstractReductionAggregate, - device_model::DeviceModel, -) - rating = _reduction_rating(entry, device_model) - iszero(rating) && error( - "A reduced arc has a zero equivalent rating; the flow limit would force zero \ - flow. Assign non-zero thermal ratings to its member branches.", - ) - return rating -end """ Shared builder for directional apparent-power rate limit constraints under @@ -1353,10 +1061,9 @@ function _add_directional_flow_rate_limits!( pflow = get_variable(container, PVar, T) qflow = get_variable(container, QVar, T) quad_slacks = _quadratic_rate_slacks(container, device_model, T) - entries = _branch_rating_entries(network_model, devices, T, ConsKey) - branch_names = [name for (name, _) in entries] + reps = _representative_branches(network_model, T, ConsKey) cons = add_constraints_container!( - container, ConsKey, T, branch_names, time_steps, + container, ConsKey, T, [rep.name for rep in reps], time_steps, ) jump_model = get_jump_model(container) @@ -1369,7 +1076,8 @@ function _add_directional_flow_rate_limits!( ts_branch_names = Set(axes(mult, 1)) end - for (name, entry) in entries + _for_each_branch(reps) do rep + name = rep.name if name in ts_branch_names param = get_parameter_column_refs(param_container, name) for t in time_steps @@ -1382,7 +1090,7 @@ function _add_directional_flow_rate_limits!( ) end else - rating = _directional_flow_rating(entry, device_model) + rating = _directional_flow_rating(rep, device_model) for t in time_steps lhs = pflow[name, t]^2 + qflow[name, t]^2 - @@ -1715,23 +1423,23 @@ function add_constraints!( qft = get_variable(container, FlowReactivePowerFromToVariable, T) qtf = get_variable(container, FlowReactivePowerToFromVariable, T) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] + reps = _representative_branches( + network_model, T, NetworkFlowConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) cons_pft, cons_qft, cons_ptf, cons_qtf = - _add_flow_constraint_containers!(container, T, branch_names) + _add_flow_constraint_containers!(container, T, [r.name for r in reps]) jump_model = get_jump_model(container) slacks = _flow_equality_slacks(container, device_model, T) - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm - from_bus = g_geom.from_name - to_bus = g_geom.to_name + _for_each_branch(reps) do rep + name = rep.name + adm = _admittance(rep) + from_bus = _from_name(rep) + to_bus = _to_name(rep) vp = _voltage_products(container, network_model, T, name, from_bus, to_bus) - tap_var = _tap_controlled(device_model, g_geom) ? get_variable(container, TapRatioVariable, T) : nothing + tap_var = _tap_controlled(device_model, rep) ? get_variable(container, TapRatioVariable, T) : nothing for t in time_steps tap = isnothing(tap_var) ? adm.tap : tap_var[name, t] y = _tapped_admittance(jump_model, adm, tap) @@ -1765,9 +1473,6 @@ function add_constraints!( return end -_iter_branches(ts::_TRANSFORMERS) = ((c, _circuit_arc_name(t, c, i)) for t in ts for (i, c) in enumerate(PSY.get_circuits(t))) -_iter_branches(ds) = ((d, PSY.get_name(d)) for d in ds) - _voltage_magnitude(container, name, ::NetworkModel{ACPNetworkModel}) = get_variable(container, VoltageMagnitude, PSY.ACBus)[name, :] _voltage_magnitude( @@ -1902,28 +1607,6 @@ end ################################## LPACCNetworkModel branch constraints ############### -# Branch voltage-angle-difference bounds (angmin, angmax). Only Line / MonitoredLine -# carry angle-limit data; other branch types get a finite ±π/2 default so the LPAC -# cosine variable and its relaxation stay bounded (Principle 0). -# angle limits are in radians — no per-unit conversion -_lpacc_branch_angle_limits(d::PSY.Line) = PSY.get_angle_limits(d) -_lpacc_branch_angle_limits(d::PSY.MonitoredLine) = PSY.get_angle_limits(d) -_lpacc_branch_angle_limits(::PSY.ACTransmission) = (min = -π / 2, max = π / 2) - -# Finite cosine-variable bounds (cos_min, cos_max) from the branch angle limits, following -# the PowerModels `variable_buspair_cosine` convention. -function _lpacc_cosine_bounds(d::PSY.ACTransmission) - lims = _lpacc_branch_angle_limits(d) - angmin = lims.min - angmax = lims.max - if angmin >= 0 - return (cos(angmax), cos(angmin)) - elseif angmax <= 0 - return (cos(angmin), cos(angmax)) - else - return (min(cos(angmin), cos(angmax)), 1.0) - end -end """ Add the LPAC convex cosine relaxation for ACBranch under LPACCNetworkModel: @@ -1945,72 +1628,37 @@ function add_constraints!( va = get_variable(container, VoltageAngle, PSY.ACBus) cs = get_variable(container, CosineApproximation, T) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = _branch_geometries( - number_to_name, network_model, devices, T, CosineRelaxationConstraint, - ) - device_by_name = Dict(PSY.get_name(d) => d for d in devices) - # Angle limits are per-device data: a direct entry reads its own device; a PNM - # series/parallel equivalent has none and uses the same ±π/2 default as devices - # without the angle-limits API. Zero-width limits produce no constraint, so the - # container is sized on the constrained subset only. - constrained = [(g, _entry_angle_limits(g, device_by_name)) for g in geoms] - filter!(x -> !iszero(max(abs(x[2].min), abs(x[2].max))), constrained) - branch_names = [g.name for (g, _) in constrained] + reps = _representative_branches( + network_model, T, CosineRelaxationConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) + # Zero-width angle limits produce no constraint, so the container is sized on the + # constrained subset only. + constrained = + filter(rep -> !iszero(_max_angle_difference(rep)), reps) cons = add_constraints_container!( - container, CosineRelaxationConstraint, T, branch_names, time_steps, + container, CosineRelaxationConstraint, T, [rep.name for rep in constrained], + time_steps, ) - for (g, lims) in constrained - vad_max = max(abs(lims.min), abs(lims.max)) + _for_each_branch(constrained) do rep + vad_max = _max_angle_difference(rep) k = (1.0 - cos(vad_max)) / vad_max^2 + from_name = _from_name(rep) + to_name = _to_name(rep) for t in time_steps - cons[g.name, t] = JuMP.@constraint( + cons[rep.name, t] = JuMP.@constraint( get_jump_model(container), - cs[g.name, t] <= - 1.0 - k * (va[g.from_name, t] - va[g.to_name, t])^2, + cs[rep.name, t] <= + 1.0 - k * (va[from_name, t] - va[to_name, t])^2, ) end end return end -# Angle-difference bounds for one geometry entry: direct entries defer to the device's -# `_lpacc_branch_angle_limits`; reduction equivalents carry no angle-limit data and use -# the same finite ±π/2 default as devices without the angle-limits API. -function _entry_angle_limits(geometry, device_by_name::Dict{String, <:PSY.ACTransmission}) - if geometry.direct - return _lpacc_branch_angle_limits(device_by_name[geometry.name]) - end - return (min = -π / 2, max = π / 2) -end - ################################## IVRNetworkModel branch constraints ################## -function _min_endpoint_voltage_limit(branch::PSY.ACTransmission) - arc = PSY.get_arc(branch) - # bus voltage limits are already per-unit - vmin_fr = PSY.get_voltage_limits(PSY.get_from(arc)).min - vmin_to = PSY.get_voltage_limits(PSY.get_to(arc)).min - return min(vmin_fr, vmin_to) -end - -function _min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) - return minimum(_min_endpoint_voltage_limit(member) for member in entry) -end - -# Current rating of one entry: apparent-power rating over the lowest endpoint voltage it can -# see, so the bound holds across the whole voltage band. Entry-dispatched like -# `_directional_flow_rating`, so the direct-branch and reduced-arc cases share a caller. -function _current_rating(entry, device_model::DeviceModel) - rate_a = _directional_flow_rating(entry, device_model) - vmin = _min_endpoint_voltage_limit(entry) - vmin <= 0.0 && error( - "IVR: $(PNM.get_name(entry)) has a non-positive endpoint voltage minimum ($vmin)", - ) - return rate_a / vmin -end - """ Add IVR branch constraints for ACBranch under IVRNetworkModel. @@ -2055,10 +1703,11 @@ function add_constraints!( csr = get_variable(container, BranchSeriesCurrentReal, T) csi = get_variable(container, BranchSeriesCurrentImaginary, T) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] + reps = _representative_branches( + network_model, T, NetworkFlowConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) + branch_names = [rep.name for rep in reps] cons_pft = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "p_ft", @@ -2100,17 +1749,17 @@ function add_constraints!( else nothing end - for g_geom in geoms - name = g_geom.name - adm = g_geom.adm + _for_each_branch(reps) do rep + name = rep.name + adm = _admittance(rep) g = adm.g b = adm.b g_fr = adm.g_fr b_fr = adm.b_fr g_to = adm.g_to b_to = adm.b_to - from_bus = g_geom.from_name - to_bus = g_geom.to_name + from_bus = _from_name(rep) + to_bus = _to_name(rep) # Series impedance Z = r + jx = conj(y)/|y|² ymag2 = g^2 + b^2 @@ -2118,7 +1767,7 @@ function add_constraints!( x = -b / ymag2 for t in time_steps - tm = _tap_controlled(device_model, g_geom) ? tap_var[name, t] : adm.tap + tm = _tap_controlled(device_model, rep) ? tap_var[name, t] : adm.tap tr = tm * cos(adm.shift) ti = tm * sin(adm.shift) tm2 = tm^2 @@ -2215,10 +1864,9 @@ function add_constraints!( device_model::DeviceModel{T, U}, network_model::NetworkModel{IVRNetworkModel}, ) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} - entries = _branch_rating_entries(network_model, devices, T, CurrentLimitConstraint) rating2 = [ - name => _rate_rhs_squared(_current_rating(entry, device_model)) for - (name, entry) in entries + rep.name => _rate_rhs_squared(_current_rating(rep, device_model)) for + rep in _representative_branches(network_model, T, CurrentLimitConstraint) ] _add_current_magnitude_limits!( container, T, rating2, "from", @@ -2303,94 +1951,20 @@ function add_constraints!( ts_branch_names = Set(axes(mult, 1)) end - network_reduction = get_network_reduction(network_model) - if !isempty(network_reduction) - # Reduced case: one lb/ub pair per reduced arc (the flow variables are shared per - # arc), with the rating from the reduction entry's equivalent parameters. The TS - # parameter axes are already reduction-entry names. - entries = _branch_rating_entries(network_model, devices, T, FlowRateConstraint) - branch_names = [name for (name, _) in entries] - con_lb = add_constraints_container!( - container, FlowRateConstraint, T, branch_names, time_steps; meta = "lb", - ) - con_ub = add_constraints_container!( - container, FlowRateConstraint, T, branch_names, time_steps; meta = "ub", - ) - for (name, entry) in entries - if name in ts_branch_names - param = get_parameter_column_refs(param_container, name) - if use_slacks - add_parameterized_rating_constraints!( - container, con_ub, con_lb, flow_vars, name, param, mult, - slack_ub, slack_lb, - ) - else - add_parameterized_rating_constraints!( - container, con_ub, con_lb, flow_vars, name, param, mult, - ) - end - else - limits = min_max_flow_limits(entry, device_model) - for t in time_steps - if use_slacks - ub_lhs = flow_vars[name, t] - slack_ub[name, t] - lb_lhs = flow_vars[name, t] + slack_lb[name, t] - else - ub_lhs = flow_vars[name, t] - lb_lhs = flow_vars[name, t] - end - con_ub[name, t] = - JuMP.@constraint(jump_model, ub_lhs <= limits.max) - con_lb[name, t] = - JuMP.@constraint(jump_model, lb_lhs >= limits.min) - end - end - end - return - end - - branch_names = [PSY.get_name(d) for d in devices] - static_devices = [d for d in devices if !(PSY.get_name(d) in ts_branch_names)] - ts_devices = [d for d in devices if PSY.get_name(d) in ts_branch_names] - - # STATIC rating path: a plain `limits.min <= flow <= limits.max` (slack subtracted on - # UB, added on LB). Delegated to the generic slack-aware IOM range helper since it is - # the same lb/ub logic shared across devices. The "lb"/"ub" containers are created over - # ALL `branch_names` (via `constraint_names`) so the TS path below can fill its share of - # the same containers; only `static_devices` are constrained here. - if use_slacks - add_slacked_range_constraints!( - container, - FlowRateConstraint, - flow_vars, - static_devices, - device_model, - slack_ub, - slack_lb; - constraint_names = branch_names, - ) - else - add_slacked_range_constraints!( - container, - FlowRateConstraint, - flow_vars, - static_devices, - device_model, - nothing, - nothing; - constraint_names = branch_names, - ) - end - - # TIME-SERIES rating path: the RHS is a parameterized rating (rating_factor * rating) - # that varies per time step, so it is not covered by the scalar-limit range helper. - # The static path above already created the "lb"/"ub" containers; fill the TS - # branches' entries via the shared parameterized-rating builder. - if !isempty(ts_devices) - con_lb = get_constraint(container, FlowRateConstraint, T, "lb") - con_ub = get_constraint(container, FlowRateConstraint, T, "ub") - for d in ts_devices - name = PSY.get_name(d) + # One lb/ub pair per reduced arc — the flow variables are shared per arc — with the + # rating from the arc's equivalent parameters. The TS parameter axes are already + # reduction-entry names. + reps = _representative_branches(network_model, T, FlowRateConstraint) + branch_names = [rep.name for rep in reps] + con_lb = add_constraints_container!( + container, FlowRateConstraint, T, branch_names, time_steps; meta = "lb", + ) + con_ub = add_constraints_container!( + container, FlowRateConstraint, T, branch_names, time_steps; meta = "ub", + ) + _for_each_branch(reps) do rep + name = rep.name + if name in ts_branch_names param = get_parameter_column_refs(param_container, name) if use_slacks add_parameterized_rating_constraints!( @@ -2402,6 +1976,19 @@ function add_constraints!( container, con_ub, con_lb, flow_vars, name, param, mult, ) end + else + limits = min_max_flow_limits(rep, device_model) + for t in time_steps + if use_slacks + ub_lhs = flow_vars[name, t] - slack_ub[name, t] + lb_lhs = flow_vars[name, t] + slack_lb[name, t] + else + ub_lhs = flow_vars[name, t] + lb_lhs = flow_vars[name, t] + end + con_ub[name, t] = JuMP.@constraint(jump_model, ub_lhs <= limits.max) + con_lb[name, t] = JuMP.@constraint(jump_model, lb_lhs >= limits.min) + end end end return @@ -2419,10 +2006,11 @@ function add_constraints!( va = get_variable(container, VoltageAngle, PSY.ACBus) p = get_variable(container, FlowActivePowerVariable, T) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] + reps = _representative_branches( + network_model, T, NetworkFlowConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) + branch_names = [rep.name for rep in reps] cons = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps, ) @@ -2441,26 +2029,36 @@ function add_constraints!( nothing end - for g in geoms, t in time_steps - angle = JuMP.@expression(jump_model, va[g.from_name, t] - va[g.to_name, t] - g.shift_dc) - flow = - if use_slacks - JuMP.@expression( - jump_model, - p[g.name, t] - slack_ub[g.name, t] + slack_lb[g.name, t] - ) - else - p[g.name, t] - end - cons[g.name, t] = - if _tap_controlled(device_model, g) - JuMP.@constraint( - jump_model, - flow * tap_var[g.name, t] == g.b_dc * angle * g.adm.tap - ) - else - JuMP.@constraint(jump_model, flow == g.b_dc * angle) - end + _for_each_branch(reps) do rep + name = rep.name + b = _dc_susceptance(rep) + shift = _dc_shift(rep) + from_name = _from_name(rep) + to_name = _to_name(rep) + tap_controlled = _tap_controlled(device_model, rep) + tap = tap_controlled ? _admittance(rep).tap : 1.0 + for t in time_steps + angle = + JuMP.@expression(jump_model, va[from_name, t] - va[to_name, t] - shift) + flow = + if use_slacks + JuMP.@expression( + jump_model, + p[name, t] - slack_ub[name, t] + slack_lb[name, t] + ) + else + p[name, t] + end + cons[name, t] = + if tap_controlled + JuMP.@constraint( + jump_model, + flow * tap_var[name, t] == b * angle * tap + ) + else + JuMP.@constraint(jump_model, flow == b * angle) + end + end end return end @@ -2475,7 +2073,7 @@ with the DC `b`/`shift` pair described on the `NetworkFlowConstraint` builder ab Angles are the only decision variables for StaticBranch under DCP — there is no `FlowActivePowerVariable` and no defining Ohm's-law equality; the flow is carried -directly as this expression. Uses the same `geoms = _branch_geometries(...)` walk +directly as this expression. Uses the same `reps = _representative_branches(...)` walk (one geometry per reduced arc, claimed against the `NetworkFlowConstraint` family so it interoperates with other branch types/formulations sharing an arc) as the variable-based Ohm's-law builder above. Also wires the expression into the two @@ -2493,29 +2091,30 @@ function add_expressions!( time_steps = get_time_steps(container) va = get_variable(container, VoltageAngle, PSY.ACBus) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] + reps = _representative_branches( + network_model, T, NetworkFlowConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) + branch_names = [rep.name for rep in reps] bfe = add_expression_container!(container, BThetaBranchFlow, T, branch_names, time_steps) nodal_expr = get_expression(container, ActivePowerBalance, PSY.ACBus) jump_model = get_jump_model(container) - for g in geoms - b = g.b_dc - shift = g.shift_dc - from_name = g.from_name - to_name = g.to_name - from_no = g.from_number - to_no = g.to_number + _for_each_branch(reps) do rep + b = _dc_susceptance(rep) + shift = _dc_shift(rep) + from_name = _from_name(rep) + to_name = _to_name(rep) + from_no = _from_number(rep) + to_no = _to_number(rep) for t in time_steps flow = JuMP.@expression( jump_model, b * (va[from_name, t] - va[to_name, t] - shift) ) - bfe[g.name, t] = flow + bfe[rep.name, t] = flow add_proportional_to_jump_expression!(nodal_expr[from_no, t], flow, -1.0) add_proportional_to_jump_expression!(nodal_expr[to_no, t], flow, 1.0) end @@ -2526,11 +2125,9 @@ end """ Add branch flow rate (rating) inequalities for ACBranch StaticBranch under DCPNetworkModel, directly on the `BThetaBranchFlow` expression (no defining -equality/variable to bound instead). Unifies the reduced/unreduced axis via -`_branch_rating_entries` (one entry per device when unreduced, one per not-yet-claimed -reduced arc otherwise) and reuses the shared static/parameterized-rating row builders, -mirroring the variable-based `FlowRateConstraint` DCP builder above but targeting the -expression container. +equality/variable to bound instead). Walks one representative per reduced arc and reuses +the shared static/parameterized-rating row builders, mirroring the variable-based +`FlowRateConstraint` DCP builder above but targeting the expression container. """ function add_constraints!( container::OptimizationContainer, @@ -2556,8 +2153,8 @@ function add_constraints!( ts_branch_names = Set(axes(mult, 1)) end - entries = _branch_rating_entries(network_model, devices, T, FlowRateConstraint) - branch_names = [name for (name, _) in entries] + reps = _representative_branches(network_model, T, FlowRateConstraint) + branch_names = [rep.name for rep in reps] con_lb = add_constraints_container!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "lb", ) @@ -2565,7 +2162,8 @@ function add_constraints!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "ub", ) - for (name, entry) in entries + _for_each_branch(reps) do rep + name = rep.name if name in ts_branch_names param = get_parameter_column_refs(param_container, name) if use_slacks @@ -2578,7 +2176,7 @@ function add_constraints!( ) end else - limits = min_max_flow_limits(entry, device_model) + limits = min_max_flow_limits(rep, device_model) for t in time_steps if use_slacks ub_lhs = bfe[name, t] - slack_ub[name, t] @@ -2623,32 +2221,35 @@ function add_constraints!( <:Union{DCPNetworkModel, ACPNetworkModel, DCPLLNetworkModel, LPACCNetworkModel}, }, ) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} - limited = [d for d in devices if _constrains_angle_difference(d)] - isempty(limited) && return + any(_constrains_angle_difference, devices) || return time_steps = get_time_steps(container) va = get_variable(container, VoltageAngle, PSY.ACBus) - number_to_name = _retained_number_to_name(sys, network_model) - # Angle limits are per-device data, so only direct entries whose device passed the - # filter receive a constraint; series/parallel equivalents carry no angle limits. - geoms = _branch_geometries( - number_to_name, network_model, devices, T, AngleDifferenceConstraint, - ) - limited_by_name = Dict(PSY.get_name(d) => d for d in limited) - constrained = [g for g in geoms if g.direct && haskey(limited_by_name, g.name)] - - branch_names = [g.name for g in constrained] + # Angle limits are per-device data, so only direct entries carrying non-default + # limits receive a constraint; series/parallel equivalents have none. + constrained = filter( + _constrains_angle_difference, + _representative_branches( + network_model, T, AngleDifferenceConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ), + ) + isempty(constrained) && return + + branch_names = [rep.name for rep in constrained] cons = add_constraints_container!( container, AngleDifferenceConstraint, T, branch_names, time_steps, ) - for g in constrained + _for_each_branch(constrained) do rep # angle limits are in radians — no per-unit conversion - lims = PSY.get_angle_limits(limited_by_name[g.name]) + lims = _angle_limits(rep) + from_name = _from_name(rep) + to_name = _to_name(rep) for t in time_steps - cons[g.name, t] = JuMP.@constraint( + cons[rep.name, t] = JuMP.@constraint( get_jump_model(container), - lims.min <= va[g.from_name, t] - va[g.to_name, t] <= lims.max, + lims.min <= va[from_name, t] - va[to_name, t] <= lims.max, ) end end @@ -2676,22 +2277,23 @@ function add_constraints!( ::DeviceModel{T, U}, network_model::NetworkModel{<:Union{ACRNetworkModel, IVRNetworkModel}}, ) where {T <: PSY.ACTransmission, U <: AbstractBranchFormulation} - limited = [d for d in devices if _constrains_angle_difference(d)] - isempty(limited) && return + any(_constrains_angle_difference, devices) || return time_steps = get_time_steps(container) vr = get_variable(container, VoltageReal, PSY.ACBus) vi = get_variable(container, VoltageImaginary, PSY.ACBus) - number_to_name = _retained_number_to_name(sys, network_model) - # Angle limits are per-device data, so only direct entries whose device passed the - # filter receive a constraint; series/parallel equivalents carry no angle limits. - geoms = _branch_geometries( - number_to_name, network_model, devices, T, AngleDifferenceConstraint, - ) - limited_by_name = Dict(PSY.get_name(d) => d for d in limited) - constrained = [g for g in geoms if g.direct && haskey(limited_by_name, g.name)] - - branch_names = [g.name for g in constrained] + # Angle limits are per-device data, so only direct entries carrying non-default + # limits receive a constraint; series/parallel equivalents have none. + constrained = filter( + _constrains_angle_difference, + _representative_branches( + network_model, T, AngleDifferenceConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ), + ) + isempty(constrained) && return + + branch_names = [rep.name for rep in constrained] cons_ub = add_constraints_container!( container, AngleDifferenceConstraint, T, branch_names, time_steps; meta = "ub", ) @@ -2700,16 +2302,16 @@ function add_constraints!( ) jump_model = get_jump_model(container) - for g in constrained + _for_each_branch(constrained) do rep # angle limits are in radians — no per-unit conversion - lims = PSY.get_angle_limits(limited_by_name[g.name]) - fr = g.from_name - to = g.to_name + lims = _angle_limits(rep) + fr = _from_name(rep) + to = _to_name(rep) for t in time_steps vvr = vr[fr, t] * vr[to, t] + vi[fr, t] * vi[to, t] vvi = vi[fr, t] * vr[to, t] - vr[fr, t] * vi[to, t] - cons_ub[g.name, t] = JuMP.@constraint(jump_model, vvi <= tan(lims.max) * vvr) - cons_lb[g.name, t] = JuMP.@constraint(jump_model, vvi >= tan(lims.min) * vvr) + cons_ub[rep.name, t] = JuMP.@constraint(jump_model, vvi <= tan(lims.max) * vvr) + cons_lb[rep.name, t] = JuMP.@constraint(jump_model, vvi >= tan(lims.min) * vvr) end end return @@ -2735,9 +2337,9 @@ end # Bound DCPLL directional active flows by the branch rating (system base). Finite bounds are # mandatory for QCP performance (Principle 0). A zero rating is a data error. Bounds are -# variable tightening (not one-per-arc constraints), so under an active reduction this -# runs over every reduction entry without claiming constraint-axis arcs; aliased per-arc -# variables tolerate the repeated tightening (all members carry the same equivalent). +# variable tightening (not one-per-arc constraints), so this runs over every branch name +# without claiming constraint-axis arcs; aliased per-arc variables tolerate the repeated +# tightening (all members carry the same equivalent). function _set_dcpll_flow_bounds!( container::OptimizationContainer, sys::PSY.System, @@ -2748,27 +2350,11 @@ function _set_dcpll_flow_bounds!( time_steps = get_time_steps(container) pft = get_variable(container, FlowActivePowerFromToVariable, T) ptf = get_variable(container, FlowActivePowerToFromVariable, T) - network_reduction = get_network_reduction(network_model) - if isempty(network_reduction) - for d in devices - name = PSY.get_name(d) - rate = PSY.get_rating(d) - iszero(rate) && - error("Branch $name has a zero rating; cannot bound DCPLL flows.") - for t in time_steps - _tighten_flow_bound!(pft[name, t], rate) - _tighten_flow_bound!(ptf[name, t], rate) - end - end - return - end - all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(network_reduction) - for (name, (arc, reduction)) in get_name_to_arc_map_entries(network_reduction, T) - entry = all_branch_maps_by_type[reduction][T][arc] - rate = _directional_flow_rating(entry, device_model) + _for_each_branch(_all_branches(network_model, T)) do rep + rate = _directional_flow_rating(rep, device_model) for t in time_steps - _tighten_flow_bound!(pft[name, t], rate) - _tighten_flow_bound!(ptf[name, t], rate) + _tighten_flow_bound!(pft[rep.name, t], rate) + _tighten_flow_bound!(ptf[rep.name, t], rate) end end return @@ -2799,8 +2385,8 @@ function add_constraints!( slack_lb = get_variable(container, FlowActivePowerSlackLowerBound, T) jump_model = get_jump_model(container) - entries = _branch_rating_entries(network_model, devices, T, FlowRateConstraint) - branch_names = [name for (name, _) in entries] + reps = _representative_branches(network_model, T, FlowRateConstraint) + branch_names = [rep.name for rep in reps] con_ft_ub = add_constraints_container!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "ft_ub", ) @@ -2814,8 +2400,9 @@ function add_constraints!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "tf_lb", ) - for (name, entry) in entries - limits = min_max_flow_limits(entry, device_model) + _for_each_branch(reps) do rep + name = rep.name + limits = min_max_flow_limits(rep, device_model) for t in time_steps con_ft_ub[name, t] = JuMP.@constraint( jump_model, @@ -2850,10 +2437,11 @@ function add_constraints!( va = get_variable(container, VoltageAngle, PSY.ACBus) pft = get_variable(container, FlowActivePowerFromToVariable, T) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] + reps = _representative_branches( + network_model, T, NetworkFlowConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) + branch_names = [rep.name for rep in reps] cons = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps, ) @@ -2866,17 +2454,27 @@ function add_constraints!( nothing end - for g in geoms, t in time_steps - angle = JuMP.@expression(jump_model, va[g.from_name, t] - va[g.to_name, t] - g.shift_dc) - cons[g.name, t] = - if _tap_controlled(device_model, g) - JuMP.@constraint( - jump_model, - pft[g.name, t] * tap_var[g.name, t] == g.b_dc * angle * g.adm.tap - ) - else - JuMP.@constraint(jump_model, pft[g.name, t] == g.b_dc * angle) - end + _for_each_branch(reps) do rep + name = rep.name + b = _dc_susceptance(rep) + shift = _dc_shift(rep) + from_name = _from_name(rep) + to_name = _to_name(rep) + tap_controlled = _tap_controlled(device_model, rep) + tap = tap_controlled ? _admittance(rep).tap : 1.0 + for t in time_steps + angle = + JuMP.@expression(jump_model, va[from_name, t] - va[to_name, t] - shift) + cons[name, t] = + if tap_controlled + JuMP.@constraint( + jump_model, + pft[name, t] * tap_var[name, t] == b * angle * tap + ) + else + JuMP.@constraint(jump_model, pft[name, t] == b * angle) + end + end end return end @@ -2902,21 +2500,22 @@ function add_constraints!( pft = get_variable(container, FlowActivePowerFromToVariable, T) ptf = get_variable(container, FlowActivePowerToFromVariable, T) - number_to_name = _retained_number_to_name(sys, network_model) - geoms = - _branch_geometries(number_to_name, network_model, devices, T, NetworkLossConstraint) - branch_names = [g.name for g in geoms] + reps = _representative_branches( + network_model, T, NetworkLossConstraint; + number_to_name = _retained_number_to_name(sys, network_model), + ) + branch_names = [rep.name for rep in reps] cons = add_constraints_container!( container, NetworkLossConstraint, T, branch_names, time_steps, ) jump_model = get_jump_model(container) - for g in geoms - r = g.r_dc + _for_each_branch(reps) do rep + r = _dc_resistance(rep) for t in time_steps - cons[g.name, t] = JuMP.@constraint( + cons[rep.name, t] = JuMP.@constraint( jump_model, - pft[g.name, t] + ptf[g.name, t] >= r * pft[g.name, t]^2, + pft[rep.name, t] + ptf[rep.name, t] >= r * pft[rep.name, t]^2, ) end end diff --git a/src/ac_transmission_models/RepresentativeBranch.jl b/src/ac_transmission_models/RepresentativeBranch.jl new file mode 100644 index 0000000..7bc4e71 --- /dev/null +++ b/src/ac_transmission_models/RepresentativeBranch.jl @@ -0,0 +1,331 @@ +#################################### RepresentativeBranch ################################## + +const DIRECT_BRANCH_MAP = "direct_branch_map" + +# Stand-in bus-name map for builders whose `add_constraints!` signature carries no `sys` +# (the rating/limit families, which never need endpoint names). `_bus_name` errors rather +# than return a wrong name if such a branch is asked for one. +const _NO_BUS_NAMES = Dict{Int, String}() + +""" +One branch as the reduction-aware builders see it. Build with +[`_representative_branches`](@ref) (one per arc, for constraint rows) or +[`_all_branches`](@ref) (one per device name, for variables), and iterate with +[`_for_each_branch`](@ref). + +`branch` is the direct PSY device or the PNM series/parallel equivalent standing in for the +arc; the surrounding fields carry the arc context (`nr`, `arc`, `reduction`, +`number_to_name`) that the accessors below need and cannot recover from `branch` alone. + +Quantities are read through the accessors rather than stored, so a DC build never pays to +compute AC admittances. The `B` parameter is what keeps those accessors inferred: one arc +map can mix direct devices with series/parallel equivalents, so a vector of these is not +concretely typed and must be walked through [`_for_each_branch`](@ref). +""" +struct RepresentativeBranch{B} + name::String + arc::Tuple{Int, Int} + reduction::String + branch::B + nr::PNM.NetworkReductionData + number_to_name::Dict{Int, String} +end + +""" +Apply `f` to each representative branch, specializing it on the entry type. + +The vector `reps` is not concretely typed whenever an arc map mixes direct devices with +reduction equivalents, so iterating it inline would read `rep.branch` as `Any` and leave +every accessor — and the JuMP expressions built from them — uninferred. Dispatching through +`f` pays one dynamic dispatch per arc and specializes the whole body, nested time-step loop +included. + + _for_each_branch(reps) do rep + b = _dc_susceptance(rep) # inferred + ... + end +""" +function _for_each_branch(f::F, reps) where {F} + for rep in reps + f(rep) + end + return +end + +function _make_representative_branch( + nr::PNM.NetworkReductionData, + all_branch_maps_by_type::Dict, + arc_map, + number_to_name::Dict{Int, String}, + ::Type{T}, + name::AbstractString, +) where {T <: PSY.ACTransmission} + (arc, reduction) = arc_map[name] + return RepresentativeBranch( + name, + arc, + reduction, + all_branch_maps_by_type[reduction][T][arc], + nr, + number_to_name, + ) +end + +""" +One [`RepresentativeBranch`](@ref) per reduced arc of `T` not already claimed for the +constraint family `C`, in the axis order the constraint containers must be sized with. + +Every member of a reduced arc shares one set of flow variables, so the arc's physics must +be constrained exactly once; the claim is recorded in the network model's branch tracker so +the guarantee holds across separate `construct_device!` calls. Use [`_all_branches`](@ref) +for variable creation and bound tightening, which visit every device name and claim +nothing. + +Pass `number_to_name` (from `_retained_number_to_name`) whenever the builder reads endpoint +bus names; builders with no `sys` in scope may omit it. +""" +function _representative_branches( + network_model::NetworkModel, + ::Type{T}, + ::Type{C}; + number_to_name::Dict{Int, String} = _NO_BUS_NAMES, +) where {T <: PSY.ACTransmission, C <: ConstraintType} + nr = get_network_reduction(network_model) + tracker = get_reduced_branch_tracker(network_model) + arc_map = get_name_to_arc_map_entries(nr, T) + all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(nr) + return [ + _make_representative_branch( + nr, all_branch_maps_by_type, arc_map, number_to_name, T, name, + ) + for name in get_branch_argument_constraint_axis(nr, tracker, T, C) + ] +end + +""" +One entry per branch *name* of `T` — every device, including each member of a merged arc, +so no device drops out of the model. Several entries may therefore share an arc and alias +the same underlying JuMP variables. + +For variable creation and variable-bound tightening, which must register every device name +and tolerate repeated visits to an arc. Claims no constraint axis: use +[`_representative_branches`](@ref) for constraint rows, which must cover each arc exactly +once. +""" +function _all_branches( + network_model::NetworkModel, + ::Type{T}; + number_to_name::Dict{Int, String} = _NO_BUS_NAMES, +) where {T <: PSY.ACTransmission} + nr = get_network_reduction(network_model) + arc_map = get_name_to_arc_map_entries(nr, T) + all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(nr) + return [ + _make_representative_branch( + nr, all_branch_maps_by_type, arc_map, number_to_name, T, name, + ) + for name in keys(arc_map) + ] +end + +################################## Topology ################################################ + +_from_number(rep::RepresentativeBranch) = rep.arc[1] +_to_number(rep::RepresentativeBranch) = rep.arc[2] + +function _bus_name(rep::RepresentativeBranch, number::Int) + name = get(rep.number_to_name, number, nothing) + name === nothing && error( + "RepresentativeBranch $(rep.name) carries no bus-name map; build it with \ + `number_to_name = _retained_number_to_name(sys, network_model)` to read \ + endpoint bus names.", + ) + return name +end + +_from_name(rep::RepresentativeBranch) = _bus_name(rep, _from_number(rep)) +_to_name(rep::RepresentativeBranch) = _bus_name(rep, _to_number(rep)) + +_is_aggregate(::PNM.AbstractReductionAggregate) = true +_is_aggregate(::PSY.ACTransmission) = false + +""" +Whether this arc is a PNM series/parallel equivalent rather than a single device. +Aggregates carry no per-device data (angle limits, control circuits), so the accessors for +those fall back to defaults. +""" +_is_aggregate(rep::RepresentativeBranch) = _is_aggregate(rep.branch) + +# A merged arc keeps the name of one member but is no longer that member alone. +_is_direct(rep::RepresentativeBranch) = rep.reduction == DIRECT_BRANCH_MAP + +################################## Electrical ############################################## + +_branch_admittance(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = + PNM.branch_admittance(branch, nr) +_branch_admittance(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = + PNM.branch_admittance(branch) + +_dc_phase_shift(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = + PNM.get_series_phase_shift(branch, nr) +_dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = + PNM.get_series_phase_shift(branch) + +""" +Full π-model admittance `(g, b, g_fr, b_fr, g_to, b_to, tap, shift)` for the arc. +""" +_admittance(rep::RepresentativeBranch) = _branch_admittance(rep.branch, rep.nr) + +# DC susceptance `1/(tap*x)` — tap-divided, not the r-inclusive π-model susceptance. +_dc_susceptance(rep::RepresentativeBranch) = + PNM.get_series_susceptance(rep.branch, PSY.SU) +_dc_shift(rep::RepresentativeBranch) = _dc_phase_shift(rep.branch, rep.nr) +_dc_resistance(rep::RepresentativeBranch) = PNM.arc_dc_resistance(rep.nr, rep.arc) + +################################## Transformer control ##################################### + +_get_circuit(t::PSY.TwoWindingTransformer) = PSY.get_circuit(t) +_get_circuit(t::PNM.ThreeWindingTransformerCircuit) = t.circuit +_get_circuit(_) = nothing + +_control_objective(::Nothing) = PSY.TransformerControlObjective.UNDEFINED +_control_objective(c::PSY.TransformerCircuit) = + if PSY.get_available(c) + PSY.get_control_objective(c) + else + PSY.TransformerControlObjective.UNDEFINED + end + +""" +Control objective of the arc's transformer circuit, or `UNDEFINED` for anything that is not +an available controlled transformer. +""" +_control_objective(rep::RepresentativeBranch) = _control_objective(_get_circuit(rep.branch)) + +_quantity_limits(::Nothing) = (min = -Inf, max = Inf) +_quantity_limits(c::PSY.TransformerCircuit) = PSY.get_controlled_quantity_limits(c) +_quantity_limits(rep::RepresentativeBranch) = _quantity_limits(_get_circuit(rep.branch)) + +_regulated_number(::Nothing) = -1 +_regulated_number(c::PSY.TransformerCircuit) = PSY.get_regulated_bus_number(c) +_regulated_number(rep::RepresentativeBranch) = _regulated_number(_get_circuit(rep.branch)) + +_tap_controlled(rep::RepresentativeBranch) = _tap_controlled(_control_objective(rep)) +_voltage_controlled(rep::RepresentativeBranch) = _voltage_controlled(_control_objective(rep)) +_reactive_controlled(rep::RepresentativeBranch) = + _reactive_controlled(_control_objective(rep)) + +################################## Ratings and limits ###################################### + +# Resolve the per-DeviceModel attribute to one of the explicit PNM rating functions. +# `MixedBranchesParallel` ignores the attribute and always uses the plain sum, since the +# constituent branches may carry different DeviceModel preferences and there is no +# defensible way to pick one. The PNM aggregators return system-base values. +function _parallel_branches_rating(model::DeviceModel, bp::PNM.BranchesParallel) + method = get_attribute(model, PARALLEL_BRANCH_MAX_RATING_KEY) + if method == "single_element_contingency" + return PNM.get_single_element_contingency_rating(bp) + elseif method == "sum_of_max" + return PNM.get_sum_of_max_rating(bp) + elseif method == "impedance_averaged" + return PNM.get_impedance_averaged_rating(bp) + else + error( + "Unknown $PARALLEL_BRANCH_MAX_RATING_KEY value: $(repr(method)). " * + "Valid: \"single_element_contingency\", \"sum_of_max\", \"impedance_averaged\".", + ) + end +end + +_parallel_branches_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel) = + PNM.get_sum_of_max_rating(mbp) + +# System base throughout, matching the per-unit flow variables. The PNM aggregators already +# return system base; a direct device must be read with `PSY.SU` explicitly, since +# `PNM.get_equivalent_rating` on a bare device reads its *device* base (`PSY.DU`). +_branch_rating(d::PSY.ACTransmission, ::DeviceModel) = PSY.get_rating(d, PSY.SU) +_branch_rating(entry::PNM.BranchesSeries, ::DeviceModel) = PNM.get_equivalent_rating(entry) +_branch_rating(entry::PNM.AbstractBranchesParallel, model::DeviceModel) = + _parallel_branches_rating(model, entry) + +""" +Thermal rating of the arc in system base: the device's own rating for a direct branch, the +PNM equivalent for a series arc, and the [`PARALLEL_BRANCH_MAX_RATING_KEY`](@ref) +aggregation for a parallel group. +""" +_branch_rating(rep::RepresentativeBranch, model::DeviceModel) = + _branch_rating(rep.branch, model) + +""" +[`_branch_rating`](@ref) with a zero guard, for the flow limits that would otherwise pin +the arc to zero flow. + +Zero is a data error rather than "unlimited" as in MATPOWER-style data: `p² + q² ≤ 0` would +silently delete the branch from the network. +""" +function _directional_flow_rating(rep::RepresentativeBranch, model::DeviceModel) + rating = _branch_rating(rep, model) + iszero(rating) && error( + "Branch $(rep.name) has a zero rating; the flow limit would force zero flow. \ + Assign a non-zero thermal rating to it or its member branches, or use an \ + unbounded formulation.", + ) + return rating +end + +function _min_max_flow_limits(entry, model::DeviceModel) + rating = _branch_rating(entry, model) + return (min = -rating, max = rating) +end + +# `MonitoredLine` carries explicit, possibly asymmetric `flow_limits`; defer to its own +# `get_min_max_limits` instead of the symmetric rating. +_min_max_flow_limits(device::PSY.MonitoredLine, ::DeviceModel) = + get_min_max_limits(device, FlowRateConstraint, AbstractBranchFormulation) + +""" +Symmetric `(min, max)` flow limits from [`_branch_rating`](@ref), except for +`MonitoredLine`, which carries its own possibly asymmetric monitoring limits. +""" +min_max_flow_limits(rep::RepresentativeBranch, model::DeviceModel) = + _min_max_flow_limits(rep.branch, model) + +function _min_endpoint_voltage_limit(branch::PSY.ACTransmission) + arc = PSY.get_arc(branch) + # bus voltage limits are already per-unit + vmin_fr = PSY.get_voltage_limits(PSY.get_from(arc)).min + vmin_to = PSY.get_voltage_limits(PSY.get_to(arc)).min + return min(vmin_fr, vmin_to) +end + +_min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) = + minimum(_min_endpoint_voltage_limit(member) for member in entry) + +""" +Current rating of the arc: apparent-power rating over the lowest endpoint voltage it can +see, so the bound holds across the whole voltage band. +""" +function _current_rating(rep::RepresentativeBranch, model::DeviceModel) + rate_a = _directional_flow_rating(rep, model) + vmin = _min_endpoint_voltage_limit(rep.branch) + vmin <= 0.0 && + error("IVR: $(rep.name) has a non-positive endpoint voltage minimum ($vmin)") + return rate_a / vmin +end + +""" +Angle-difference bounds for the arc. Reduction equivalents carry no angle-limit data and +take the same finite ±π/2 default `_angle_limits` uses for devices without the +angle-limits API. +""" +_angle_limits(rep::RepresentativeBranch) = + _is_aggregate(rep) ? (min = -π / 2, max = π / 2) : _angle_limits(rep.branch) + +_constrains_angle_difference(rep::RepresentativeBranch) = + !_is_aggregate(rep) && _constrains_angle_difference(rep.branch) + +# Widest angle excursion the arc allows, the `vad_max` of the LPAC cosine relaxation. +function _max_angle_difference(rep::RepresentativeBranch) + lims = _angle_limits(rep) + return max(abs(lims.min), abs(lims.max)) +end From ad042e9db50c43723df3053c737dbbfe656aa267 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 18 Aug 2026 16:54:21 -0400 Subject: [PATCH 15/19] cleaned up claude's code; control constraints use new api --- src/ac_transmission_models/AC_branches.jl | 110 ++++++++-------- .../RepresentativeBranch.jl | 117 ++++-------------- 2 files changed, 84 insertions(+), 143 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 48cd60c..c3ebdd5 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -165,25 +165,13 @@ end #################################### Flow Variable Bounds ################################################## -_branch_variable_bounds( - ::Type{V}, - rep::RepresentativeBranch, - ::DeviceModel{T, F}, -) where {V <: VariableType, T <: PSY.ACTransmission, F <: AbstractBranchFormulation} = +_branch_variable_bounds(::Type{V}, rep, ::DeviceModel{F}) where {V <: VariableType, F <: AbstractBranchFormulation} = ( get_variable_lower_bound(V, rep.branch, F), get_variable_upper_bound(V, rep.branch, F), ) -_angle_limits(d::PSY.Line) = PSY.get_angle_limits(d) -_angle_limits(d::PSY.MonitoredLine) = PSY.get_angle_limits(d) -_angle_limits(::PSY.ACTransmission) = (min = -π / 2, max = π / 2) - -function _branch_variable_bounds( - ::Type{CosineApproximation}, - rep::RepresentativeBranch, - ::DeviceModel{T, F}, -) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} +function _branch_variable_bounds(::Type{CosineApproximation}, rep, _) lims = _angle_limits(rep) if lims.min >= 0 return (cos(lims.max), cos(lims.min)) @@ -194,18 +182,24 @@ function _branch_variable_bounds( end end -function _branch_variable_bounds( - ::Type{<:AbstractBranchCurrentVariable}, - rep::RepresentativeBranch, - device_model::DeviceModel{T, F}, -) where {T <: PSY.ACTransmission, F <: AbstractBranchFormulation} +function _branch_variable_bounds(_, rep, device_model) rating = _current_rating(rep, device_model) return (-rating, rating) end +_branch_variable_bounds(::Type{TapRatioVariable}) = (min = 0.0, max = 1.0) + _branch_variable_start(::Type{CosineApproximation}) = 1.0 +_branch_variable_start(::Type{TapRatioVariable}) = 1.0 _branch_variable_start(_) = nothing +_is_control(::Type{TapRatioVariable}) = true +_is_control(_) = false + +_branch_uses_control(::Type{TapRatioVariable}, branch) = _tap_controlled(branch) +#_branch_uses_control(::Type{PhaseShiftVariable}, branch) = _phase_controlled(branch) +_branch_uses_control(_, _) = true + """ Branch variables for reduction-aware networks. Every entry of a reduced arc aliases the same underlying JuMP variable. @@ -224,10 +218,15 @@ function add_variables!( reduced_branch_tracker = get_reduced_branch_tracker(network_model) start = _branch_variable_start(V) - # Every device name, not one per arc: members merged into a shared arc must still be - # registered on the variable axis, aliasing the arc's variables via the tracker. - branches = _all_branches(network_model, T) - + branches = if _is_control(V) + members = RepresentativeBranch[] + _for_each_branch(_all_branches(network_model, T)) do branch + _branch_uses_control(V, branch) && push!(members, branch) + end + members + else + _all_branches(network_model, T) + end variable_container = add_variable_container!( container, V, @@ -236,14 +235,14 @@ function add_variables!( time_steps, ) - _for_each_branch(branches) do rep + _for_each_branch(branches) do branch has_entry, tracker_container = search_for_reduced_branch_variable!( reduced_branch_tracker, - rep.arc, + branch.arc, V, ) if !has_entry - (lb, ub) = _branch_variable_bounds(V, rep, device_model) + (lb, ub) = _branch_variable_bounds(V, branch, device_model) for t in time_steps var = JuMP.@variable( jump_model, @@ -256,7 +255,7 @@ function add_variables!( end end for t in time_steps - variable_container[rep.name, t] = tracker_container[t] + variable_container[branch.name, t] = tracker_container[t] end end return @@ -279,10 +278,17 @@ function _add_tap_control_variables!( } _control_enabled(model) || return _warn_tap_control_nonconvexity(network_model) - add_variables!(container, TapRatioVariable, devices, device_model, network_model) + add_variables!(container, TapRatioVariable, devices, model, network_model) return end +_add_tap_control_variables!( + ::OptimizationContainer, + ::DeviceModel{U}, + ::IS.FlattenIteratorWrapper{U}, + ::NetworkModel, +) where {U <: PSY.ACTransmission} = nothing + function _add_meta_flow_slack!( container::OptimizationContainer, ::Type{T}, @@ -1516,23 +1522,23 @@ function _add_voltage_control_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - for (circuit, circuit_name) in _iter_branches(devices) - _voltage_controlled(device_model, circuit) || continue + _for_each_branch(_representative_branches(network_model, T, VoltageMagnitudeConstraint)) do rep + _voltage_controlled(device_model, rep) || return - bus = PSY.get_bus(sys, PSY.get_regulated_bus_number(circuit)) + bus = PSY.get_bus(sys, _regulated_number(rep)) bus_name = PSY.get_name(bus) bus_limits = PSY.get_voltage_limits(bus) - ctl_limits = PSY.get_controlled_quantity_limits(circuit) + ctl_limits = _quantity_limits(rep) # TODO: temporary pending PSY#1755 (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error( - "Bus voltage limits for $bus_name disagree with control limits for circuit $circuit_name.", + "Bus voltage limits for $bus_name disagree with control limits for circuit $(rep.name).", ) lims = _voltage_limits(ctl_limits, network_model) vm = _voltage_magnitude(container, bus_name, network_model) for t in time_steps - cons[circuit_name, 1, t] = JuMP.@constraint(jump_model, vm[t] >= lims.min) - cons[circuit_name, 2, t] = JuMP.@constraint(jump_model, vm[t] <= lims.max) + cons[rep.name, 1, t] = JuMP.@constraint(jump_model, vm[t] >= lims.min) + cons[rep.name, 2, t] = JuMP.@constraint(jump_model, vm[t] <= lims.max) end end return @@ -1544,13 +1550,13 @@ _add_voltage_control_constraints!( ::IS.FlattenIteratorWrapper{T}, ::DeviceModel{T}, ::NetworkModel, -) where {T} = nothing +) where {T <: PSY.ACTransmission} = nothing function _add_reactive_control_constraints!( container::OptimizationContainer, devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T}, - ::NetworkModel{<:NativeACNetworkModel}, + network_model::NetworkModel{<:NativeACNetworkModel}, ) where {T <: _TRANSFORMERS} _control_enabled(device_model) || return @@ -1568,9 +1574,10 @@ function _add_reactive_control_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - for (circuit, name) in _iter_branches(devices) - _reactive_controlled(device_model, circuit) || continue - lims = PSY.get_controlled_quantity_limits(circuit) + _for_each_branch(_representative_branches(network_model, T, ReactivePowerFlowControlConstraint)) do rep + name = rep.name + _reactive_controlled(device_model, rep) || return + lims = _quantity_limits(rep) for t in time_steps cons[name, 1, t] = @@ -1591,7 +1598,7 @@ _add_reactive_control_constraints!( ::IS.FlattenIteratorWrapper{T}, ::DeviceModel{T}, ::NetworkModel, -) where {T} = nothing +) where {T <: PSY.ACTransmission} = nothing function _add_transformer_control_constraints!( container::OptimizationContainer, @@ -1599,14 +1606,21 @@ function _add_transformer_control_constraints!( devices::IS.FlattenIteratorWrapper{T}, device_model::DeviceModel{T}, network_model::NetworkModel, -) where {T <: PSY.ACTransmission} +) where {T <: _TRANSFORMERS} _add_voltage_control_constraints!(container, sys, devices, device_model, network_model) _add_reactive_control_constraints!(container, devices, device_model, network_model) return end -################################## LPACCNetworkModel branch constraints ############### +_add_transformer_control_constraints!( + ::OptimizationContainer, + ::PSY.System, + ::IS.FlattenIteratorWrapper{T}, + ::DeviceModel{T}, + ::NetworkModel, +) where {T <: PSY.ACTransmission} = nothing +################################## LPACCNetworkModel branch constraints ############### """ Add the LPAC convex cosine relaxation for ACBranch under LPACCNetworkModel: @@ -2193,16 +2207,6 @@ function add_constraints!( return end -# A branch constrains the angle difference when it carries angle-limit data (only -# Line / MonitoredLine do) narrower than the PSY default ±π window. -_constrains_angle_difference(::PSY.ACTransmission) = false -# angle limits are in radians — no per-unit conversion -_constrains_angle_difference(d::PSY.Line) = - _is_binding_angle_window(PSY.get_angle_limits(d)) -_constrains_angle_difference(d::PSY.MonitoredLine) = - _is_binding_angle_window(PSY.get_angle_limits(d)) -_is_binding_angle_window(lims) = !(lims.min ≈ -π && lims.max ≈ π) - """ Add branch angle-difference limit constraints for ACBranch under DCP/ACP/DCPLL/LPACC network models. diff --git a/src/ac_transmission_models/RepresentativeBranch.jl b/src/ac_transmission_models/RepresentativeBranch.jl index 7bc4e71..fca3d5b 100644 --- a/src/ac_transmission_models/RepresentativeBranch.jl +++ b/src/ac_transmission_models/RepresentativeBranch.jl @@ -13,14 +13,8 @@ One branch as the reduction-aware builders see it. Build with [`_all_branches`](@ref) (one per device name, for variables), and iterate with [`_for_each_branch`](@ref). -`branch` is the direct PSY device or the PNM series/parallel equivalent standing in for the -arc; the surrounding fields carry the arc context (`nr`, `arc`, `reduction`, -`number_to_name`) that the accessors below need and cannot recover from `branch` alone. - -Quantities are read through the accessors rather than stored, so a DC build never pays to -compute AC admittances. The `B` parameter is what keeps those accessors inferred: one arc -map can mix direct devices with series/parallel equivalents, so a vector of these is not -concretely typed and must be walked through [`_for_each_branch`](@ref). +`B` is either a PSY.Device, a PNM.AbstractReductionAggregate, or a +PNM.ThreeWindingTransformerCircuit. """ struct RepresentativeBranch{B} name::String @@ -32,18 +26,7 @@ struct RepresentativeBranch{B} end """ -Apply `f` to each representative branch, specializing it on the entry type. - -The vector `reps` is not concretely typed whenever an arc map mixes direct devices with -reduction equivalents, so iterating it inline would read `rep.branch` as `Any` and leave -every accessor — and the JuMP expressions built from them — uninferred. Dispatching through -`f` pays one dynamic dispatch per arc and specializes the whole body, nested time-step loop -included. - - _for_each_branch(reps) do rep - b = _dc_susceptance(rep) # inferred - ... - end +Used for specializing the device loop per concrete RepresentativeBranch. """ function _for_each_branch(f::F, reps) where {F} for rep in reps @@ -54,7 +37,7 @@ end function _make_representative_branch( nr::PNM.NetworkReductionData, - all_branch_maps_by_type::Dict, + all_branch_maps_by_type::PNM.BranchMapsByType, arc_map, number_to_name::Dict{Int, String}, ::Type{T}, @@ -75,12 +58,6 @@ end One [`RepresentativeBranch`](@ref) per reduced arc of `T` not already claimed for the constraint family `C`, in the axis order the constraint containers must be sized with. -Every member of a reduced arc shares one set of flow variables, so the arc's physics must -be constrained exactly once; the claim is recorded in the network model's branch tracker so -the guarantee holds across separate `construct_device!` calls. Use [`_all_branches`](@ref) -for variable creation and bound tightening, which visit every device name and claim -nothing. - Pass `number_to_name` (from `_retained_number_to_name`) whenever the builder reads endpoint bus names; builders with no `sys` in scope may omit it. """ @@ -103,14 +80,7 @@ function _representative_branches( end """ -One entry per branch *name* of `T` — every device, including each member of a merged arc, -so no device drops out of the model. Several entries may therefore share an arc and alias -the same underlying JuMP variables. - -For variable creation and variable-bound tightening, which must register every device name -and tolerate repeated visits to an arc. Claims no constraint axis: use -[`_representative_branches`](@ref) for constraint rows, which must cover each arc exactly -once. +One entry per device regardless of reduction. Use for variable container axes. """ function _all_branches( network_model::NetworkModel, @@ -148,34 +118,21 @@ _to_name(rep::RepresentativeBranch) = _bus_name(rep, _to_number(rep)) _is_aggregate(::PNM.AbstractReductionAggregate) = true _is_aggregate(::PSY.ACTransmission) = false - -""" -Whether this arc is a PNM series/parallel equivalent rather than a single device. -Aggregates carry no per-device data (angle limits, control circuits), so the accessors for -those fall back to defaults. -""" _is_aggregate(rep::RepresentativeBranch) = _is_aggregate(rep.branch) -# A merged arc keeps the name of one member but is no longer that member alone. -_is_direct(rep::RepresentativeBranch) = rep.reduction == DIRECT_BRANCH_MAP - ################################## Electrical ############################################## -_branch_admittance(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = +_admittance(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = PNM.branch_admittance(branch, nr) -_branch_admittance(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = +_admittance(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = PNM.branch_admittance(branch) +_admittance(rep::RepresentativeBranch) = _admittance(rep.branch, rep.nr) _dc_phase_shift(branch::PNM.AbstractReductionAggregate, nr::PNM.NetworkReductionData) = PNM.get_series_phase_shift(branch, nr) _dc_phase_shift(branch::PSY.ACTransmission, ::PNM.NetworkReductionData) = PNM.get_series_phase_shift(branch) -""" -Full π-model admittance `(g, b, g_fr, b_fr, g_to, b_to, tap, shift)` for the arc. -""" -_admittance(rep::RepresentativeBranch) = _branch_admittance(rep.branch, rep.nr) - # DC susceptance `1/(tap*x)` — tap-divided, not the r-inclusive π-model susceptance. _dc_susceptance(rep::RepresentativeBranch) = PNM.get_series_susceptance(rep.branch, PSY.SU) @@ -195,13 +152,13 @@ _control_objective(c::PSY.TransformerCircuit) = else PSY.TransformerControlObjective.UNDEFINED end - -""" -Control objective of the arc's transformer circuit, or `UNDEFINED` for anything that is not -an available controlled transformer. -""" _control_objective(rep::RepresentativeBranch) = _control_objective(_get_circuit(rep.branch)) +_tap_controlled(rep::RepresentativeBranch) = _tap_controlled(_control_objective(rep)) +_voltage_controlled(rep::RepresentativeBranch) = _voltage_controlled(_control_objective(rep)) +_reactive_controlled(rep::RepresentativeBranch) = + _reactive_controlled(_control_objective(rep)) + _quantity_limits(::Nothing) = (min = -Inf, max = Inf) _quantity_limits(c::PSY.TransformerCircuit) = PSY.get_controlled_quantity_limits(c) _quantity_limits(rep::RepresentativeBranch) = _quantity_limits(_get_circuit(rep.branch)) @@ -210,17 +167,8 @@ _regulated_number(::Nothing) = -1 _regulated_number(c::PSY.TransformerCircuit) = PSY.get_regulated_bus_number(c) _regulated_number(rep::RepresentativeBranch) = _regulated_number(_get_circuit(rep.branch)) -_tap_controlled(rep::RepresentativeBranch) = _tap_controlled(_control_objective(rep)) -_voltage_controlled(rep::RepresentativeBranch) = _voltage_controlled(_control_objective(rep)) -_reactive_controlled(rep::RepresentativeBranch) = - _reactive_controlled(_control_objective(rep)) - ################################## Ratings and limits ###################################### -# Resolve the per-DeviceModel attribute to one of the explicit PNM rating functions. -# `MixedBranchesParallel` ignores the attribute and always uses the plain sum, since the -# constituent branches may carry different DeviceModel preferences and there is no -# defensible way to pick one. The PNM aggregators return system-base values. function _parallel_branches_rating(model::DeviceModel, bp::PNM.BranchesParallel) method = get_attribute(model, PARALLEL_BRANCH_MAX_RATING_KEY) if method == "single_element_contingency" @@ -240,28 +188,19 @@ end _parallel_branches_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel) = PNM.get_sum_of_max_rating(mbp) -# System base throughout, matching the per-unit flow variables. The PNM aggregators already -# return system base; a direct device must be read with `PSY.SU` explicitly, since -# `PNM.get_equivalent_rating` on a bare device reads its *device* base (`PSY.DU`). _branch_rating(d::PSY.ACTransmission, ::DeviceModel) = PSY.get_rating(d, PSY.SU) +_branch_rating(t::PSY.TwoWindingTransformer, ::DeviceModel) = PSY.get_rating(PSY.get_circuit(t), PSY.SU) +_branch_rating(t::PNM.ThreeWindingTransformerCircuit, ::DeviceModel) = PSY.get_rating(t.circuit, PSY.SU) _branch_rating(entry::PNM.BranchesSeries, ::DeviceModel) = PNM.get_equivalent_rating(entry) _branch_rating(entry::PNM.AbstractBranchesParallel, model::DeviceModel) = _parallel_branches_rating(model, entry) -""" -Thermal rating of the arc in system base: the device's own rating for a direct branch, the -PNM equivalent for a series arc, and the [`PARALLEL_BRANCH_MAX_RATING_KEY`](@ref) -aggregation for a parallel group. -""" _branch_rating(rep::RepresentativeBranch, model::DeviceModel) = _branch_rating(rep.branch, model) """ [`_branch_rating`](@ref) with a zero guard, for the flow limits that would otherwise pin the arc to zero flow. - -Zero is a data error rather than "unlimited" as in MATPOWER-style data: `p² + q² ≤ 0` would -silently delete the branch from the network. """ function _directional_flow_rating(rep::RepresentativeBranch, model::DeviceModel) rating = _branch_rating(rep, model) @@ -277,16 +216,8 @@ function _min_max_flow_limits(entry, model::DeviceModel) rating = _branch_rating(entry, model) return (min = -rating, max = rating) end - -# `MonitoredLine` carries explicit, possibly asymmetric `flow_limits`; defer to its own -# `get_min_max_limits` instead of the symmetric rating. _min_max_flow_limits(device::PSY.MonitoredLine, ::DeviceModel) = get_min_max_limits(device, FlowRateConstraint, AbstractBranchFormulation) - -""" -Symmetric `(min, max)` flow limits from [`_branch_rating`](@ref), except for -`MonitoredLine`, which carries its own possibly asymmetric monitoring limits. -""" min_max_flow_limits(rep::RepresentativeBranch, model::DeviceModel) = _min_max_flow_limits(rep.branch, model) @@ -297,7 +228,6 @@ function _min_endpoint_voltage_limit(branch::PSY.ACTransmission) vmin_to = PSY.get_voltage_limits(PSY.get_to(arc)).min return min(vmin_fr, vmin_to) end - _min_endpoint_voltage_limit(entry::PNM.AbstractReductionAggregate) = minimum(_min_endpoint_voltage_limit(member) for member in entry) @@ -313,14 +243,21 @@ function _current_rating(rep::RepresentativeBranch, model::DeviceModel) return rate_a / vmin end -""" -Angle-difference bounds for the arc. Reduction equivalents carry no angle-limit data and -take the same finite ±π/2 default `_angle_limits` uses for devices without the -angle-limits API. -""" +_angle_limits(d::PSY.Line) = PSY.get_angle_limits(d) +_angle_limits(d::PSY.MonitoredLine) = PSY.get_angle_limits(d) +_angle_limits(::PSY.ACTransmission) = (min = -π / 2, max = π / 2) _angle_limits(rep::RepresentativeBranch) = _is_aggregate(rep) ? (min = -π / 2, max = π / 2) : _angle_limits(rep.branch) +# A branch constrains the angle difference when it carries angle-limit data (only +# Line / MonitoredLine do) narrower than the PSY default ±π window. +_constrains_angle_difference(::PSY.ACTransmission) = false +_constrains_angle_difference(d::PSY.Line) = + _is_binding_angle_window(PSY.get_angle_limits(d)) +_constrains_angle_difference(d::PSY.MonitoredLine) = + _is_binding_angle_window(PSY.get_angle_limits(d)) +_is_binding_angle_window(lims) = !(lims.min ≈ -π && lims.max ≈ π) + _constrains_angle_difference(rep::RepresentativeBranch) = !_is_aggregate(rep) && _constrains_angle_difference(rep.branch) From 7d45fb48ea713f2c8df870cd77efe177e313ec10 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 18 Aug 2026 21:40:03 -0400 Subject: [PATCH 16/19] tests pass --- src/ac_transmission_models/AC_branches.jl | 2 +- test/test_transformer_controls.jl | 30 ++++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index c3ebdd5..6e34dff 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -1383,7 +1383,7 @@ function _voltage_products( va = get_variable(container, VoltageAngle, PSY.ACBus) phi = get_variable(container, VoltageDeviation, PSY.ACBus) cs = get_variable(container, CosineApproximation, D) - phi_fr, phi_to = phi[from_bus, t], phi[to_bus, t] + phi_fr, phi_to = phi[from_bus, :], phi[to_bus, :] T = length(get_time_steps(container)) return ( v2_fr = JuMP.@expression(jump_model, [t=1:T], 1.0 + 2.0 * phi_fr[t]), diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index a82b7ed..6647ac5 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -113,7 +113,7 @@ end @testset "VOLTAGE control holds the regulated bus inside its limits" begin rawsys = PSB.build_system(PSITestSystems, "c_sys14") - buses = PSY.get_components(PSY.ACBus, rawsys) + buses = collect(PSY.get_components(PSY.ACBus, rawsys))[1:3] for network_formulation in VOLTAGE_NETWORKS, bus in buses bus_name = PSY.get_name(bus) free_vm = _uncontrolled_voltage(bus_name) @@ -142,7 +142,7 @@ end # TODO: Is this excessive to be looping all networks and transformers? (I also do this later) for network_formulation in AC_NETWORKS, name in TRANFORMER_NAMES sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = limits, name = name) - model, status = _build_controlled(sys, network; optimizer = ipopt_optimizer) + model, status = _build_controlled(sys, network_formulation; optimizer = ipopt_optimizer) @test status == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -154,8 +154,8 @@ end ) flow = read_variable(res, key; table_format = TableFormat.WIDE) for r in 1:nrow(flow) - @test flow[r, name] >= limits.min - 1e-6 - @test flow[r, name] <= limits.max + 1e-6 + @test flow[r, name] / base >= limits.min - 1e-6 + @test flow[r, name] / base <= limits.max + 1e-6 end end end @@ -167,9 +167,23 @@ end limits = (min = 0.94, max = 1.06) tap_range = (min = 0.5, max = 1.5) - branch_formulation(::Union{DCPNetworkModel, DCPLLNetworkModel}) = StaticBranchBounds + branch_formulation(::Type{<:Union{DCPNetworkModel, DCPLLNetworkModel}}) = StaticBranchBounds branch_formulation(_) = StaticBranch + flow_keys(::Type{DCPNetworkModel}) = ( + "FlowActivePowerVariable__TwoWindingTransformer", + ) + flow_keys(::Type{DCPLLNetworkModel}) = ( + "FlowActivePowerFromToVariable__TwoWindingTransformer", + "FlowActivePowerToFromVariable__TwoWindingTransformer", + ) + flow_keys(_) = ( + "FlowActivePowerFromToVariable__TwoWindingTransformer", + "FlowActivePowerToFromVariable__TwoWindingTransformer", + "FlowReactivePowerFromToVariable__TwoWindingTransformer", + "FlowReactivePowerToFromVariable__TwoWindingTransformer", + ) + for network_formulation in ALL_NETWORKS, name in TRANFORMER_NAMES sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = limits, name = name) model_fixed, status_fixed = _build_controlled( @@ -201,10 +215,8 @@ end IOM.get_objective_value(res_fixed); rtol = 1e-3, ) - for key in ( - "FlowActivePowerFromToVariable__TwoWindingTransformer", - "FlowReactivePowerFromToVariable__TwoWindingTransformer", - ) + + for key in flow_keys(network_formulation) flow_fixed = read_variable(res_fixed, key; table_format = TableFormat.WIDE) flow_var = read_variable(res_var, key; table_format = TableFormat.WIDE) @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) From ed02aec358b56188728122ed221e5b0e90b14579 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 18 Aug 2026 22:43:38 -0400 Subject: [PATCH 17/19] some bug fixes; claude 3w tests --- src/ac_transmission_models/AC_branches.jl | 41 +- .../RepresentativeBranch.jl | 4 + src/network_models/reduction_exceptions.jl | 8 +- test/test_transformer_controls.jl | 536 +++++++++++++++--- 4 files changed, 483 insertions(+), 106 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 6e34dff..40128c0 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -187,7 +187,7 @@ function _branch_variable_bounds(_, rep, device_model) return (-rating, rating) end -_branch_variable_bounds(::Type{TapRatioVariable}) = (min = 0.0, max = 1.0) +_branch_variable_bounds(::Type{TapRatioVariable}, rep, _) = _control_limits(rep) _branch_variable_start(::Type{CosineApproximation}) = 1.0 _branch_variable_start(::Type{TapRatioVariable}) = 1.0 @@ -246,7 +246,7 @@ function add_variables!( for t in time_steps var = JuMP.@variable( jump_model, - base_name = "$(nameof(V))_$(nameof(T))_$(rep.reduction)_{$(rep.name), $(t)}", + base_name = "$(nameof(V))_$(nameof(T))_$(branch.reduction)_{$(branch.name), $(t)}", ) lb !== nothing && JuMP.set_lower_bound(var, lb) ub !== nothing && JuMP.set_upper_bound(var, ub) @@ -267,6 +267,23 @@ _warn_tap_control_nonconvexity( @warn "Tap control makes $N network models non-convex. Use Ipopt or change circuit controls." _warn_tap_control_nonconvexity(_) = nothing +function _validate_controlled_branch_not_reduced( + network_model::NetworkModel, + ::Type{T}, +) where {T <: _TRANSFORMERS} + isempty(get_network_reduction(network_model)) && return + _for_each_branch(_all_branches(network_model, T)) do rep + _control_enabled(_get_circuit(rep.branch)) || return + rep.reduction == DIRECT_BRANCH_MAP && return + error( + "Controlled transformer circuit $(rep.name) was merged into a reduced arc \ + ($(rep.reduction)). Either remove the parallel branch or disable control \ + for this circuit.", + ) + end + return +end + function _add_tap_control_variables!( container::OptimizationContainer, model::DeviceModel{U, F}, @@ -277,6 +294,7 @@ function _add_tap_control_variables!( F <: AbstractBranchFormulation, } _control_enabled(model) || return + _validate_controlled_branch_not_reduced(network_model, U) _warn_tap_control_nonconvexity(network_model) add_variables!(container, TapRatioVariable, devices, model, network_model) return @@ -1018,25 +1036,6 @@ function _price_slack_upper!( return end -function _validate_controlled_branch_not_reduced( - network_model::NetworkModel, - ::Type{T}, - controlled_names, -) where {T <: PSY.ACTransmission} - network_reduction = get_network_reduction(network_model) - isempty(network_reduction) && return - arc_map = get_name_to_arc_map_entries(network_reduction, T) - for name in controlled_names - entry = get(arc_map, name, nothing) - if entry === nothing || entry[2] != DIRECT_BRANCH_MAP - error( - "Controlled transformer circuit $(name) was merged with a parallel branch. Either remove the parallel branch or disable control for this circuit.", - ) - end - end - return -end - ################################## ACP apparent-power rate constraints ###################### """ diff --git a/src/ac_transmission_models/RepresentativeBranch.jl b/src/ac_transmission_models/RepresentativeBranch.jl index fca3d5b..1c9a709 100644 --- a/src/ac_transmission_models/RepresentativeBranch.jl +++ b/src/ac_transmission_models/RepresentativeBranch.jl @@ -159,6 +159,10 @@ _voltage_controlled(rep::RepresentativeBranch) = _voltage_controlled(_control_ob _reactive_controlled(rep::RepresentativeBranch) = _reactive_controlled(_control_objective(rep)) +_control_limits(::Nothing) = (min = -Inf, max = Inf) +_control_limits(c::PSY.TransformerCircuit) = PSY.get_control_limits(c) +_control_limits(rep::RepresentativeBranch) = _control_limits(_get_circuit(rep.branch)) + _quantity_limits(::Nothing) = (min = -Inf, max = Inf) _quantity_limits(c::PSY.TransformerCircuit) = PSY.get_controlled_quantity_limits(c) _quantity_limits(rep::RepresentativeBranch) = _quantity_limits(_get_circuit(rep.branch)) diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index ea8d399..328cb12 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -8,7 +8,7 @@ protects every system Outage; this protects only what the template actually mode contingency the model never enforces cannot block a reduction. =# -function _push_component_buses!(buses::Set{Int}, branch::PSY.Branch) +function _push_component_buses!(buses::Set{Int}, branch::Union{PSY.Branch, PSY.TransformerCircuit}) arc = PSY.get_arc(branch) push!(buses, PSY.get_number(PSY.get_from(arc))) push!(buses, PSY.get_number(PSY.get_to(arc))) @@ -152,15 +152,15 @@ _pin_model_all_branches!(::Set{Int}, ::DeviceModel) = nothing # with controls enabled must not be reduced away, nor can its regulated bus. function _pin_transformer_controls!( buses::Set{Int}, - m::DeviceModel{_TRANSFORMERS}, + m::DeviceModel{<:_TRANSFORMERS}, ) _control_enabled(m) || return for transformer in get_device_cache(m) for circuit in PSY.get_circuits(transformer) - PSY.get_available(m) || continue + PSY.get_available(circuit) || continue PSY.get_control_objective(circuit) in (PSY.TransformerControlObjective.VOLTAGE,) || continue _push_component_buses!(buses, circuit) - push!(buses, get_regulated_bus(circuit)) + push!(buses, PSY.get_regulated_bus_number(circuit)) end end return diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 6647ac5..315fb17 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -7,27 +7,197 @@ const AC_NETWORKS = (VOLTAGE_NETWORKS..., IVRNetworkModel) const DC_NETWORKS = (DCPNetworkModel, DCPLLNetworkModel) const ALL_NETWORKS = (AC_NETWORKS..., DC_NETWORKS...) -const TRANFORMER_NAMES = ["Trans1", "Trans2", "Trans3", "Trans4"] +const TRANSFORMER_NAMES = ["Trans1", "Trans2", "Trans3", "Trans4"] + +const T3W_NAME = "ThreeWindingTransformer_busD" +const T3W_WINDINGS = ["$(T3W_NAME)_winding_$i" for i in 1:3] +const T3W_TERMINALS = (101, 102) +const T3W_STAR_NUMBER = 103 + +################################### two-winding fixture ################################ function _controlled_sys14( objective; - name = "Trans1", + circuit_index = 1, regulated = 9, quantity_limits = (min = 0.95, max = 1.05), control_limits = (min = 0.9, max = 1.1), ) sys = PSB.build_system(PSITestSystems, "c_sys14") + name = TRANSFORMER_NAMES[circuit_index] transformer = PSY.get_component(PSY.TwoWindingTransformer, sys, name) circuit = PSY.get_circuit(transformer) PSY.set_control_objective!(circuit, objective) PSY.set_regulated_bus_number!(circuit, regulated) PSY.set_controlled_quantity_limits!(circuit, quantity_limits) PSY.set_control_limits!(circuit, control_limits) - return sys, transformer, circuit, PSY.get_name(PSY.get_bus(sys, regulated)) + return ( + sys = sys, + device = transformer, + circuit = circuit, + regulated_name = PSY.get_name(PSY.get_bus(sys, regulated)), + axis_name = name, + ) end +_first_three_bus_numbers(sys) = + [PSY.get_number(b) for b in collect(PSY.get_components(PSY.ACBus, sys))[1:3]] + +################################## three-winding fixture ############################### + +""" +`c_sys5_ml` plus a three-winding transformer: two new terminal buses carrying a load and a +generator so every winding sees flow, a star bus, and `T3W_NAME` arcing each terminal into +the star. PSB ships no system with a `ThreeWindingTransformer`, so the device is built here; +the topology mirrors the fixture in `test_device_branch_constructors.jl`. + +The added buses get a wider voltage band than that fixture's `(0.95, 1.05)`: the VOLTAGE +objective errors unless the bus limits bracket the control band, and the control band the +tests use is derived from a free solve. +""" +function _sys5_with_3w() + sys = PSB.build_system(PSITestSystems, "c_sys5_ml") + busD = PSY.get_component(PSY.ACBus, sys, "nodeD") + + function _add_bus!(number, name) + bus = PSY.ACBus(; + number = number, + name = name, + available = true, + bustype = PSY.ACBusTypes.PQ, + angle = 0.0, + magnitude = 1.0, + voltage_limits = (min = 0.9, max = 1.1), + base_voltage = 230.0, + area = PSY.get_area(busD), + load_zone = PSY.get_load_zone(busD), + ) + PSY.add_component!(sys, bus) + return bus + end + + terminal_1 = _add_bus!(T3W_TERMINALS[1], "Bus3WT_1") + terminal_2 = _add_bus!(T3W_TERMINALS[2], "Bus3WT_2") + star_bus = _add_bus!(T3W_STAR_NUMBER, "Star_Bus_T3W") + + PSY.add_component!( + sys, + PSY.PowerLoad(; + name = "Load_Bus3WT", + available = true, + bus = terminal_1, + active_power = 0.5, + reactive_power = 0.1, + base_power = 100.0, + max_active_power = 0.5, + max_reactive_power = 0.1, + ), + ) + PSY.add_component!( + sys, + PSY.ThermalStandard(; + name = "Gen_Bus3WT", + available = true, + status = true, + bus = terminal_2, + active_power = 0.4, + reactive_power = 0.0, + rating = 0.5, + prime_mover_type = PSY.PrimeMovers.ST, + fuel = PSY.ThermalFuels.COAL, + active_power_limits = (min = 0.0, max = 0.5), + reactive_power_limits = (min = -0.3, max = 0.3), + ramp_limits = (up = 0.5, down = 0.5), + operation_cost = PSY.ThermalGenerationCost(; + variable = PSY.CostCurve(PSY.LinearCurve(0.0)), + start_up = 0.0, + shut_down = 0.0, + fixed = 0.0, + ), + base_power = 100.0, + time_limits = nothing, + ), + ) + + _star_leg(from) = PSY.TransformerCircuit(; + available = true, + arc = PSY.Arc(; from = from, to = star_bus), + r = 0.01, + x = 0.1, + rating = 1.0, + base_power = 100.0, + ) + PSY.add_component!( + sys, + PSY.ThreeWindingTransformer(; + name = T3W_NAME, + primary_circuit = _star_leg(busD), + secondary_circuit = _star_leg(terminal_1), + tertiary_circuit = _star_leg(terminal_2), + star_bus = star_bus, + ), + ) + return sys +end + +function _controlled_sys3w( + objective; + circuit_index = 1, + regulated = nothing, + quantity_limits = (min = 0.95, max = 1.05), + control_limits = (min = 0.9, max = 1.1), +) + sys = _sys5_with_3w() + transformer = PSY.get_component(PSY.ThreeWindingTransformer, sys, T3W_NAME) + circuit = PSY.get_circuits(transformer)[circuit_index] + # Each winding arcs terminal -> star, so the from-bus is this winding's own terminal. + number = isnothing(regulated) ? + PSY.get_number(PSY.get_from(PSY.get_arc(circuit))) : regulated + PSY.set_control_objective!(circuit, objective) + PSY.set_regulated_bus_number!(circuit, number) + PSY.set_controlled_quantity_limits!(circuit, quantity_limits) + PSY.set_control_limits!(circuit, control_limits) + return ( + sys = sys, + device = transformer, + circuit = circuit, + regulated_name = PSY.get_name(PSY.get_bus(sys, number)), + axis_name = T3W_WINDINGS[circuit_index], + ) +end + +_t3w_adjacent_bus_numbers(_) = [T3W_TERMINALS..., T3W_STAR_NUMBER] + +#################################### case descriptors ################################## + +# One entry per transformer arity. Every testset below runs the whole tuple, so the two +# arities stay in lockstep; `circuit_indices` selects which circuit of the device carries +# the control objective (the transformer for two-winding, the winding for three-winding). +const TWO_WINDING_CASE = ( + device_type = PSY.TwoWindingTransformer, + make = _controlled_sys14, + plain = () -> PSB.build_system(PSITestSystems, "c_sys14"), + circuit_indices = 1:length(TRANSFORMER_NAMES), + axis_names = TRANSFORMER_NAMES, + voltage_bus_numbers = _first_three_bus_numbers, +) + +const THREE_WINDING_CASE = ( + device_type = PSY.ThreeWindingTransformer, + make = _controlled_sys3w, + plain = _sys5_with_3w, + circuit_indices = 1:3, + axis_names = T3W_WINDINGS, + voltage_bus_numbers = _t3w_adjacent_bus_numbers, +) + +const TRANSFORMER_CASES = (TWO_WINDING_CASE, THREE_WINDING_CASE) + +######################################## helpers ####################################### + function _controlled_template( - network_formulation; + network_formulation, + device_type; enable = true, formulation = StaticBranch, kwargs..., @@ -37,7 +207,7 @@ function _controlled_template( set_device_model!( template, DeviceModel( - PSY.TwoWindingTransformer, + device_type, formulation; attributes = Dict( POM.ENABLE_CONTROLS_KEY => enable @@ -49,14 +219,16 @@ end function _build_controlled( sys, - network_formulation; + network_formulation, + device_type; enable = true, optimizer, formulation = StaticBranch, kwargs..., ) template = _controlled_template( - network_formulation; enable = enable, formulation = formulation, kwargs..., + network_formulation, device_type; + enable = enable, formulation = formulation, kwargs..., ) model = DecisionModel(template, sys; optimizer = optimizer) status = build!(model; output_dir = mktempdir(; cleanup = true)) @@ -66,70 +238,152 @@ end _has_tap_variable(container) = any(k -> occursin("TapRatioVariable", string(k)), keys(IOM.get_variables(container))) +_variable_key(variable, device_type) = "$(nameof(variable))__$(nameof(device_type))" + +# The regulated quantity each AC formulation actually constrains, converted back to a bus +# voltage magnitude so every formulation can be checked against the same per-unit band. +# Mirrors `_voltage_magnitude`/`_voltage_limits` in `AC_branches.jl`. +function _voltage_magnitudes(res, bus_name, ::Type{ACPNetworkModel}) + vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) + return vm[!, bus_name] +end + +function _voltage_magnitudes( + res, + bus_name, + ::Type{<:Union{ACRNetworkModel, IVRNetworkModel}}, +) + vr = read_variable(res, "VoltageReal__ACBus"; table_format = TableFormat.WIDE) + vi = read_variable(res, "VoltageImaginary__ACBus"; table_format = TableFormat.WIDE) + return sqrt.(vr[!, bus_name] .^ 2 .+ vi[!, bus_name] .^ 2) +end + +function _voltage_magnitudes(res, bus_name, ::Type{LPACCNetworkModel}) + phi = read_variable(res, "VoltageDeviation__ACBus"; table_format = TableFormat.WIDE) + return 1.0 .+ phi[!, bus_name] +end + +_reduction_source() = + NetworkReductionSpec([PNM.RadialReduction(), PNM.DegreeTwoReduction()]) + ################################### attribute plumbing ################################# @testset "a controlled circuit builds no tap variable while enable_controls is off" begin - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - model, status = - _build_controlled(sys, ACPNetworkModel; enable = false, optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test !_has_tap_variable(IOM.get_optimization_container(model)) + for case in TRANSFORMER_CASES + fixture = case.make(VOLTAGE_CONTROL) + model, status = _build_controlled( + fixture.sys, ACPNetworkModel, case.device_type; + enable = false, optimizer = ipopt_optimizer, + ) + @test status == IOM.ModelBuildStatus.BUILT + @test !_has_tap_variable(IOM.get_optimization_container(model)) + end end @testset "TapRatioVariable is created only for controlled circuits, bounded by control_limits" begin limits = (min = 0.95, max = 1.05) - for mode in TAP_CONTROLS - sys, _, _, _ = _controlled_sys14(mode; control_limits = limits) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) + for case in TRANSFORMER_CASES, mode in TAP_CONTROLS + # Deliberately not circuit 1: the untouched circuits are left UNDEFINED, so only + # the controlled one may appear on the axis. + controlled = last(case.circuit_indices) + fixture = case.make(mode; circuit_index = controlled, control_limits = limits) + model, status = _build_controlled( + fixture.sys, ACPNetworkModel, case.device_type; optimizer = ipopt_optimizer, + ) @test status == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(model) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) - # Trans2 / Trans3 are left UNDEFINED, so only the controlled circuit gets a variable. - @test axes(tap)[1] == ["Trans1"] - @test check_variable_bounded(model, TapRatioVariable, PSY.TwoWindingTransformer) + tap = IOM.get_variable(container, TapRatioVariable, case.device_type) + @test axes(tap)[1] == [fixture.axis_name] + + # `control_limits` is the tap band itself, so it must land on the variable as hard + # bounds rather than merely being present as data. + band = PSY.get_control_limits(fixture.circuit) + for t in get_time_steps(container) + var = tap[fixture.axis_name, t] + @test JuMP.has_lower_bound(var) + @test JuMP.has_upper_bound(var) + @test JuMP.lower_bound(var) ≈ band.min + @test JuMP.upper_bound(var) ≈ band.max + end end end +@testset "one three-winding device carries independent per-winding objectives" begin + sys = _sys5_with_3w() + transformer = PSY.get_component(PSY.ThreeWindingTransformer, sys, T3W_NAME) + circuits = PSY.get_circuits(transformer) + + PSY.set_control_objective!(circuits[1], VOLTAGE_CONTROL) + PSY.set_regulated_bus_number!(circuits[1], T3W_STAR_NUMBER) + PSY.set_controlled_quantity_limits!(circuits[1], (min = 0.95, max = 1.05)) + + PSY.set_control_objective!(circuits[3], Q_FLOW_CONTROL) + PSY.set_controlled_quantity_limits!(circuits[3], (min = -0.05, max = 0.05)) + + model, status = _build_controlled( + sys, ACPNetworkModel, PSY.ThreeWindingTransformer; optimizer = ipopt_optimizer, + ) + @test status == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model) + tap = IOM.get_variable(container, TapRatioVariable, PSY.ThreeWindingTransformer) + # Winding 2 is left UNDEFINED, so it gets no tap of its own. + @test sort(axes(tap)[1]) == [T3W_WINDINGS[1], T3W_WINDINGS[3]] + + _constrained_names(cons) = Set(k[1] for k in keys(cons.data)) + @test _constrained_names( + IOM.get_constraint( + container, VoltageMagnitudeConstraint, PSY.ThreeWindingTransformer, + ), + ) == Set([T3W_WINDINGS[1]]) + @test _constrained_names( + IOM.get_constraint( + container, ReactivePowerFlowControlConstraint, PSY.ThreeWindingTransformer, + ), + ) == Set([T3W_WINDINGS[3]]) + + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + ################################### VOLTAGE objective ################################## # Solve system with no controls to get bus voltage reference to make sure our # control constraint tests are doing something. -function _uncontrolled_voltage(bus_name; network_formulation = ACPNetworkModel) - sys = PSB.build_system(PSITestSystems, "c_sys14") - model, status = - _build_controlled( - sys, - network_formulation; - enable = false, - optimizer = ipopt_optimizer, - ) +function _uncontrolled_voltage(case, bus_name, network_formulation) + model, status = _build_controlled( + case.plain(), network_formulation, case.device_type; + enable = false, optimizer = ipopt_optimizer, + ) @test status == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = IOM.OptimizationProblemOutputs(model) - vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) - return vm[1, bus_name] + return first(_voltage_magnitudes(res, bus_name, network_formulation)) end @testset "VOLTAGE control holds the regulated bus inside its limits" begin - rawsys = PSB.build_system(PSITestSystems, "c_sys14") - buses = collect(PSY.get_components(PSY.ACBus, rawsys))[1:3] - for network_formulation in VOLTAGE_NETWORKS, bus in buses - bus_name = PSY.get_name(bus) - free_vm = _uncontrolled_voltage(bus_name) - limits = (min = free_vm - 0.02, max = free_vm - 0.01) - - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; regulated = PSY.get_number(bus), quantity_limits = limits) - model, status = _build_controlled(sys, ACPNetworkModel; optimizer = ipopt_optimizer) - @test status == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + for case in TRANSFORMER_CASES + rawsys = case.plain() + numbers = case.voltage_bus_numbers(rawsys) + for network_formulation in VOLTAGE_NETWORKS, number in numbers + bus_name = PSY.get_name(PSY.get_bus(rawsys, number)) + free_vm = _uncontrolled_voltage(case, bus_name, network_formulation) + limits = (min = free_vm - 0.02, max = free_vm - 0.01) + + fixture = + case.make(VOLTAGE_CONTROL; regulated = number, quantity_limits = limits) + model, status = _build_controlled( + fixture.sys, network_formulation, case.device_type; + optimizer = ipopt_optimizer, + ) + @test status == IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = IOM.OptimizationProblemOutputs(model) - vm = read_variable(res, "VoltageMagnitude__ACBus"; table_format = TableFormat.WIDE) - @test bus_name in names(vm) - for r in 1:nrow(vm) - @test vm[r, bus_name] >= limits.min - 1e-6 - @test vm[r, bus_name] <= limits.max + 1e-6 + res = IOM.OptimizationProblemOutputs(model) + for v in _voltage_magnitudes(res, bus_name, network_formulation) + @test v >= limits.min - 1e-6 + @test v <= limits.max + 1e-6 + end end end end @@ -139,23 +393,30 @@ end @testset "REACTIVE_POWER_FLOW control holds the terminal flow inside its limits" begin limits = (min = -0.05, max = 0.05) - # TODO: Is this excessive to be looping all networks and transformers? (I also do this later) - for network_formulation in AC_NETWORKS, name in TRANFORMER_NAMES - sys, _, _, _ = _controlled_sys14(Q_FLOW_CONTROL; quantity_limits = limits, name = name) - model, status = _build_controlled(sys, network_formulation; optimizer = ipopt_optimizer) + for case in TRANSFORMER_CASES, + network_formulation in AC_NETWORKS, + index in case.circuit_indices + + fixture = + case.make(Q_FLOW_CONTROL; circuit_index = index, quantity_limits = limits) + model, status = _build_controlled( + fixture.sys, network_formulation, case.device_type; + optimizer = ipopt_optimizer, + ) @test status == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = IOM.OptimizationProblemOutputs(model) base = IOM.get_model_base_power(res) - for key in ( - "FlowReactivePowerFromToVariable__TwoWindingTransformer", - "FlowReactivePowerToFromVariable__TwoWindingTransformer", - ) - flow = read_variable(res, key; table_format = TableFormat.WIDE) + for variable in + (FlowReactivePowerFromToVariable, FlowReactivePowerToFromVariable) + flow = read_variable( + res, _variable_key(variable, case.device_type); + table_format = TableFormat.WIDE, + ) for r in 1:nrow(flow) - @test flow[r, name] / base >= limits.min - 1e-6 - @test flow[r, name] / base <= limits.max + 1e-6 + @test flow[r, fixture.axis_name] / base >= limits.min - 1e-6 + @test flow[r, fixture.axis_name] / base <= limits.max + 1e-6 end end end @@ -167,43 +428,55 @@ end limits = (min = 0.94, max = 1.06) tap_range = (min = 0.5, max = 1.5) - branch_formulation(::Type{<:Union{DCPNetworkModel, DCPLLNetworkModel}}) = StaticBranchBounds + branch_formulation(::Type{<:Union{DCPNetworkModel, DCPLLNetworkModel}}) = + StaticBranchBounds branch_formulation(_) = StaticBranch - flow_keys(::Type{DCPNetworkModel}) = ( - "FlowActivePowerVariable__TwoWindingTransformer", - ) - flow_keys(::Type{DCPLLNetworkModel}) = ( - "FlowActivePowerFromToVariable__TwoWindingTransformer", - "FlowActivePowerToFromVariable__TwoWindingTransformer", + flow_variables(::Type{DCPNetworkModel}) = (FlowActivePowerVariable,) + flow_variables(::Type{DCPLLNetworkModel}) = ( + FlowActivePowerFromToVariable, + FlowActivePowerToFromVariable, ) - flow_keys(_) = ( - "FlowActivePowerFromToVariable__TwoWindingTransformer", - "FlowActivePowerToFromVariable__TwoWindingTransformer", - "FlowReactivePowerFromToVariable__TwoWindingTransformer", - "FlowReactivePowerToFromVariable__TwoWindingTransformer", + flow_variables(_) = ( + FlowActivePowerFromToVariable, + FlowActivePowerToFromVariable, + FlowReactivePowerFromToVariable, + FlowReactivePowerToFromVariable, ) - for network_formulation in ALL_NETWORKS, name in TRANFORMER_NAMES - sys_fixed, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; quantity_limits = limits, name = name) + for case in TRANSFORMER_CASES, + network_formulation in ALL_NETWORKS, + index in case.circuit_indices + + formulation = branch_formulation(network_formulation) + + fixed = case.make( + VOLTAGE_CONTROL; circuit_index = index, quantity_limits = limits, + ) model_fixed, status_fixed = _build_controlled( - sys_fixed, network_formulation; enable = false, - optimizer = ipopt_optimizer, formulation = branch_formulation(network_formulation) + fixed.sys, network_formulation, case.device_type; + enable = false, optimizer = ipopt_optimizer, formulation = formulation, ) @test status_fixed == IOM.ModelBuildStatus.BUILT @test solve!(model_fixed) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - sys_var, transformer, circuit, _ = _controlled_sys14( - VOLTAGE_CONTROL; quantity_limits = limits, control_limits = tap_range, name = name + varying = case.make( + VOLTAGE_CONTROL; + circuit_index = index, + quantity_limits = limits, + control_limits = tap_range, + ) + model_var, status_var = _build_controlled( + varying.sys, network_formulation, case.device_type; + optimizer = ipopt_optimizer, formulation = formulation, ) - model_var, status_var = _build_controlled(sys_var, network_formulation; optimizer = ipopt_optimizer, formulation = branch_formulation(network_formulation)) @test status_var == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(model_var) - tap = IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer) + tap = IOM.get_variable(container, TapRatioVariable, case.device_type) for t in get_time_steps(container) JuMP.fix( - tap[name, t], PSY.get_tap(circuit); force = true, + tap[varying.axis_name, t], PSY.get_tap(varying.circuit); force = true, ) end @test solve!(model_var) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -216,10 +489,15 @@ end rtol = 1e-3, ) - for key in flow_keys(network_formulation) + for variable in flow_variables(network_formulation) + key = _variable_key(variable, case.device_type) flow_fixed = read_variable(res_fixed, key; table_format = TableFormat.WIDE) flow_var = read_variable(res_var, key; table_format = TableFormat.WIDE) - @test isapprox(flow_var[1, name], flow_fixed[1, name]; atol = 1e-3) + @test isapprox( + flow_var[1, varying.axis_name], + flow_fixed[1, varying.axis_name]; + atol = 1e-3, + ) end end end @@ -260,6 +538,57 @@ end ) end end + + # A three-winding transformer reaches the builders as one `ThreeWindingTransformerCircuit` + # per star leg, each with its own tap and phase shift. + sys3w = _sys5_with_3w() + tr3w = PSY.get_component(PSY.ThreeWindingTransformer, sys3w, T3W_NAME) + for (index, star_leg) in enumerate(PSY.get_circuits(tr3w)) + winding = PNM.ThreeWindingTransformerCircuit(tr3w, index) + adm = PNM.branch_admittance(winding) + check_terms( + POM._tapped_admittance(model, adm, adm.tap), + PNM.ybus_branch_entries(winding), + ) + + for shift in (-pi / 5, 0.0, pi / 6) + PSY.set_α!(star_leg, shift) + PSY.set_tap!(star_leg, 1.0) + adm = PNM.branch_admittance(winding) + for tap in (0.9, 1.0, 1.1, 1.25) + PSY.set_tap!(star_leg, tap) + check_terms( + POM._tapped_admittance(model, adm, tap), + PNM.ybus_branch_entries(winding), + ) + end + end + PSY.set_α!(star_leg, 0.0) + PSY.set_tap!(star_leg, 1.0) + end +end + +@testset "a voltage-controlled circuit and its regulated bus survive a network reduction" begin + # Without the bus-pinning rule the controlled circuit is merged into a reduced arc and + # `_validate_controlled_branch_not_reduced` rejects the build. + for case in TRANSFORMER_CASES + index = last(case.circuit_indices) + fixture = case.make(VOLTAGE_CONTROL; circuit_index = index) + model, status = _build_controlled( + fixture.sys, ACPNetworkModel, case.device_type; + optimizer = ipopt_optimizer, network_source = _reduction_source(), + ) + @test status == IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(model) + tap = IOM.get_variable(container, TapRatioVariable, case.device_type) + @test fixture.axis_name in axes(tap)[1] + + # The regulated bus must be retained too, else the voltage constraint has nothing + # to bind against. + vm = IOM.get_variable(container, VoltageMagnitude, PSY.ACBus) + @test fixture.regulated_name in axes(vm)[1] + end end ################################ static tap ############################################ @@ -315,6 +644,51 @@ end @test tested_a_real_tap end +@testset "StaticBranch models three-winding off-nominal taps under DCP" begin + sys = _sys5_with_3w() + transformer = PSY.get_component(PSY.ThreeWindingTransformer, sys, T3W_NAME) + # The fixture is built at nominal, so an off-nominal tap has to be set here for the + # tap-divided susceptance to be doing any work. + PSY.set_tap!(PSY.get_secondary_circuit(transformer), 1.05) + PSY.set_tap!(PSY.get_tertiary_circuit(transformer), 0.95) + + template = get_thermal_dispatch_template_network(NetworkModel(DCPNetworkModel)) + set_device_model!(template, PSY.ThreeWindingTransformer, StaticBranch) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + res = IOM.OptimizationProblemOutputs(model) + base = IOM.get_model_base_power(res) + pflow = read_expression( + res, + "BThetaBranchFlow__ThreeWindingTransformer"; + table_format = TableFormat.WIDE, + ) + va = read_variable(res, "VoltageAngle__ACBus"; table_format = TableFormat.WIDE) + + for (index, star_leg) in enumerate(PSY.get_circuits(transformer)) + name = T3W_WINDINGS[index] + @test name in names(pflow) + + winding = PNM.ThreeWindingTransformerCircuit(transformer, index) + adm = PNM.branch_admittance(winding) + x = -adm.b / (adm.g^2 + adm.b^2) + @test 1 / (x * adm.tap) ≈ PNM.get_series_susceptance(winding, PSY.SU) + + arc = PSY.get_arc(star_leg) + fr = PSY.get_name(PSY.get_from(arc)) + to = PSY.get_name(PSY.get_to(arc)) + shift = PNM.get_series_phase_shift(winding) + for r in 1:nrow(pflow) + p_pu = pflow[r, name] / base + expected = (va[r, fr] - va[r, to] - shift) / (x * adm.tap) + @test isapprox(p_pu, expected; atol = 1e-5) + end + end +end + ######################################################################################### # Phase control (the ACTIVE_POWER_FLOW / ASYMMETRIC_ACTIVE_POWER_FLOW objectives, where the # phase shift α rather than the tap ratio is the decision variable) is NOT supported yet. From 4cfea67d5b50816552f1c3a49bd9ad4aa5617218 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 19 Aug 2026 00:24:54 -0400 Subject: [PATCH 18/19] tests pass --- src/ac_transmission_models/AC_branches.jl | 70 ++++++++++++------- .../RepresentativeBranch.jl | 13 +++- .../transformer_models.jl | 2 +- src/network_models/reduction_exceptions.jl | 6 ++ ...ransmission_security_constrained_models.jl | 11 --- test/test_device_branch_constructors.jl | 20 +++--- test/test_native_network_reductions.jl | 31 ++++---- test/test_transformer_controls.jl | 70 ++++++++++++------- 8 files changed, 132 insertions(+), 91 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index 40128c0..d794540 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -165,13 +165,21 @@ end #################################### Flow Variable Bounds ################################################## -_branch_variable_bounds(::Type{V}, rep, ::DeviceModel{F}) where {V <: VariableType, F <: AbstractBranchFormulation} = +_branch_variable_bounds( + ::Type{V}, + rep, + ::DeviceModel{D, F}, +) where {V, D <: PSY.ACTransmission, F <: AbstractBranchFormulation} = ( get_variable_lower_bound(V, rep.branch, F), get_variable_upper_bound(V, rep.branch, F), ) -function _branch_variable_bounds(::Type{CosineApproximation}, rep, _) +function _branch_variable_bounds( + ::Type{CosineApproximation}, + rep, + ::DeviceModel{<:PSY.ACTransmission, <:AbstractBranchFormulation}, +) lims = _angle_limits(rep) if lims.min >= 0 return (cos(lims.max), cos(lims.min)) @@ -182,13 +190,21 @@ function _branch_variable_bounds(::Type{CosineApproximation}, rep, _) end end -function _branch_variable_bounds(_, rep, device_model) +_branch_variable_bounds( + ::Type{TapRatioVariable}, + rep, + ::DeviceModel{<:PSY.ACTransmission, <:AbstractBranchFormulation}, +) = _control_limits(rep) + +function _branch_variable_bounds( + ::Type{<:AbstractBranchCurrentVariable}, + rep, + device_model::DeviceModel{<:PSY.ACTransmission, <:AbstractBranchFormulation}, +) rating = _current_rating(rep, device_model) return (-rating, rating) end -_branch_variable_bounds(::Type{TapRatioVariable}, rep, _) = _control_limits(rep) - _branch_variable_start(::Type{CosineApproximation}) = 1.0 _branch_variable_start(::Type{TapRatioVariable}) = 1.0 _branch_variable_start(_) = nothing @@ -231,7 +247,7 @@ function add_variables!( container, V, T, - [b.name for b in branches], + _branch_names(branches), time_steps, ) @@ -271,14 +287,14 @@ function _validate_controlled_branch_not_reduced( network_model::NetworkModel, ::Type{T}, ) where {T <: _TRANSFORMERS} - isempty(get_network_reduction(network_model)) && return _for_each_branch(_all_branches(network_model, T)) do rep - _control_enabled(_get_circuit(rep.branch)) || return rep.reduction == DIRECT_BRANCH_MAP && return + names = _controlled_circuit_names(rep) + isempty(names) && return error( - "Controlled transformer circuit $(rep.name) was merged into a reduced arc \ - ($(rep.reduction)). Either remove the parallel branch or disable control \ - for this circuit.", + "Controlled transformer circuit $(join(names, ", ")) was merged into the \ + reduced arc $(rep.name) ($(rep.reduction)). Either remove the parallel \ + branch or disable control for this circuit.", ) end return @@ -507,7 +523,7 @@ function add_constraints!( } time_steps = get_time_steps(container) reps = _representative_branches(network_model, T, cons_type) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) con_lb = add_constraints_container!( @@ -558,7 +574,7 @@ function add_constraints!( } time_steps = get_time_steps(container) reps = _representative_branches(network_model, T, cons_type) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) con_lb = add_constraints_container!( @@ -636,7 +652,7 @@ function add_flow_rate_constraint_with_parameters!( } time_steps = get_time_steps(container) reps = _representative_branches(network_model, T, cons_type) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) con_lb = add_constraints_container!( @@ -1068,7 +1084,7 @@ function _add_directional_flow_rate_limits!( quad_slacks = _quadratic_rate_slacks(container, device_model, T) reps = _representative_branches(network_model, T, ConsKey) cons = add_constraints_container!( - container, ConsKey, T, [rep.name for rep in reps], time_steps, + container, ConsKey, T, _branch_names(reps), time_steps, ) jump_model = get_jump_model(container) @@ -1433,7 +1449,7 @@ function add_constraints!( number_to_name = _retained_number_to_name(sys, network_model), ) cons_pft, cons_qft, cons_ptf, cons_qtf = - _add_flow_constraint_containers!(container, T, [r.name for r in reps]) + _add_flow_constraint_containers!(container, T, _branch_names(reps)) jump_model = get_jump_model(container) slacks = _flow_equality_slacks(container, device_model, T) @@ -1650,7 +1666,7 @@ function add_constraints!( constrained = filter(rep -> !iszero(_max_angle_difference(rep)), reps) cons = add_constraints_container!( - container, CosineRelaxationConstraint, T, [rep.name for rep in constrained], + container, CosineRelaxationConstraint, T, _branch_names(constrained), time_steps, ) @@ -1720,7 +1736,7 @@ function add_constraints!( network_model, T, NetworkFlowConstraint; number_to_name = _retained_number_to_name(sys, network_model), ) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) cons_pft = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps; meta = "p_ft", @@ -1968,7 +1984,7 @@ function add_constraints!( # rating from the arc's equivalent parameters. The TS parameter axes are already # reduction-entry names. reps = _representative_branches(network_model, T, FlowRateConstraint) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) con_lb = add_constraints_container!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "lb", ) @@ -2023,7 +2039,7 @@ function add_constraints!( network_model, T, NetworkFlowConstraint; number_to_name = _retained_number_to_name(sys, network_model), ) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) cons = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps, ) @@ -2108,7 +2124,7 @@ function add_expressions!( network_model, T, NetworkFlowConstraint; number_to_name = _retained_number_to_name(sys, network_model), ) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) bfe = add_expression_container!(container, BThetaBranchFlow, T, branch_names, time_steps) @@ -2167,7 +2183,7 @@ function add_constraints!( end reps = _representative_branches(network_model, T, FlowRateConstraint) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) con_lb = add_constraints_container!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "lb", ) @@ -2239,7 +2255,7 @@ function add_constraints!( ) isempty(constrained) && return - branch_names = [rep.name for rep in constrained] + branch_names = _branch_names(constrained) cons = add_constraints_container!( container, AngleDifferenceConstraint, T, branch_names, time_steps, ) @@ -2296,7 +2312,7 @@ function add_constraints!( ) isempty(constrained) && return - branch_names = [rep.name for rep in constrained] + branch_names = _branch_names(constrained) cons_ub = add_constraints_container!( container, AngleDifferenceConstraint, T, branch_names, time_steps; meta = "ub", ) @@ -2389,7 +2405,7 @@ function add_constraints!( jump_model = get_jump_model(container) reps = _representative_branches(network_model, T, FlowRateConstraint) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) con_ft_ub = add_constraints_container!( container, FlowRateConstraint, T, branch_names, time_steps; meta = "ft_ub", ) @@ -2444,7 +2460,7 @@ function add_constraints!( network_model, T, NetworkFlowConstraint; number_to_name = _retained_number_to_name(sys, network_model), ) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) cons = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps, ) @@ -2507,7 +2523,7 @@ function add_constraints!( network_model, T, NetworkLossConstraint; number_to_name = _retained_number_to_name(sys, network_model), ) - branch_names = [rep.name for rep in reps] + branch_names = _branch_names(reps) cons = add_constraints_container!( container, NetworkLossConstraint, T, branch_names, time_steps, ) diff --git a/src/ac_transmission_models/RepresentativeBranch.jl b/src/ac_transmission_models/RepresentativeBranch.jl index 1c9a709..b5a4a9b 100644 --- a/src/ac_transmission_models/RepresentativeBranch.jl +++ b/src/ac_transmission_models/RepresentativeBranch.jl @@ -35,6 +35,9 @@ function _for_each_branch(f::F, reps) where {F} return end +# Concrete-typed for container axes +_branch_names(reps) = String[rep.name for rep in reps] + function _make_representative_branch( nr::PNM.NetworkReductionData, all_branch_maps_by_type::PNM.BranchMapsByType, @@ -71,7 +74,7 @@ function _representative_branches( tracker = get_reduced_branch_tracker(network_model) arc_map = get_name_to_arc_map_entries(nr, T) all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(nr) - return [ + return RepresentativeBranch[ _make_representative_branch( nr, all_branch_maps_by_type, arc_map, number_to_name, T, name, ) @@ -90,7 +93,7 @@ function _all_branches( nr = get_network_reduction(network_model) arc_map = get_name_to_arc_map_entries(nr, T) all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(nr) - return [ + return RepresentativeBranch[ _make_representative_branch( nr, all_branch_maps_by_type, arc_map, number_to_name, T, name, ) @@ -159,6 +162,12 @@ _voltage_controlled(rep::RepresentativeBranch) = _voltage_controlled(_control_ob _reactive_controlled(rep::RepresentativeBranch) = _reactive_controlled(_control_objective(rep)) +_controlled_circuit_names(branch) = + _control_enabled(_get_circuit(branch)) ? [PNM.get_name(branch)] : String[] +_controlled_circuit_names(entry::PNM.AbstractReductionAggregate) = + reduce(vcat, (_controlled_circuit_names(member) for member in entry); init = String[]) +_controlled_circuit_names(rep::RepresentativeBranch) = _controlled_circuit_names(rep.branch) + _control_limits(::Nothing) = (min = -Inf, max = Inf) _control_limits(c::PSY.TransformerCircuit) = PSY.get_control_limits(c) _control_limits(rep::RepresentativeBranch) = _control_limits(_get_circuit(rep.branch)) diff --git a/src/ac_transmission_models/transformer_models.jl b/src/ac_transmission_models/transformer_models.jl index cd3e09f..52932b6 100644 --- a/src/ac_transmission_models/transformer_models.jl +++ b/src/ac_transmission_models/transformer_models.jl @@ -286,7 +286,7 @@ function add_constraints!( number_to_name = _retained_number_to_name(sys, network_model) geoms = _branch_geometries(number_to_name, network_model, devices, T, NetworkFlowConstraint) - branch_names = [g.name for g in geoms] + branch_names = _branch_names(geoms) cons = add_constraints_container!( container, NetworkFlowConstraint, T, branch_names, time_steps, ) diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index 328cb12..2a0eac5 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -107,6 +107,12 @@ function _pin_time_series_branch_buses!( return end +_pin_time_series_branch_buses!( + ::Set{Int}, + ::DeviceModel, + ::PSY.System, +) = nothing + # An outage registered on an outage-aware branch model pins both its # monitored and its outaged endpoints. The MODF column for a contingency is keyed by # the outaged arc's endpoints, and post-contingency flow constraints reference the diff --git a/test/test_ac_transmission_security_constrained_models.jl b/test/test_ac_transmission_security_constrained_models.jl index 97f5d58..25f5b7d 100644 --- a/test/test_ac_transmission_security_constrained_models.jl +++ b/test/test_ac_transmission_security_constrained_models.jl @@ -1564,17 +1564,6 @@ end @test lim.min ≈ -rb end -@testset "emergency limits: TwoWindingTransformer rating_b resolves through its circuit" begin - c_sys14 = PSB.build_system(PSITestSystems, "c_sys14") - transformer = first(PSY.get_components(PSY.TwoWindingTransformer, c_sys14)) - circuit = PSY.get_circuit(transformer) - - @test POM._branch_rating_b(transformer) === nothing - PSY.set_rating_b!(circuit, 0.9 * PSY.SU) - @test POM._branch_rating_b(transformer) ≈ PSY.get_rating_b(circuit, PSY.SU) - @test POM._branch_rating_b(transformer) ≈ 0.9 -end - # Attach a single outage (monitoring every branch) to every branch of the system, # so every branch is both an outaged and a monitored component. function _attach_all_branch_outages!(sys) diff --git a/test/test_device_branch_constructors.jl b/test/test_device_branch_constructors.jl index 029f4d9..65486b7 100644 --- a/test/test_device_branch_constructors.jl +++ b/test/test_device_branch_constructors.jl @@ -116,10 +116,10 @@ end limits_max = min(limits_from.max, limits_to.max) tap_transformer = PSY.get_component(TwoWindingTransformer, system, "Trans3") - rate_limit = POM._branch_rating(tap_transformer) + rate_limit = PSY.get_rating(PSY.get_circuit(tap_transformer), PSY.SU) transformer = PSY.get_component(TwoWindingTransformer, system, "Trans4") - rate_limit2w = POM._branch_rating(transformer) + rate_limit2w = PSY.get_rating(PSY.get_circuit(transformer), PSY.SU) for model in DC_NETWORK_MODELS_FOR_TESTING template = get_template_dispatch_with_network( @@ -171,10 +171,10 @@ end limits_max = min(limits_from.max, limits_to.max) tap_transformer = PSY.get_component(TwoWindingTransformer, system, "Trans3") - rate_limit = POM._branch_rating(tap_transformer) + rate_limit = PSY.get_rating(PSY.get_circuit(tap_transformer), PSY.SU) transformer = PSY.get_component(TwoWindingTransformer, system, "Trans4") - rate_limit2w = POM._branch_rating(transformer) + rate_limit2w = PSY.get_rating(PSY.get_circuit(transformer), PSY.SU) for model in DC_NETWORK_MODELS_FOR_TESTING template = get_template_dispatch_with_network( @@ -482,10 +482,10 @@ end limits_max = min(limits_from.max, limits_to.max) tap_transformer = PSY.get_component(TwoWindingTransformer, system, "Trans3") - rate_limit = POM._branch_rating(tap_transformer) + rate_limit = PSY.get_rating(PSY.get_circuit(tap_transformer), PSY.SU) transformer = PSY.get_component(TwoWindingTransformer, system, "Trans4") - rate_limit2w = POM._branch_rating(transformer) + rate_limit2w = PSY.get_rating(PSY.get_circuit(transformer), PSY.SU) template = get_template_dispatch_with_network( NetworkModel(PTDFNetworkModel), @@ -600,10 +600,10 @@ end limits_max = min(limits_from.max, limits_to.max) tap_transformer = PSY.get_component(TwoWindingTransformer, system, "Trans3") - rate_limit = POM._branch_rating(tap_transformer) + rate_limit = PSY.get_rating(PSY.get_circuit(tap_transformer), PSY.SU) transformer = PSY.get_component(TwoWindingTransformer, system, "Trans4") - rate_limit2w = POM._branch_rating(transformer) + rate_limit2w = PSY.get_rating(PSY.get_circuit(transformer), PSY.SU) template = get_template_dispatch_with_network(ACPNetworkModel) set_device_model!(template, TwoWindingTransformer, StaticBranchBounds) @@ -1017,10 +1017,12 @@ end # PSY change introducing a per-branch base surfaces here instead of silently mis-bounding # branch flows against the system-base `FlowActivePowerVariable` bounds. @testset "PNM rating aggregators are system base (branch_rating invariant)" begin + _rating(t::PSY.TwoWindingTransformer) = PSY.get_rating(PSY.get_circuit(t), PSY.SU) + _rating(d) = PSY.get_rating(d, PSY.SU) for sysname in ("c_sys5", "c_sys14") system = PSB.build_system(PSITestSystems, sysname) for branch in PSY.get_components(PSY.ACTransmission, system) - @test PNM.get_equivalent_rating(branch) == POM._branch_rating(branch) + @test PNM.get_equivalent_rating(branch) == _rating(branch) end end end diff --git a/test/test_native_network_reductions.jl b/test/test_native_network_reductions.jl index 69fcea7..2f5c15d 100644 --- a/test/test_native_network_reductions.jl +++ b/test/test_native_network_reductions.jl @@ -414,18 +414,20 @@ end @testset "a controlled circuit survives the network reduction" begin # Controlled transformers pin their endpoint buses irreducible, so the circuit keeps # its own arc (and therefore its own tap variable) even with reductions requested. - sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL) - model, status = _build_controlled( - sys, - ACPNetworkModel; - optimizer = ipopt_optimizer, - reduce_radial_branches = true, - reduce_degree_two_branches = true, - ) - @test status == IOM.ModelBuildStatus.BUILT - container = IOM.get_optimization_container(model) - @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == - ["Trans1"] + for i in 1:4 + sys, _, _, _ = _controlled_sys14(VOLTAGE_CONTROL; circuit_index = i) + model, status = _build_controlled( + sys, + ACPNetworkModel, + PSY.TwoWindingTransformer; + optimizer = ipopt_optimizer, + network_source = NetworkReductionSpec([PNM.RadialReduction(), PNM.DegreeTwoReduction()]) + ) + @test status == IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == + ["Trans$i"] + end end @testset "a controlled circuit merged with a parallel branch fails with a clear error" begin @@ -449,7 +451,7 @@ end shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, ), ) - template = _controlled_template(ACPNetworkModel) + template = _controlled_template(ACPNetworkModel, PSY.TwoWindingTransformer) model = DecisionModel(template, sys; optimizer = ipopt_optimizer) out = mktempdir(; cleanup = true) @test build!(model; output_dir = out, console_level = Logging.Error) == @@ -606,7 +608,8 @@ end device_model = get_model(get_template(model), PSY.Line) for (name, (arc, reduction)) in line_entries entry = all_maps[reduction][PSY.Line][arc] - rating = POM.branch_rating(entry, device_model) + rating = POM._branch_rating(entry, device_model) + rating = _ for t in time_steps for var in (pft, ptf, qft, qtf) @test JuMP.has_upper_bound(var[name, t]) diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 315fb17..1d2cd75 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -51,13 +51,20 @@ generator so every winding sees flow, a star bus, and `T3W_NAME` arcing each ter the star. PSB ships no system with a `ThreeWindingTransformer`, so the device is built here; the topology mirrors the fixture in `test_device_branch_constructors.jl`. -The added buses get a wider voltage band than that fixture's `(0.95, 1.05)`: the VOLTAGE -objective errors unless the bus limits bracket the control band, and the control band the -tests use is derived from a free solve. +Every bus the transformer touches — the added ones and `nodeD` — gets a `(0.9, 1.1)` voltage +band: the VOLTAGE objective errors unless the bus limits bracket the control band, and the +control bands these tests use are derived from a free solve or from `c_sys14`'s wider +`(0.94, 1.06)`, both of which escape `c_sys5_ml`'s stock `(0.9, 1.05)`. + +Both terminal buses carry a generator with a reactive range. Without one at `Bus3WT_1` the +load's reactive draw could only be served across winding 2, pinning that winding's terminal +reactive flow to the load value whatever the tap does — no reactive control objective on it +would be satisfiable. """ function _sys5_with_3w() sys = PSB.build_system(PSITestSystems, "c_sys5_ml") busD = PSY.get_component(PSY.ACBus, sys, "nodeD") + PSY.set_voltage_limits!(busD, (min = 0.9, max = 1.1)) function _add_bus!(number, name) bus = PSY.ACBus(; @@ -93,31 +100,38 @@ function _sys5_with_3w() max_reactive_power = 0.1, ), ) - PSY.add_component!( - sys, - PSY.ThermalStandard(; - name = "Gen_Bus3WT", - available = true, - status = true, - bus = terminal_2, - active_power = 0.4, - reactive_power = 0.0, - rating = 0.5, - prime_mover_type = PSY.PrimeMovers.ST, - fuel = PSY.ThermalFuels.COAL, - active_power_limits = (min = 0.0, max = 0.5), - reactive_power_limits = (min = -0.3, max = 0.3), - ramp_limits = (up = 0.5, down = 0.5), - operation_cost = PSY.ThermalGenerationCost(; - variable = PSY.CostCurve(PSY.LinearCurve(0.0)), - start_up = 0.0, - shut_down = 0.0, - fixed = 0.0, + # `Bus3WT_1`'s generator stays small on active power so the load keeps drawing across + # winding 2. + function _add_gen!(bus, name, active_max) + PSY.add_component!( + sys, + PSY.ThermalStandard(; + name = name, + available = true, + status = true, + bus = bus, + active_power = 0.8 * active_max, + reactive_power = 0.0, + rating = 0.5, + prime_mover_type = PSY.PrimeMovers.ST, + fuel = PSY.ThermalFuels.COAL, + active_power_limits = (min = 0.0, max = active_max), + reactive_power_limits = (min = -0.3, max = 0.3), + ramp_limits = (up = 0.5, down = 0.5), + operation_cost = PSY.ThermalGenerationCost(; + variable = PSY.CostCurve(PSY.LinearCurve(0.0)), + start_up = 0.0, + shut_down = 0.0, + fixed = 0.0, + ), + base_power = 100.0, + time_limits = nothing, ), - base_power = 100.0, - time_limits = nothing, - ), - ) + ) + end + + _add_gen!(terminal_1, "Gen_Bus3WT_1", 0.1) + _add_gen!(terminal_2, "Gen_Bus3WT", 0.5) _star_leg(from) = PSY.TransformerCircuit(; available = true, @@ -396,6 +410,8 @@ end for case in TRANSFORMER_CASES, network_formulation in AC_NETWORKS, index in case.circuit_indices + println("%%%%%%%%") + @show case, network_formulation, index fixture = case.make(Q_FLOW_CONTROL; circuit_index = index, quantity_limits = limits) From 1200d29028225d7c999549ceb8ec66d4b6174e9f Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 19 Aug 2026 00:25:15 -0400 Subject: [PATCH 19/19] formatting --- src/ac_transmission_models/AC_branches.jl | 62 ++++++++++++++----- .../RepresentativeBranch.jl | 9 ++- .../branch_constructor.jl | 32 ++++++++-- src/network_models/reduction_exceptions.jl | 8 ++- test/test_native_network_reductions.jl | 9 ++- test/test_transformer_controls.jl | 12 ++-- 6 files changed, 100 insertions(+), 32 deletions(-) diff --git a/src/ac_transmission_models/AC_branches.jl b/src/ac_transmission_models/AC_branches.jl index d794540..58b02cc 100644 --- a/src/ac_transmission_models/AC_branches.jl +++ b/src/ac_transmission_models/AC_branches.jl @@ -1357,10 +1357,18 @@ function _voltage_products( vaf, vat = va[from_bus, :], va[to_bus, :] T = length(get_time_steps(container)) return ( - v2_fr = JuMP.@expression(jump_model, [t=1:T], vmf[t]^2), - v2_to = JuMP.@expression(jump_model, [t=1:T], vmt[t]^2), - vv_cos = JuMP.@expression(jump_model, [t=1:T], vmf[t] * vmt[t] * cos(vaf[t] - vat[t])), - vv_sin = JuMP.@expression(jump_model, [t=1:T], vmf[t] * vmt[t] * sin(vaf[t] - vat[t])), + v2_fr = JuMP.@expression(jump_model, [t = 1:T], vmf[t]^2), + v2_to = JuMP.@expression(jump_model, [t = 1:T], vmt[t]^2), + vv_cos = JuMP.@expression( + jump_model, + [t = 1:T], + vmf[t] * vmt[t] * cos(vaf[t] - vat[t]) + ), + vv_sin = JuMP.@expression( + jump_model, + [t = 1:T], + vmf[t] * vmt[t] * sin(vaf[t] - vat[t]) + ), ) end @@ -1379,10 +1387,18 @@ function _voltage_products( vi_fr, vi_to = vi[from_bus, :], vi[to_bus, :] T = length(get_time_steps(container)) return ( - v2_fr = JuMP.@expression(jump_model, [t=1:T], vr_fr[t]^2 + vi_fr[t]^2), - v2_to = JuMP.@expression(jump_model, [t=1:T], vr_to[t]^2 + vi_to[t]^2), - vv_cos = JuMP.@expression(jump_model, [t=1:T], vr_fr[t] * vr_to[t] + vi_fr[t] * vi_to[t]), - vv_sin = JuMP.@expression(jump_model, [t=1:T], vi_fr[t] * vr_to[t] - vr_fr[t] * vi_to[t]), + v2_fr = JuMP.@expression(jump_model, [t = 1:T], vr_fr[t]^2 + vi_fr[t]^2), + v2_to = JuMP.@expression(jump_model, [t = 1:T], vr_to[t]^2 + vi_to[t]^2), + vv_cos = JuMP.@expression( + jump_model, + [t = 1:T], + vr_fr[t] * vr_to[t] + vi_fr[t] * vi_to[t] + ), + vv_sin = JuMP.@expression( + jump_model, + [t = 1:T], + vi_fr[t] * vr_to[t] - vr_fr[t] * vi_to[t] + ), ) end @@ -1401,10 +1417,14 @@ function _voltage_products( phi_fr, phi_to = phi[from_bus, :], phi[to_bus, :] T = length(get_time_steps(container)) return ( - v2_fr = JuMP.@expression(jump_model, [t=1:T], 1.0 + 2.0 * phi_fr[t]), - v2_to = JuMP.@expression(jump_model, [t=1:T], 1.0 + 2.0 * phi_to[t]), - vv_cos = JuMP.@expression(jump_model, [t=1:T], cs[name, t] + phi_fr[t] + phi_to[t]), - vv_sin = JuMP.@expression(jump_model, [t=1:T], va[from_bus, t] - va[to_bus, t]), + v2_fr = JuMP.@expression(jump_model, [t = 1:T], 1.0 + 2.0 * phi_fr[t]), + v2_to = JuMP.@expression(jump_model, [t = 1:T], 1.0 + 2.0 * phi_to[t]), + vv_cos = JuMP.@expression( + jump_model, + [t = 1:T], + cs[name, t] + phi_fr[t] + phi_to[t] + ), + vv_sin = JuMP.@expression(jump_model, [t = 1:T], va[from_bus, t] - va[to_bus, t]), ) end @@ -1460,7 +1480,11 @@ function add_constraints!( to_bus = _to_name(rep) vp = _voltage_products(container, network_model, T, name, from_bus, to_bus) - tap_var = _tap_controlled(device_model, rep) ? get_variable(container, TapRatioVariable, T) : nothing + tap_var = if _tap_controlled(device_model, rep) + get_variable(container, TapRatioVariable, T) + else + nothing + end for t in time_steps tap = isnothing(tap_var) ? adm.tap : tap_var[name, t] y = _tapped_admittance(jump_model, adm, tap) @@ -1503,7 +1527,7 @@ _voltage_magnitude( ) = JuMP.@expression( get_jump_model(container), - [t=1:length(get_time_steps(container))], + [t = 1:length(get_time_steps(container))], get_variable(container, VoltageReal, PSY.ACBus)[name, t]^2 + get_variable(container, VoltageImaginary, PSY.ACBus)[name, t]^2 ) @@ -1537,7 +1561,9 @@ function _add_voltage_control_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - _for_each_branch(_representative_branches(network_model, T, VoltageMagnitudeConstraint)) do rep + _for_each_branch( + _representative_branches(network_model, T, VoltageMagnitudeConstraint), + ) do rep _voltage_controlled(device_model, rep) || return bus = PSY.get_bus(sys, _regulated_number(rep)) @@ -1546,7 +1572,7 @@ function _add_voltage_control_constraints!( ctl_limits = _quantity_limits(rep) # TODO: temporary pending PSY#1755 (bus_limits.min <= ctl_limits.min <= ctl_limits.max <= bus_limits.max) || error( - "Bus voltage limits for $bus_name disagree with control limits for circuit $(rep.name).", + "Bus voltage limits for $bus_name disagree with control limits for circuit $(rep.name).", ) lims = _voltage_limits(ctl_limits, network_model) @@ -1589,7 +1615,9 @@ function _add_reactive_control_constraints!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - _for_each_branch(_representative_branches(network_model, T, ReactivePowerFlowControlConstraint)) do rep + _for_each_branch( + _representative_branches(network_model, T, ReactivePowerFlowControlConstraint), + ) do rep name = rep.name _reactive_controlled(device_model, rep) || return lims = _quantity_limits(rep) diff --git a/src/ac_transmission_models/RepresentativeBranch.jl b/src/ac_transmission_models/RepresentativeBranch.jl index b5a4a9b..7fd02f7 100644 --- a/src/ac_transmission_models/RepresentativeBranch.jl +++ b/src/ac_transmission_models/RepresentativeBranch.jl @@ -158,7 +158,8 @@ _control_objective(c::PSY.TransformerCircuit) = _control_objective(rep::RepresentativeBranch) = _control_objective(_get_circuit(rep.branch)) _tap_controlled(rep::RepresentativeBranch) = _tap_controlled(_control_objective(rep)) -_voltage_controlled(rep::RepresentativeBranch) = _voltage_controlled(_control_objective(rep)) +_voltage_controlled(rep::RepresentativeBranch) = + _voltage_controlled(_control_objective(rep)) _reactive_controlled(rep::RepresentativeBranch) = _reactive_controlled(_control_objective(rep)) @@ -202,8 +203,10 @@ _parallel_branches_rating(::DeviceModel, mbp::PNM.MixedBranchesParallel) = PNM.get_sum_of_max_rating(mbp) _branch_rating(d::PSY.ACTransmission, ::DeviceModel) = PSY.get_rating(d, PSY.SU) -_branch_rating(t::PSY.TwoWindingTransformer, ::DeviceModel) = PSY.get_rating(PSY.get_circuit(t), PSY.SU) -_branch_rating(t::PNM.ThreeWindingTransformerCircuit, ::DeviceModel) = PSY.get_rating(t.circuit, PSY.SU) +_branch_rating(t::PSY.TwoWindingTransformer, ::DeviceModel) = + PSY.get_rating(PSY.get_circuit(t), PSY.SU) +_branch_rating(t::PNM.ThreeWindingTransformerCircuit, ::DeviceModel) = + PSY.get_rating(t.circuit, PSY.SU) _branch_rating(entry::PNM.BranchesSeries, ::DeviceModel) = PNM.get_equivalent_rating(entry) _branch_rating(entry::PNM.AbstractBranchesParallel, model::DeviceModel) = _parallel_branches_rating(model, entry) diff --git a/src/ac_transmission_models/branch_constructor.jl b/src/ac_transmission_models/branch_constructor.jl index 200fef4..a9bac8a 100644 --- a/src/ac_transmission_models/branch_constructor.jl +++ b/src/ac_transmission_models/branch_constructor.jl @@ -1000,8 +1000,20 @@ function construct_device!( @debug "construct_device DCPLL (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!(container, FlowActivePowerFromToVariable, devices, device_model, network_model) - add_variables!(container, FlowActivePowerToFromVariable, devices, device_model, network_model) + add_variables!( + container, + FlowActivePowerFromToVariable, + devices, + device_model, + network_model, + ) + add_variables!( + container, + FlowActivePowerToFromVariable, + devices, + device_model, + network_model, + ) # Slacks turn the rating into a soft limit, so the two enforcement styles are # mutually exclusive: hard variable bounds without slacks (tighter QCP), slacked # FlowRateConstraint pairs (ModelConstructStage) with them. @@ -1070,8 +1082,20 @@ function construct_device!( @debug "construct_device DCPLL StaticBranchBounds (ArgumentConstructStage)" _group = LOG_GROUP_BRANCH_CONSTRUCTIONS devices = get_available_components(device_model, sys) - add_variables!(container, FlowActivePowerFromToVariable, devices, device_model, network_model) - add_variables!(container, FlowActivePowerToFromVariable, devices, device_model, network_model) + add_variables!( + container, + FlowActivePowerFromToVariable, + devices, + device_model, + network_model, + ) + add_variables!( + container, + FlowActivePowerToFromVariable, + devices, + device_model, + network_model, + ) if get_use_slacks(device_model) _add_flow_slacks!(container, devices, device_model, network_model) else diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index 2a0eac5..3d29648 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -8,7 +8,10 @@ protects every system Outage; this protects only what the template actually mode contingency the model never enforces cannot block a reduction. =# -function _push_component_buses!(buses::Set{Int}, branch::Union{PSY.Branch, PSY.TransformerCircuit}) +function _push_component_buses!( + buses::Set{Int}, + branch::Union{PSY.Branch, PSY.TransformerCircuit}, +) arc = PSY.get_arc(branch) push!(buses, PSY.get_number(PSY.get_from(arc))) push!(buses, PSY.get_number(PSY.get_to(arc))) @@ -164,7 +167,8 @@ function _pin_transformer_controls!( for transformer in get_device_cache(m) for circuit in PSY.get_circuits(transformer) PSY.get_available(circuit) || continue - PSY.get_control_objective(circuit) in (PSY.TransformerControlObjective.VOLTAGE,) || continue + PSY.get_control_objective(circuit) in + (PSY.TransformerControlObjective.VOLTAGE,) || continue _push_component_buses!(buses, circuit) push!(buses, PSY.get_regulated_bus_number(circuit)) end diff --git a/test/test_native_network_reductions.jl b/test/test_native_network_reductions.jl index 2f5c15d..b4de9ca 100644 --- a/test/test_native_network_reductions.jl +++ b/test/test_native_network_reductions.jl @@ -421,11 +421,16 @@ end ACPNetworkModel, PSY.TwoWindingTransformer; optimizer = ipopt_optimizer, - network_source = NetworkReductionSpec([PNM.RadialReduction(), PNM.DegreeTwoReduction()]) + network_source = NetworkReductionSpec([ + PNM.RadialReduction(), + PNM.DegreeTwoReduction(), + ]), ) @test status == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(model) - @test axes(IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer))[1] == + @test axes( + IOM.get_variable(container, TapRatioVariable, PSY.TwoWindingTransformer), + )[1] == ["Trans$i"] end end diff --git a/test/test_transformer_controls.jl b/test/test_transformer_controls.jl index 1d2cd75..e7c264c 100644 --- a/test/test_transformer_controls.jl +++ b/test/test_transformer_controls.jl @@ -165,8 +165,11 @@ function _controlled_sys3w( transformer = PSY.get_component(PSY.ThreeWindingTransformer, sys, T3W_NAME) circuit = PSY.get_circuits(transformer)[circuit_index] # Each winding arcs terminal -> star, so the from-bus is this winding's own terminal. - number = isnothing(regulated) ? - PSY.get_number(PSY.get_from(PSY.get_arc(circuit))) : regulated + number = if isnothing(regulated) + PSY.get_number(PSY.get_from(PSY.get_arc(circuit))) + else + regulated + end PSY.set_control_objective!(circuit, objective) PSY.set_regulated_bus_number!(circuit, number) PSY.set_controlled_quantity_limits!(circuit, quantity_limits) @@ -224,8 +227,8 @@ function _controlled_template( device_type, formulation; attributes = Dict( - POM.ENABLE_CONTROLS_KEY => enable - ) + POM.ENABLE_CONTROLS_KEY => enable, + ), ), ) return template @@ -410,6 +413,7 @@ end for case in TRANSFORMER_CASES, network_formulation in AC_NETWORKS, index in case.circuit_indices + println("%%%%%%%%") @show case, network_formulation, index