From 4db411deb4da33d2e8d0a46f5943a2fe04c806f7 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 1 Jul 2026 08:26:54 -0600 Subject: [PATCH 1/6] Contain type instability in outage-driven typeof dispatches PSY's heterogeneous outage/generator supplemental-attribute maps force typeof(d)-based get_variable/get_variable_multiplier lookups in the G-1 security-constrained reserve code. Wrap each lookup's downstream per-timestep loop in a function barrier so the unstable dispatch is isolated to once per device/outage instead of leaking into the inner loop; hoist one redundant per-(entries,t) lookup to once per outage. Also route two constraint sites through IOM.add_range_bound_constraint!/ add_range_equality_constraint! where they fit the existing 2-arg (name, t) signature, matching established POM convention. Co-Authored-By: Claude Sonnet 5 --- src/services_models/reserves.jl | 62 ++-- ...c_injection_security_constrained_models.jl | 278 +++++++++++++----- 2 files changed, 241 insertions(+), 99 deletions(-) diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index 9f475909..ecaacb42 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -211,9 +211,9 @@ function add_constraints!( _sum_reserve_variables(@view(reserve_variable[:, t]), extra) use_slacks && JuMP.add_to_expression!(resource_expression, slack_vars[t]) - constraint[service_name, t] = JuMP.@constraint( - jump_model, - resource_expression >= ts_vector[t] * requirement + IOM.add_range_bound_constraint!( + IOM.LowerBound(), jump_model, constraint, service_name, t, + resource_expression, ts_vector[t] * requirement, ) end end @@ -266,9 +266,9 @@ function add_constraints!( var_r[name, t] <= (requirement * max_participation_factor) * param[t] ) else - cons[name, t] = JuMP.@constraint( - jump_model, - var_r[name, t] <= (requirement * max_participation_factor) * ts_vector[t] + IOM.add_range_bound_constraint!( + IOM.UpperBound(), jump_model, cons, name, t, var_r[name, t], + (requirement * max_participation_factor) * ts_vector[t], ) end end @@ -308,8 +308,10 @@ function add_constraints!( resource_expression = _sum_reserve_variables(@view(reserve_variable[:, t]), extra) use_slacks && JuMP.add_to_expression!(resource_expression, slack_vars[t]) - constraint[service_name, t] = - JuMP.@constraint(jump_model, resource_expression >= requirement) + IOM.add_range_bound_constraint!( + IOM.LowerBound(), jump_model, constraint, service_name, t, + resource_expression, requirement, + ) end return @@ -411,9 +413,9 @@ function add_constraints!( for d in ramp_devices, t in time_steps name = PSY.get_name(d) ramp_limits = PSY.get_ramp_limits(d, PSY.SU) - con_up[name, t] = JuMP.@constraint( - jump_model, - variable[name, t] <= ramp_limits.up * time_frame + IOM.add_range_bound_constraint!( + IOM.UpperBound(), jump_model, con_up, name, t, variable[name, t], + ramp_limits.up * time_frame, ) end else @@ -450,9 +452,9 @@ function add_constraints!( for d in ramp_devices, t in time_steps name = PSY.get_name(d) ramp_limits = PSY.get_ramp_limits(d, PSY.SU) - con_down[name, t] = JuMP.@constraint( - jump_model, - variable[name, t] <= ramp_limits.down * time_frame + IOM.add_range_bound_constraint!( + IOM.UpperBound(), jump_model, con_down, name, t, variable[name, t], + ramp_limits.down * time_frame, ) end else @@ -491,6 +493,11 @@ function add_constraints!( reserve_response_time = PSY.get_time_frame(service) jump_model = get_jump_model(container) for d in contributing_devices + # `contributing_devices` is flattened across every device type the + # service applies to, so `typeof(d)` is runtime-only and this + # `get_variable` dispatches dynamically. Hand the resulting `varstatus` + # to a function barrier so the `t` loop runs fully specialized instead + # of paying a dynamic dispatch on every iteration. component_type = typeof(d) name = PSY.get_name(d) varstatus = get_variable(container, OnVariable, component_type) @@ -503,12 +510,27 @@ function add_constraints!( else reserve_limit = 0.0 end - for t in time_steps - cons[name, t] = JuMP.@constraint( - jump_model, - var_r[name, t] <= (1 - varstatus[name, t]) * reserve_limit - ) - end + _add_reserve_power_constraint_over_time!( + cons, jump_model, var_r, varstatus, name, reserve_limit, time_steps, + ) + end + return +end + +function _add_reserve_power_constraint_over_time!( + cons, + jump_model, + var_r, + varstatus, + name::String, + reserve_limit, + time_steps, +) + for t in time_steps + cons[name, t] = JuMP.@constraint( + jump_model, + var_r[name, t] <= (1 - varstatus[name, t]) * reserve_limit + ) end return end diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 6d3dc01b..ddd04bb9 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -102,28 +102,37 @@ end # Helpers: monitored-arc resolution + sparse container scaffolding # ---------------------------------------------------------------------------- +""" +One resolved monitored component: `(monitored_type, container_name, arc, +reduction_kind)`. See [`_resolve_service_monitored_arcs`](@ref). +""" +const MonitoredArcEntry = Tuple{DataType, String, Tuple{Int, Int}, String} + +""" +Per-outage resolution of monitored components, sorted by outage UUID for +deterministic axes. See [`_resolve_service_monitored_arcs`](@ref). +""" +const ResolvedMonitoredArcs = Vector{Pair{Base.UUID, Vector{MonitoredArcEntry}}} + """ Resolve every monitored component in `service_model.outages` to a container name and arc tuple in the active network reduction. Mirrors the device-side `_resolve_monitored_arcs` but operates on a `ServiceModel`. Outages whose monitored component types are not modeled in the network are skipped. -Returns -`Vector{Pair{UUID, Vector{Tuple{DataType, String, Tuple{Int,Int}, String}}}}` -where each inner tuple is `(monitored_type, container_name, arc, -reduction_kind)`. Outages are sorted by UUID for deterministic axes. +Returns a [`ResolvedMonitoredArcs`](@ref); each [`MonitoredArcEntry`](@ref) is +`(monitored_type, container_name, arc, reduction_kind)`. """ function _resolve_service_monitored_arcs( service_model::ServiceModel, net_reduction_data::PNM.NetworkReductionData, -) +)::ResolvedMonitoredArcs name_to_arc_maps = PNM.get_name_to_arc_maps(net_reduction_data) component_to_reduction_maps = PNM.get_component_to_reduction_name_map(net_reduction_data) - resolved = - Pair{Base.UUID, Vector{Tuple{DataType, String, Tuple{Int, Int}, String}}}[] + resolved = ResolvedMonitoredArcs() for (uuid, per_type) in get_outages(service_model) - kept = Tuple{DataType, String, Tuple{Int, Int}, String}[] + kept = MonitoredArcEntry[] for (T, names) in per_type haskey(name_to_arc_maps, T) || continue name_to_arc = name_to_arc_maps[T] @@ -191,9 +200,7 @@ function add_post_contingency_slack_variables!( ::Type{T}, service::R, service_name::String, - resolved::Vector{ - Pair{Base.UUID, Vector{Tuple{DataType, String, Tuple{Int, Int}, String}}}, - }, + resolved::ResolvedMonitoredArcs, ::Type{<:AbstractSecurityConstrainedReservesFormulation}, ) where {T <: AbstractContingencySlackVariableType, R <: PSY.AbstractReserve} time_steps = get_time_steps(container) @@ -230,9 +237,7 @@ function _make_post_contingency_slacks( container::OptimizationContainer, service::R, service_name::String, - resolved::Vector{ - Pair{Base.UUID, Vector{Tuple{DataType, String, Tuple{Int, Int}, String}}}, - }, + resolved::ResolvedMonitoredArcs, ::Type{F}, use_slacks::Bool, ) where {R <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} @@ -471,15 +476,33 @@ function add_to_expression!( outage in associated_outages || continue outage_id = string(IS.get_uuid(outage)) name = PSY.get_name(d) + # `d`'s concrete type is only known at runtime (heterogeneous + # outage-generator map), so `get_variable` dispatches dynamically here. + # The lookup below is a function barrier: it isolates that one dynamic + # dispatch, letting the per-`t` loop inside run fully type-specialized. variable = get_variable(container, U, typeof(d)) mult = get_variable_multiplier(U, typeof(d), F) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, t], - variable[name, t], - mult, - ) - end + _add_pre_contingency_terms_over_time!( + expression, variable, mult, outage_id, name, time_steps, + ) + end + return +end + +function _add_pre_contingency_terms_over_time!( + expression, + variable, + mult, + outage_id::String, + name::String, + time_steps, +) + for t in time_steps + add_proportional_to_jump_expression!( + expression[outage_id, t], + variable[name, t], + mult, + ) end return end @@ -578,16 +601,33 @@ function add_to_expression!( outage in associated_outages || continue outage_id = string(IS.get_uuid(outage)) name = PSY.get_name(device) + # See the function-barrier note in the analogous per-outage power-balance + # method above: `typeof(device)` is a runtime-only type here. variable = get_variable(container, U, typeof(device)) mult = get_variable_multiplier(U, typeof(device), F) bus_number = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, bus_number, t], - variable[name, t], - mult, - ) - end + _add_pre_contingency_nodal_terms_over_time!( + expression, variable, mult, outage_id, bus_number, name, time_steps, + ) + end + return +end + +function _add_pre_contingency_nodal_terms_over_time!( + expression, + variable, + mult, + outage_id::String, + bus_number, + name::String, + time_steps, +) + for t in time_steps + add_proportional_to_jump_expression!( + expression[outage_id, bus_number, t], + variable[name, t], + mult, + ) end return end @@ -680,16 +720,33 @@ function add_to_expression!( outage in associated_outages || continue outage_id = string(IS.get_uuid(outage)) name = PSY.get_name(device) + # See the function-barrier note in the analogous per-outage power-balance + # method above: `typeof(device)` is a runtime-only type here. variable = get_variable(container, U, typeof(device)) mult = get_variable_multiplier(U, typeof(device), F) area_name = PSY.get_name(PSY.get_area(PSY.get_bus(device))) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, area_name, t], - variable[name, t], - mult, - ) - end + _add_pre_contingency_area_terms_over_time!( + expression, variable, mult, outage_id, area_name, name, time_steps, + ) + end + return +end + +function _add_pre_contingency_area_terms_over_time!( + expression, + variable, + mult, + outage_id::String, + area_name::String, + name::String, + time_steps, +) + for t in time_steps + add_proportional_to_jump_expression!( + expression[outage_id, area_name, t], + variable[name, t], + mult, + ) end return end @@ -734,27 +791,56 @@ function add_to_expression!( service_name, ) for device in contributing_devices + # `contributing_devices` is flattened across every device type the + # service applies to (`IOM.get_contributing_devices`), so `device`'s + # concrete type is runtime-only here and `typeof(device)` dispatches + # dynamically. Hand the resulting `gen_var` to a function barrier so the + # `(outage, t)` loop below runs fully specialized instead of paying a + # dynamic dispatch on every iteration. gen_var = get_variable(container, ActivePowerVariable, typeof(device)) gen_name = PSY.get_name(device) - for outage in associated_outages - associated_devices = PSY.get_associated_components( - sys, outage; component_type = PSY.Generator, + _add_post_contingency_generation_terms!( + expression, + reserve_deployment_variable, + gen_var, + sys, + device, + associated_outages, + gen_name, + time_steps, + ) + end + return +end + +function _add_post_contingency_generation_terms!( + expression, + reserve_deployment_variable, + gen_var, + sys::PSY.System, + device, + associated_outages, + gen_name::String, + time_steps, +) + for outage in associated_outages + associated_devices = PSY.get_associated_components( + sys, outage; component_type = PSY.Generator, + ) + outage_id = string(IS.get_uuid(outage)) + gen_outaged = device in associated_devices + for t in time_steps + add_proportional_to_jump_expression!( + expression[outage_id, gen_name, t], + reserve_deployment_variable[outage_id, gen_name, t], + 1.0, + ) + gen_outaged && continue + add_proportional_to_jump_expression!( + expression[outage_id, gen_name, t], + gen_var[gen_name, t], + 1.0, ) - outage_id = string(IS.get_uuid(outage)) - gen_outaged = device in associated_devices - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, gen_name, t], - reserve_deployment_variable[outage_id, gen_name, t], - 1.0, - ) - gen_outaged && continue - add_proportional_to_jump_expression!( - expression[outage_id, gen_name, t], - gen_var[gen_name, t], - 1.0, - ) - end end end return @@ -879,8 +965,9 @@ function add_constraints!( jump_model = get_jump_model(container) for outage in associated_outages, t in time_steps outage_id = string(IS.get_uuid(outage)) - constraint[outage_id, t] = - JuMP.@constraint(jump_model, expressions[outage_id, t] == 0) + IOM.add_range_equality_constraint!( + jump_model, constraint, outage_id, t, expressions[outage_id, t], 0.0, + ) end return end @@ -1048,6 +1135,13 @@ function add_post_contingency_flow_expressions!( outaged_gens = PSY.get_associated_components( sys, outage; component_type = PSY.Generator, ) + # `outaged_gens` is heterogeneous (multiple concrete `PSY.Generator` + # subtypes may be outaged together), so `typeof(outaged_gen)` dispatches + # dynamically. That lookup does not depend on `t` or on the monitored + # `AreaInterchange`, so precompute it once per outage instead of once + # per `(entries, t)` pair — this removes the redundant dynamic + # dispatches rather than just isolating them. + outaged_gen_terms = _outaged_generator_terms(container, outaged_gens) for (name, area_interchange) in entries from_area = PSY.get_name(PSY.get_from_area(area_interchange)) to_area = PSY.get_name(PSY.get_to_area(area_interchange)) @@ -1075,24 +1169,13 @@ function add_post_contingency_flow_expressions!( # Subtract the outaged generation contribution on the # outaged side: +pre-contingency power if in from-area, # -pre-contingency power if in to-area. - for outaged_gen in outaged_gens - outaged_area = - PSY.get_name(PSY.get_area(PSY.get_bus(outaged_gen))) - coef = if outaged_area == from_area - -1.0 - elseif outaged_area == to_area - 1.0 - else - 0.0 - end - coef == 0.0 && continue - gen_var = - get_variable(container, ActivePowerVariable, typeof(outaged_gen) - ) - add_proportional_to_jump_expression!( - expr, gen_var[PSY.get_name(outaged_gen), t], coef, - ) - end + _add_outaged_generation_terms!( + expr, + outaged_gen_terms, + from_area, + to_area, + t, + ) expression_container[outage_id, name, t] = expr end end @@ -1100,6 +1183,44 @@ function add_post_contingency_flow_expressions!( return expression_container end +""" +Per-outage, once: resolve each outaged generator's area name and its +`ActivePowerVariable` container. Isolated in its own function so the dynamic +dispatch of `get_variable(container, ActivePowerVariable, typeof(g))` (`g`'s +concrete type is runtime-only) happens a bounded number of times — once per +outaged generator — rather than once per `(entries, t)` pair. +""" +function _outaged_generator_terms(container::OptimizationContainer, outaged_gens) + return [ + ( + gen_name = PSY.get_name(g), + area = PSY.get_name(PSY.get_area(PSY.get_bus(g))), + gen_var = get_variable(container, ActivePowerVariable, typeof(g)), + ) for g in outaged_gens + ] +end + +function _add_outaged_generation_terms!( + expr, + outaged_gen_terms, + from_area::String, + to_area::String, + t::Int, +) + for term in outaged_gen_terms + coef = if term.area == from_area + -1.0 + elseif term.area == to_area + 1.0 + else + 0.0 + end + coef == 0.0 && continue + add_proportional_to_jump_expression!(expr, term.gen_var[term.gen_name, t], coef) + end + return +end + """ Per-(outage, area_interchange, t) flow-rate inequalities under the AreaBalance network model. Limits come from @@ -1142,12 +1263,11 @@ function add_constraints!( # `(type, name, arc, reduction_kind)`; build an equivalent shape with # placeholder arc/reduction values so the slack containers are keyed by the # same `(outage_id, name, t)`. - slack_resolved = - Pair{Base.UUID, Vector{Tuple{DataType, String, Tuple{Int, Int}, String}}}[ - uuid => Tuple{DataType, String, Tuple{Int, Int}, String}[ - (PSY.AreaInterchange, name, (0, 0), "") for (name, _) in entries - ] for (uuid, entries) in resolved - ] + slack_resolved = Pair{Base.UUID, Vector{MonitoredArcEntry}}[ + uuid => MonitoredArcEntry[ + (PSY.AreaInterchange, name, (0, 0), "") for (name, _) in entries + ] for (uuid, entries) in resolved + ] slack_ub, slack_lb = _make_post_contingency_slacks( container, service, service_name, slack_resolved, F, get_use_slacks(service_model), From b2a672f2b499a4f8a1763daa1f5efa9c073510fd Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 1 Jul 2026 08:46:47 -0600 Subject: [PATCH 2/6] Align service/contributing_devices argument order with reserves.jl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two add_constraints! methods placed contributing_devices before service, reversed from the (service, contributing_devices) order every other service constraint uses (reserves.jl and the other outage add_constraints! methods). No dispatch impact — just matches the established convention. Co-Authored-By: Claude Sonnet 5 --- .../static_injection_security_constrained_models.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index ddd04bb9..0a70256e 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -1300,8 +1300,8 @@ function add_constraints!( ::Type{T}, ::Type{X}, ::Type{U}, - contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractPowerModel}, ) where { @@ -1358,8 +1358,8 @@ function add_constraints!( container::OptimizationContainer, sys::PSY.System, ::Type{T}, - contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractActivePowerModel}, ) where { @@ -1652,7 +1652,7 @@ function construct_service!( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, ActivePowerReserveVariable, PostContingencyActivePowerReserveDeploymentVariable, - contributing_devices, service, model, network_model, + service, contributing_devices, model, network_model, ) else add_to_expression!( @@ -1661,7 +1661,7 @@ function construct_service!( ) add_constraints!( container, sys, PostContingencyActivePowerGenerationLimitsConstraint, - contributing_devices, service, model, network_model, + service, contributing_devices, model, network_model, ) end return From b4519a0608446e59c78411b5f9900d3b52ac6dcb Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 1 Jul 2026 09:26:59 -0600 Subject: [PATCH 3/6] Consolidate post-contingency balance expressions on target-index dispatch The system-wide, PTDF-nodal, and AreaBalance-area post-contingency balance/deployment expressions differed only in which extra index component sits between outage_id and t; the surrounding accumulation logic was duplicated three times per (healthy-deployment, outaged-generator) pair. Mirrors the _system_expression_type/ _balance_expression_targets pattern already used in add_to_expression.jl: _post_contingency_target_axes/_post_contingency_target_index dispatch on the expression type T (not on network_model's runtime type, since PostContingencyActivePowerBalance is built unconditionally under every network model) to resolve the axis/index, and one generic method per pair replaces the six network-model-specific copies. Also threads attribute_device_map through to the AreaBalance pre-contingency call site instead of recomputing it via a second PSY.get_component_supplemental_attribute_pairs call, which the unified signature now requires consistently across all three network models. Co-Authored-By: Claude Sonnet 5 --- ...c_injection_security_constrained_models.jl | 361 +++++------------- 1 file changed, 98 insertions(+), 263 deletions(-) diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 0a70256e..dbf953e5 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -387,15 +387,88 @@ end # Post-contingency power-balance, nodal-deployment, area-deployment # expressions. Reserve-deployment contributions and generator-outage # contributions are added in separate dispatches to avoid `isa` checks. +# +# The three expression types below (system-wide balance, PTDF nodal +# deployment, AreaBalance area deployment) differ only in the extra index +# component between `outage_id` and `t` — resolved per *expression type* +# `T` by `_post_contingency_target_axes`/`_post_contingency_target_index` +# — so one generic method per dispatch pair covers all three instead of +# one copy each. +# +# Dispatch is keyed on `T`, not on `network_model`'s runtime type: +# `PostContingencyActivePowerBalance` is built unconditionally under every +# network model (the outer system-wide check, from +# `_construct_service_post_contingency_balance!`), so it must always be +# axis-less regardless of what `network_model` happens to be. Only +# `PostContingencyNodalActivePowerDeployment`/`PostContingencyAreaActivePowerDeployment` +# are network-model-specific, and their callers (`_add_post_contingency_network_terms!`) +# already guarantee the matching `network_model` type before invoking them. # ---------------------------------------------------------------------------- """ -Add the healthy generators' reserve **deployments** to the per-outage system -power-balance expression: `+Σ_g mult·Δrsv[c, g, t]`. Paired with the -pre-contingency method below (which subtracts the lost unit's `p[g_outaged, t]`), -the `PostContingencyGenerationBalanceConstraint` then forces the deployed reserve -to exactly replace the outaged generation. The outaged generator deploys nothing, -so it is skipped rather than added with a zero coefficient. +Extra container axis (besides the outage-UUID axis and `time_steps`) for a +post-contingency balance/deployment expression container, resolved per +expression type `T`: none for the system-wide balance +(`PostContingencyActivePowerBalance`), the PTDF bus axis for the nodal path +(`PostContingencyNodalActivePowerDeployment`), or the system's area names +for the area path (`PostContingencyAreaActivePowerDeployment`). Mirrors +[`_post_contingency_target_index`](@ref), which resolves the same axis for +a single device. +""" +_post_contingency_target_axes( + ::Type{PostContingencyActivePowerBalance}, + ::NetworkModel, + ::PSY.System, +) = + () +_post_contingency_target_axes( + ::Type{PostContingencyNodalActivePowerDeployment}, + network_model::NetworkModel, + ::PSY.System, +) = (PNM.get_bus_axis(get_PTDF_matrix(network_model)),) +_post_contingency_target_axes( + ::Type{PostContingencyAreaActivePowerDeployment}, + ::NetworkModel, + sys::PSY.System, +) = (PSY.get_name.(PSY.get_components(PSY.Area, sys)),) + +""" +Per-device extra index component (besides `outage_id`/`t`) into a +post-contingency balance/deployment expression, resolved per expression type +`T`: the mapped bus number for the nodal (PTDF) path, the device's area name +for the area path, or nothing for the system-wide case. +""" +_post_contingency_target_index( + ::Type{PostContingencyActivePowerBalance}, + ::NetworkModel, + ::PSY.Generator, +) = () +_post_contingency_target_index( + ::Type{PostContingencyNodalActivePowerDeployment}, + network_model::NetworkModel, + d::PSY.Generator, +) = (PNM.get_mapped_bus_number(get_network_reduction(network_model), PSY.get_bus(d)),) +_post_contingency_target_index( + ::Type{PostContingencyAreaActivePowerDeployment}, + ::NetworkModel, + d::PSY.Generator, +) = (PSY.get_name(PSY.get_area(PSY.get_bus(d))),) + +const _PostContingencyBalanceExpression = Union{ + PostContingencyActivePowerBalance, + PostContingencyNodalActivePowerDeployment, + PostContingencyAreaActivePowerDeployment, +} + +""" +Add the healthy contributing devices' reserve **deployments** to the outage's +post-contingency balance/deployment expression: `+Σ_g mult·Δrsv[c, g, t]`. +Paired with the pre-contingency method below (which subtracts the lost +unit's `p[g_outaged, t]`), the corresponding balance constraint then forces +the deployed reserve to exactly replace the outaged generation. The extra +index component (bus/area/none) is resolved per expression type by +[`_post_contingency_target_index`](@ref). The outaged generator deploys +nothing, so it is skipped rather than added with a zero coefficient. """ function add_to_expression!( container::OptimizationContainer, @@ -405,9 +478,9 @@ function add_to_expression!( contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, service_model::ServiceModel{R, F}, - ::NetworkModel{<:PM.AbstractPowerModel}, + network_model::NetworkModel, ) where { - T <: PostContingencyActivePowerBalance, + T <: _PostContingencyBalanceExpression, U <: AbstractContingencyVariableType, V <: PSY.Generator, R <: PSY.AbstractReserve, @@ -419,6 +492,7 @@ function add_to_expression!( expression = lazy_container_addition!(container, T, R, string.(IS.get_uuid.(associated_outages)), + _post_contingency_target_axes(T, network_model, sys)..., time_steps; meta = service_name, ) @@ -431,9 +505,10 @@ function add_to_expression!( for device in contributing_devices device in associated_devices && continue name = PSY.get_name(device) + idx = _post_contingency_target_index(T, network_model, device) for t in time_steps add_proportional_to_jump_expression!( - expression[outage_id, t], + expression[outage_id, idx..., t], reserve_deployment_variable[outage_id, name, t], mult_default, ) @@ -444,11 +519,13 @@ function add_to_expression!( end """ -Add the outaged generators' **pre-contingency** output to the per-outage system -power-balance expression: `−p[g_outaged, t]` (the `Generator` multiplier is -`-1.0`). This is the generation that the healthy units' deployments (above) must -make up. Iterates the system's `(generator, outage)` attribute pairs, keeping only -those whose outage this service responds to. +Add the outaged generators' **pre-contingency** output to the per-outage +post-contingency balance/deployment expression: `−p[g_outaged, t]` (the +`Generator` multiplier is `-1.0`). This is the generation that the healthy +units' deployments (above) must make up. Iterates the system's +`(generator, outage)` attribute pairs, keeping only those whose outage this +service responds to; extra index component resolved the same way as the +healthy-deployment method above. """ function add_to_expression!( container::OptimizationContainer, @@ -460,9 +537,9 @@ function add_to_expression!( }, service::R, service_model::ServiceModel{R, F}, - ::NetworkModel{<:PM.AbstractPowerModel}, + network_model::NetworkModel, ) where { - T <: PostContingencyActivePowerBalance, + T <: _PostContingencyBalanceExpression, U <: VariableType, V <: PSY.Generator, R <: PSY.AbstractReserve, @@ -482,8 +559,9 @@ function add_to_expression!( # dispatch, letting the per-`t` loop inside run fully type-specialized. variable = get_variable(container, U, typeof(d)) mult = get_variable_multiplier(U, typeof(d), F) + idx = _post_contingency_target_index(T, network_model, d) _add_pre_contingency_terms_over_time!( - expression, variable, mult, outage_id, name, time_steps, + expression, variable, mult, outage_id, idx, name, time_steps, ) end return @@ -494,256 +572,13 @@ function _add_pre_contingency_terms_over_time!( variable, mult, outage_id::String, + idx::Tuple, name::String, time_steps, ) for t in time_steps add_proportional_to_jump_expression!( - expression[outage_id, t], - variable[name, t], - mult, - ) - end - return -end - -""" -PTDF path: place the healthy generators' reserve **deployments** at their buses, -building the per-outage nodal net-injection change `Δinj[c, b, t]`. This nodal -deployment vector is what the monitored-branch flow expressions multiply by the -PTDF rows to check post-contingency line loading. The outaged generator deploys -nothing, so it is skipped. -""" -function add_to_expression!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{T}, - ::Type{U}, - contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, - service::R, - service_model::ServiceModel{R, F}, - network_model::NetworkModel{N}, -) where { - T <: PostContingencyNodalActivePowerDeployment, - U <: AbstractContingencyVariableType, - V <: PSY.Generator, - R <: PSY.AbstractReserve, - F <: AbstractSecurityConstrainedReservesFormulation, - N <: AbstractPTDFModel, -} - time_steps = get_time_steps(container) - service_name = PSY.get_name(service) - associated_outages = _service_outages(sys, service_model) - ptdf = get_PTDF_matrix(network_model) - bus_numbers = PNM.get_bus_axis(ptdf) - expression = lazy_container_addition!(container, T, - R, - string.(IS.get_uuid.(associated_outages)), - bus_numbers, - time_steps; - meta = service_name, - ) - reserve_deployment_variable = get_variable(container, U, R, service_name) - mult_default = get_variable_multiplier(U, R, F) - network_reduction = get_network_reduction(network_model) - for outage in associated_outages - associated_devices = - PSY.get_associated_components(sys, outage; component_type = PSY.Generator) - outage_id = string(IS.get_uuid(outage)) - for device in contributing_devices - device in associated_devices && continue - name = PSY.get_name(device) - bus_number = - PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, bus_number, t], - reserve_deployment_variable[outage_id, name, t], - mult_default, - ) - end - end - end - return -end - -""" -PTDF path: place the outaged generators' lost **pre-contingency** output -`−p[g_outaged, t]` at their buses, completing the per-outage nodal net-injection -change. Iterates the system's `(generator, outage)` attribute pairs, keeping only -the outages this service responds to. -""" -function add_to_expression!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{T}, - ::Type{U}, - attribute_device_map::Vector{ - NamedTuple{(:component, :supplemental_attribute), Tuple{V, PSY.Outage}}, - }, - service::R, - service_model::ServiceModel{R, F}, - network_model::NetworkModel{N}, -) where { - T <: PostContingencyNodalActivePowerDeployment, - U <: VariableType, - V <: PSY.Generator, - R <: PSY.AbstractReserve, - F <: AbstractSecurityConstrainedReservesFormulation, - N <: AbstractPTDFModel, -} - time_steps = get_time_steps(container) - service_name = PSY.get_name(service) - associated_outages = Set(_service_outages(sys, service_model)) - expression = get_expression(container, T, R, service_name) - network_reduction = get_network_reduction(network_model) - for (device, outage) in attribute_device_map - outage in associated_outages || continue - outage_id = string(IS.get_uuid(outage)) - name = PSY.get_name(device) - # See the function-barrier note in the analogous per-outage power-balance - # method above: `typeof(device)` is a runtime-only type here. - variable = get_variable(container, U, typeof(device)) - mult = get_variable_multiplier(U, typeof(device), F) - bus_number = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) - _add_pre_contingency_nodal_terms_over_time!( - expression, variable, mult, outage_id, bus_number, name, time_steps, - ) - end - return -end - -function _add_pre_contingency_nodal_terms_over_time!( - expression, - variable, - mult, - outage_id::String, - bus_number, - name::String, - time_steps, -) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, bus_number, t], - variable[name, t], - mult, - ) - end - return -end - -""" -AreaBalance path: aggregate the healthy generators' reserve **deployments** into -their areas, building the per-outage area net-injection change. Paired with the -pre-contingency method below, this feeds the `PostContingencyCopperPlateBalance` -constraint per area. The outaged generator deploys nothing, so it is skipped. -""" -function add_to_expression!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{T}, - ::Type{U}, - contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, - service::R, - service_model::ServiceModel{R, F}, - ::NetworkModel{<:AreaBalancePowerModel}, -) where { - T <: PostContingencyAreaActivePowerDeployment, - U <: AbstractContingencyVariableType, - V <: PSY.Generator, - R <: PSY.AbstractReserve, - F <: AbstractSecurityConstrainedReservesFormulation, -} - time_steps = get_time_steps(container) - service_name = PSY.get_name(service) - associated_outages = _service_outages(sys, service_model) - area_names = PSY.get_name.(PSY.get_components(PSY.Area, sys)) - expression = lazy_container_addition!(container, T, - R, - string.(IS.get_uuid.(associated_outages)), - area_names, - time_steps; - meta = service_name, - ) - reserve_deployment_variable = get_variable(container, U, R, service_name) - mult_default = get_variable_multiplier(U, R, F) - for outage in associated_outages - associated_devices = - PSY.get_associated_components(sys, outage; component_type = PSY.Generator) - outage_id = string(IS.get_uuid(outage)) - for device in contributing_devices - device in associated_devices && continue - name = PSY.get_name(device) - area_name = PSY.get_name(PSY.get_area(PSY.get_bus(device))) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, area_name, t], - reserve_deployment_variable[outage_id, name, t], - mult_default, - ) - end - end - end - return -end - -""" -AreaBalance path: aggregate the outaged generators' lost **pre-contingency** -output `−p[g_outaged, t]` into their areas, completing the per-outage area -net-injection change. Iterates the system's `(generator, outage)` attribute pairs, -keeping only the outages this service responds to. -""" -function add_to_expression!( - container::OptimizationContainer, - sys::PSY.System, - ::Type{T}, - ::Type{U}, - service::R, - service_model::ServiceModel{R, F}, - ::NetworkModel{<:AreaBalancePowerModel}, -) where { - T <: PostContingencyAreaActivePowerDeployment, - U <: VariableType, - R <: PSY.AbstractReserve, - F <: AbstractSecurityConstrainedReservesFormulation, -} - attribute_device_map = PSY.get_component_supplemental_attribute_pairs( - PSY.Generator, - PSY.Outage, - sys, - ) - time_steps = get_time_steps(container) - service_name = PSY.get_name(service) - associated_outages = Set(_service_outages(sys, service_model)) - expression = get_expression(container, T, R, service_name) - for (device, outage) in attribute_device_map - outage in associated_outages || continue - outage_id = string(IS.get_uuid(outage)) - name = PSY.get_name(device) - # See the function-barrier note in the analogous per-outage power-balance - # method above: `typeof(device)` is a runtime-only type here. - variable = get_variable(container, U, typeof(device)) - mult = get_variable_multiplier(U, typeof(device), F) - area_name = PSY.get_name(PSY.get_area(PSY.get_bus(device))) - _add_pre_contingency_area_terms_over_time!( - expression, variable, mult, outage_id, area_name, name, time_steps, - ) - end - return -end - -function _add_pre_contingency_area_terms_over_time!( - expression, - variable, - mult, - outage_id::String, - area_name::String, - name::String, - time_steps, -) - for t in time_steps - add_proportional_to_jump_expression!( - expression[outage_id, area_name, t], + expression[outage_id, idx..., t], variable[name, t], mult, ) @@ -1742,7 +1577,7 @@ function _add_post_contingency_network_terms!( ) add_to_expression!( container, sys, PostContingencyAreaActivePowerDeployment, ActivePowerVariable, - service, model, network_model, + attribute_device_map, service, model, network_model, ) add_constraints!( container, sys, PostContingencyCopperPlateBalanceConstraint, From ce6f90291670b8acef8cab95e6f75687f8078523 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 1 Jul 2026 14:56:45 -0600 Subject: [PATCH 4/6] fix formatting strangeness --- .../static_injection_security_constrained_models.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index dbf953e5..e3af3a0f 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -419,8 +419,7 @@ _post_contingency_target_axes( ::Type{PostContingencyActivePowerBalance}, ::NetworkModel, ::PSY.System, -) = - () +) = () _post_contingency_target_axes( ::Type{PostContingencyNodalActivePowerDeployment}, network_model::NetworkModel, From 29d8e5569bc2aebe02b610d88680074781dea7e9 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 1 Jul 2026 16:47:45 -0600 Subject: [PATCH 5/6] anthony review comments --- src/services_models/reserves.jl | 30 +++++++++---------- ...c_injection_security_constrained_models.jl | 22 ++++---------- 2 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index ecaacb42..e05d473c 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -211,9 +211,9 @@ function add_constraints!( _sum_reserve_variables(@view(reserve_variable[:, t]), extra) use_slacks && JuMP.add_to_expression!(resource_expression, slack_vars[t]) - IOM.add_range_bound_constraint!( - IOM.LowerBound(), jump_model, constraint, service_name, t, - resource_expression, ts_vector[t] * requirement, + constraint[service_name, t] = JuMP.@constraint( + jump_model, + resource_expression >= ts_vector[t] * requirement ) end end @@ -266,9 +266,9 @@ function add_constraints!( var_r[name, t] <= (requirement * max_participation_factor) * param[t] ) else - IOM.add_range_bound_constraint!( - IOM.UpperBound(), jump_model, cons, name, t, var_r[name, t], - (requirement * max_participation_factor) * ts_vector[t], + cons[name, t] = JuMP.@constraint( + jump_model, + var_r[name, t] <= (requirement * max_participation_factor) * ts_vector[t] ) end end @@ -308,10 +308,8 @@ function add_constraints!( resource_expression = _sum_reserve_variables(@view(reserve_variable[:, t]), extra) use_slacks && JuMP.add_to_expression!(resource_expression, slack_vars[t]) - IOM.add_range_bound_constraint!( - IOM.LowerBound(), jump_model, constraint, service_name, t, - resource_expression, requirement, - ) + constraint[service_name, t] = + JuMP.@constraint(jump_model, resource_expression >= requirement) end return @@ -413,9 +411,9 @@ function add_constraints!( for d in ramp_devices, t in time_steps name = PSY.get_name(d) ramp_limits = PSY.get_ramp_limits(d, PSY.SU) - IOM.add_range_bound_constraint!( - IOM.UpperBound(), jump_model, con_up, name, t, variable[name, t], - ramp_limits.up * time_frame, + con_up[name, t] = JuMP.@constraint( + jump_model, + variable[name, t] <= ramp_limits.up * time_frame ) end else @@ -452,9 +450,9 @@ function add_constraints!( for d in ramp_devices, t in time_steps name = PSY.get_name(d) ramp_limits = PSY.get_ramp_limits(d, PSY.SU) - IOM.add_range_bound_constraint!( - IOM.UpperBound(), jump_model, con_down, name, t, variable[name, t], - ramp_limits.down * time_frame, + con_down[name, t] = JuMP.@constraint( + jump_model, + variable[name, t] <= ramp_limits.down * time_frame ) end else diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index e3af3a0f..5026e459 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -390,10 +390,8 @@ end # # The three expression types below (system-wide balance, PTDF nodal # deployment, AreaBalance area deployment) differ only in the extra index -# component between `outage_id` and `t` — resolved per *expression type* -# `T` by `_post_contingency_target_axes`/`_post_contingency_target_index` -# — so one generic method per dispatch pair covers all three instead of -# one copy each. +# component between `outage_id` and `t`, resolved per *expression type* `T` +# by `_post_contingency_target_axes`/`_post_contingency_target_index`. # # Dispatch is keyed on `T`, not on `network_model`'s runtime type: # `PostContingencyActivePowerBalance` is built unconditionally under every @@ -625,12 +623,9 @@ function add_to_expression!( service_name, ) for device in contributing_devices - # `contributing_devices` is flattened across every device type the - # service applies to (`IOM.get_contributing_devices`), so `device`'s - # concrete type is runtime-only here and `typeof(device)` dispatches - # dynamically. Hand the resulting `gen_var` to a function barrier so the - # `(outage, t)` loop below runs fully specialized instead of paying a - # dynamic dispatch on every iteration. + # PERF: type unstable. `contributing_devices` is heterogenous. + # _add_post_contingency_generation_terms! is function barrier + # to minimize impact: one dynamic dispatch per device, not per iteration. gen_var = get_variable(container, ActivePowerVariable, typeof(device)) gen_name = PSY.get_name(device) _add_post_contingency_generation_terms!( @@ -969,12 +964,7 @@ function add_post_contingency_flow_expressions!( outaged_gens = PSY.get_associated_components( sys, outage; component_type = PSY.Generator, ) - # `outaged_gens` is heterogeneous (multiple concrete `PSY.Generator` - # subtypes may be outaged together), so `typeof(outaged_gen)` dispatches - # dynamically. That lookup does not depend on `t` or on the monitored - # `AreaInterchange`, so precompute it once per outage instead of once - # per `(entries, t)` pair — this removes the redundant dynamic - # dispatches rather than just isolating them. + # PERF: type unstable. `outaged_gens` and `outaged_gen_terms` are heterogeneous. outaged_gen_terms = _outaged_generator_terms(container, outaged_gens) for (name, area_interchange) in entries from_area = PSY.get_name(PSY.get_from_area(area_interchange)) From 32cff2f51a9ca7c03835561df083794a02425fd3 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 7 Jul 2026 13:32:10 -0600 Subject: [PATCH 6/6] trim AI comment --- src/services_models/reserves.jl | 7 ++----- .../static_injection_security_constrained_models.jl | 6 ++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index e05d473c..9f81278b 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -491,11 +491,8 @@ function add_constraints!( reserve_response_time = PSY.get_time_frame(service) jump_model = get_jump_model(container) for d in contributing_devices - # `contributing_devices` is flattened across every device type the - # service applies to, so `typeof(d)` is runtime-only and this - # `get_variable` dispatches dynamically. Hand the resulting `varstatus` - # to a function barrier so the `t` loop runs fully specialized instead - # of paying a dynamic dispatch on every iteration. + # PERF: type unstable. _add_reserve_power_constraint_over_time! function barrier + # limits the impact, but a systematic fix would be better. component_type = typeof(d) name = PSY.get_name(d) varstatus = get_variable(container, OnVariable, component_type) diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 5026e459..2c8fb092 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -550,10 +550,8 @@ function add_to_expression!( outage in associated_outages || continue outage_id = string(IS.get_uuid(outage)) name = PSY.get_name(d) - # `d`'s concrete type is only known at runtime (heterogeneous - # outage-generator map), so `get_variable` dispatches dynamically here. - # The lookup below is a function barrier: it isolates that one dynamic - # dispatch, letting the per-`t` loop inside run fully type-specialized. + # PERF: type unstable. `_add_pre_contingency_terms_over_time!` function barrier + # limits the impact, but a systematic fix would be better. variable = get_variable(container, U, typeof(d)) mult = get_variable_multiplier(U, typeof(d), F) idx = _post_contingency_target_index(T, network_model, d)