From b394fe64056e360cde54b7402a0c8da60982b74f Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Sun, 24 May 2026 19:20:52 -0600 Subject: [PATCH 01/17] Add outages field to ServiceModel for G-1 security-constrained reserves --- src/core/service_model.jl | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index 2d081432de..356fd95f8e 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -25,6 +25,15 @@ model at simulation time - `feedforward::Array{<:AbstractAffectFeedforward}` : use to pass parameters between models - `use_service_name::Bool` : use the name as the name for the service + - `outages::AbstractVector{<:PSY.Outage}` : G-1 contingencies to model when the + formulation is security-constrained. The constructor stores `IS.get_uuid(outage)` + of each entry as a key in the model's `outages::Dict{UUID, Dict{DataType, Set{String}}}` + field with empty inner maps; template validation fills the inner maps with the + per-type set of monitored component names (e.g., branches) that each outage + carries. An empty default triggers auto-discovery of every outage in the system + attached to a contributing device of the service whose formulation supports + outages. If `B` is not security-constrained, a non-empty value is dropped with a + warning. # Example @@ -39,6 +48,7 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} attributes::Dict{String, Any} contributing_devices_map::Dict{Type{<:PSY.Component}, Vector{<:PSY.Component}} subsystem::Union{Nothing, String} + outages::Dict{Base.UUID, Dict{DataType, Set{String}}} function ServiceModel( ::Type{D}, ::Type{B}, @@ -49,6 +59,7 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} time_series_names = get_default_time_series_names(D, B), attributes = Dict{String, Any}(), contributing_devices_map = Dict{Type{<:PSY.Component}, Vector{<:PSY.Component}}(), + outages::AbstractVector{<:PSY.Outage} = PSY.Outage[], ) where {D <: PSY.Service, B <: AbstractServiceFormulation} attributes_for_model = get_default_attributes(D, B) for (k, v) in attributes @@ -57,6 +68,7 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} _check_service_formulation(D) _check_service_formulation(B) + outages_field = _add_service_model_outages(D, B, outages) new{D, B}( feedforwards, service_name, @@ -66,10 +78,33 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} attributes_for_model, contributing_devices_map, nothing, + outages_field, ) end end +function _add_service_model_outages( + ::Type{D}, + ::Type{B}, + outages::AbstractVector{<:PSY.Outage}, +) where {D <: PSY.Service, B <: AbstractServiceFormulation} + field = Dict{Base.UUID, Dict{DataType, Set{String}}}() + isempty(outages) && return field + if !_formulation_supports_outages(B) + @warn "ServiceModel{$D, $B}: 'outages' kwarg ignored \u2014 formulation \ + does not support G-1 contingencies." + return field + end + for outage in outages + field[IS.get_uuid(outage)] = Dict{DataType, Set{String}}() + end + return field +end + +_formulation_supports_outages( + ::Type{<:AbstractSecurityConstrainedReservesFormulation}, +) = true + get_component_type( ::ServiceModel{D, B}, ) where {D <: PSY.Service, B <: AbstractServiceFormulation} = D @@ -89,6 +124,7 @@ get_contributing_devices_map(m::ServiceModel, key) = get_contributing_devices(m::ServiceModel) = [z for x in values(m.contributing_devices_map) for z in x] get_subsystem(m::ServiceModel) = m.subsystem +get_outages(m::ServiceModel) = m.outages set_subsystem!(m::ServiceModel, id::String) = m.subsystem = id @@ -100,6 +136,7 @@ function ServiceModel( duals = Vector{DataType}(), time_series_names = get_default_time_series_names(D, B), attributes = get_default_attributes(D, B), + outages::AbstractVector{<:PSY.Outage} = PSY.Outage[], ) where {D <: PSY.Service, B <: AbstractServiceFormulation} # If more attributes are used later, move free form string to const and organize # attributes @@ -119,6 +156,7 @@ function ServiceModel( duals, time_series_names, attributes = attributes_for_model, + outages, ) end From 40f818a65d6b2536a3f0c9b36616dd1a74da5fee Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Sun, 24 May 2026 19:22:21 -0600 Subject: [PATCH 02/17] Add SecurityConstrainedContingencyReserve and SecurityConstrainedRampReserve formulations --- src/PowerSimulations.jl | 2 ++ src/core/formulations.jl | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/PowerSimulations.jl b/src/PowerSimulations.jl index c6408a8603..4fe3eaccc3 100644 --- a/src/PowerSimulations.jl +++ b/src/PowerSimulations.jl @@ -41,6 +41,8 @@ export RangeReserve export RampReserve export StepwiseCostReserve export NonSpinningReserve +export SecurityConstrainedContingencyReserve +export SecurityConstrainedRampReserve export PIDSmoothACE export GroupReserve export ConstantMaxInterfaceFlow diff --git a/src/core/formulations.jl b/src/core/formulations.jl index c83b32d796..4a8e5ca9a4 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -287,6 +287,29 @@ abstract type AbstractReservesFormulation <: AbstractServiceFormulation end abstract type AbstractSecurityConstrainedReservesFormulation <: AbstractReservesFormulation end +""" +Security-constrained contingency reserve formulation: requires a +`RequirementTimeSeriesParameter` and deploys reserves under each G-1 outage +attached to a contributing generator. Post-contingency branch-flow constraints +are added only for the monitored components listed on each outage's +`monitored_components`. + +See also `SecurityConstrainedRampReserve`. +""" +struct SecurityConstrainedContingencyReserve <: + AbstractSecurityConstrainedReservesFormulation end + +""" +Security-constrained ramp reserve formulation: like `RampReserve` for the +pre-contingency requirement/ramp/participation constraints, plus the same +G-1 post-contingency deployment + monitored-branch flow constraints as +`SecurityConstrainedContingencyReserve`. + +See also `SecurityConstrainedContingencyReserve`. +""" +struct SecurityConstrainedRampReserve <: + AbstractSecurityConstrainedReservesFormulation end + abstract type AbstractAGCFormulation <: AbstractServiceFormulation end struct PIDSmoothACE <: AbstractAGCFormulation end From 8c8edc0b630bc8b54d415b4cbd2b5cd41783c351 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Sun, 24 May 2026 19:25:02 -0600 Subject: [PATCH 03/17] Add _build_service_model_outages! validator for SC reserve service models --- src/operation/template_validation.jl | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 6274a02b4e..603c6772e3 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -238,6 +238,7 @@ function validate_template_impl!(model::OperationModel) _check_security_constrained_network!(model.template.branches, network_model) validate_network_model(network_model, unmodeled_branch_types, model_has_branch_filters) _build_device_model_outages!(template, system) + _build_service_model_outages!(template, system) return end @@ -475,3 +476,145 @@ function _warn_unmatched_user_outages( end return end + +""" +Populate `service_model.outages` for every security-constrained (SC) reserve +`ServiceModel` in the template. Mirrors `_build_device_model_outages!`, but +keyed off outages whose *attached* component is an injector (typically a +generator) instead of a branch. Monitored components are still branches, and +the inner `Dict{DataType, Set{String}}` is grouped by their modeled branch +type — the post-contingency build resolves the branch type's arc map. + +Selection semantics match the device version: a non-empty `m.outages` is +treated as the user's explicit UUID allow-list; an empty `m.outages` triggers +auto-discovery honoring the `"include_planned_outages"` attribute (default +`false`). +""" +function _build_service_model_outages!( + template::ProblemTemplate, + sys::PSY.System, +) + sc_service_models = _sc_reserve_service_models(template) + isempty(sc_service_models) && return + + modeled_types = Set{DataType}(get_component_types(template)) + selection = _take_service_outage_selection!(sc_service_models) + uncovered_types = Dict{DataType, Set{Base.UUID}}() + + for outage in PSY.get_supplemental_attributes(PSY.Outage, sys) + outage_uuid = IS.get_uuid(outage) + if isempty(PSY.get_monitored_components(outage)) + @warn "Outage $(outage_uuid) ($(typeof(outage))) has empty \ + monitored_components; no post-contingency variables or \ + constraints will be created for this outage." _group = + LOG_GROUP_MODELS_VALIDATION + continue + end + + per_type, uncovered = _monitored_components_by_modeled_type( + outage, outage_uuid, sys, modeled_types, + ) + for comp_type in uncovered + push!(get!(uncovered_types, comp_type, Set{Base.UUID}()), outage_uuid) + end + isempty(per_type) && continue + + attached_types = _attached_component_types(outage, sys) + # SC reserve service models claim outages whose attached component is + # an injector. Skip outages attached only to branches — those belong + # to N-1 branch-outage SC device models. + any(t -> t <: PSY.StaticInjection, attached_types) || continue + + covered = _assign_outage_to_sc_service_models!( + sc_service_models, + selection, + outage, + outage_uuid, + per_type, + ) + if !covered + @warn "Outage $(outage_uuid) is attached to injector(s) of \ + type $(collect(attached_types)), but no ServiceModel with \ + an AbstractSecurityConstrainedReservesFormulation is \ + present in the template; it will not contribute any \ + post-contingency constraints." _group = + LOG_GROUP_MODELS_VALIDATION + end + end + + _warn_uncovered_monitored_types(uncovered_types) + _warn_unmatched_user_service_outages(sc_service_models, selection) + return +end + +function _sc_reserve_service_models(template::ProblemTemplate) + return ServiceModel[ + m for m in values(get_service_models(template)) if + get_formulation(m) <: AbstractSecurityConstrainedReservesFormulation + ] +end + +function _take_service_outage_selection!(sc_service_models::Vector{ServiceModel}) + selection = Dict{Tuple{DataType, String}, Set{Base.UUID}}() + for m in sc_service_models + key = (get_component_type(m), get_service_name(m)) + selection[key] = Set{Base.UUID}(keys(m.outages)) + empty!(m.outages) + end + return selection +end + +function _sc_service_claims_outage( + m::ServiceModel, + outage::PSY.Outage, + outage_uuid::Base.UUID, + sel::Set{Base.UUID}, +) + isempty(sel) || return outage_uuid in sel + if outage isa PSY.PlannedOutage + attr = get_attribute(m, "include_planned_outages") + return attr === true + end + return true +end + +function _assign_outage_to_sc_service_models!( + sc_service_models::Vector{ServiceModel}, + selection::Dict{Tuple{DataType, String}, Set{Base.UUID}}, + outage::PSY.Outage, + outage_uuid::Base.UUID, + per_type::Dict{DataType, Set{String}}, +) + covered = false + for m in sc_service_models + covered = true + key = (get_component_type(m), get_service_name(m)) + if _sc_service_claims_outage(m, outage, outage_uuid, selection[key]) + m.outages[outage_uuid] = per_type + end + end + return covered +end + +function _warn_unmatched_user_service_outages( + sc_service_models::Vector{ServiceModel}, + selection::Dict{Tuple{DataType, String}, Set{Base.UUID}}, +) + for m in sc_service_models + key = (get_component_type(m), get_service_name(m)) + sel = selection[key] + isempty(sel) && continue + for uuid in sel + haskey(m.outages, uuid) && continue + D = get_component_type(m) + @warn "Outage $(uuid) listed on ServiceModel{$D, \ + $(get_formulation(m))} (service_name=$(get_service_name(m))) \ + was not found among the system's outage supplemental \ + attributes — it will not contribute any post-contingency \ + constraints." _group = LOG_GROUP_MODELS_VALIDATION + end + end + return +end + + From 9abd829edd7c4e3fd43554b232240c04776bf56a Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Sun, 24 May 2026 21:37:44 -0600 Subject: [PATCH 04/17] Port static_injection_security_constrained_models.jl with sparse monitored-component pattern Implements SecurityConstrainedContingencyReserve and SecurityConstrainedRampReserve service formulations using the sparse + monitored-component pattern from ac_transmission_security_constrained_models.jl. Covers PTDF, CopperPlate, and AreaBalance network paths with optional post-contingency slacks. Adds supporting core types: AbstractContingencySlackVariableType with PostContingencyFlowActivePowerSlackUpperBound/LowerBound variables, PostContingencyAreaActivePowerDeployment expression, PostContingencyActivePowerGenerationLimitsConstraint and PostContingencyCopperPlateBalanceConstraint constraints, and POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST. PTDF only (no MODF); sparse containers keyed by (outage_id, monitored_name, t) scoped via service_model.outages. --- src/PowerSimulations.jl | 7 + src/core/constraints.jl | 28 + src/core/definitions.jl | 1 + src/core/expressions.jl | 1 + src/core/variables.jl | 25 + ...c_injection_security_constrained_models.jl | 1469 +++++++++++++++++ 6 files changed, 1531 insertions(+) create mode 100644 src/services_models/static_injection_security_constrained_models.jl diff --git a/src/PowerSimulations.jl b/src/PowerSimulations.jl index 4fe3eaccc3..cf874cae43 100644 --- a/src/PowerSimulations.jl +++ b/src/PowerSimulations.jl @@ -263,6 +263,8 @@ export RateofChangeConstraintSlackUp export RateofChangeConstraintSlackDown export PostContingencyActivePowerChangeVariable export PostContingencyActivePowerReserveDeploymentVariable +export PostContingencyFlowActivePowerSlackUpperBound +export PostContingencyFlowActivePowerSlackLowerBound export DCVoltage export DCLineCurrent export ConverterPowerDirection @@ -350,6 +352,8 @@ export PostContingencyActivePowerVariableLimitsConstraint export PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint export PostContingencyGenerationBalanceConstraint export PostContingencyRampConstraint +export PostContingencyActivePowerGenerationLimitsConstraint +export PostContingencyCopperPlateBalanceConstraint export ImportExportBudgetConstraint export PiecewiseLinearBlockIncrementalOfferConstraint export PiecewiseLinearBlockDecrementalOfferConstraint @@ -420,6 +424,8 @@ export ActivePowerRangeExpressionUB export PostContingencyBranchFlow export PostContingencyActivePowerGeneration export PostContingencyActivePowerBalance +export PostContingencyNodalActivePowerDeployment +export PostContingencyAreaActivePowerDeployment export NetActivePower export DCCurrentBalance @@ -694,6 +700,7 @@ include("devices_models/devices/reactivepower_device.jl") # Services Models include("services_models/reserves.jl") +include("services_models/static_injection_security_constrained_models.jl") include("services_models/reserve_group.jl") include("services_models/transmission_interface.jl") include("services_models/service_slacks.jl") diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 416353101d..cb71b966f4 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -282,6 +282,34 @@ each monitored arc m, the post-contingency flow constraint is: """ struct PostContingencyFlowRateConstraint <: PostContingencyConstraintType end +""" +Constraint on the post-contingency active power generation expression +(`PostContingencyActivePowerGeneration`) of each contributing generator +under each outage. Outaged generators are pinned to zero; all other +generators are bounded by their `active_power_limits`. + +```math +P^{\\text{min}}_g \\le p_{g,t} + \\Delta rsv_{c,g,t} \\le P^{\\text{max}}_g, +\\quad \\forall c \\in \\mathcal{C},\\ \\forall g \\notin \\mathcal{G}_c,\\ \\forall t +``` +""" +struct PostContingencyActivePowerGenerationLimitsConstraint <: + PostContingencyConstraintType end + +""" +Constraint that closes the per-area post-contingency power balance for the +`AreaBalancePowerModel` network representation, summing the +`PostContingencyAreaActivePowerDeployment` with the pre-contingency +`ActivePowerBalance` expression for each area. + +```math +\\sum_{g \\in \\mathcal{A}}(\\Delta rsv_{c,g,t} + p_{g,t}\\mathbb{1}_{g \\in \\mathcal{G}_c}) + +\\text{Bal}^{\\text{pre}}_{a,t} = 0,\\quad \\forall a \\in \\mathcal{A},\\ \\forall c,\\ \\forall t +``` +""" +struct PostContingencyCopperPlateBalanceConstraint <: + PostContingencyConstraintType end + """ Struct to create the constraint for branch flow rate limits from the 'from' bus to the 'to' bus. For more information check [Branch Formulations](@ref PowerSystems.Branch-Formulations). diff --git a/src/core/definitions.jl b/src/core/definitions.jl index 09b5671698..f55e83f450 100644 --- a/src/core/definitions.jl +++ b/src/core/definitions.jl @@ -62,6 +62,7 @@ const UNSET_INI_TIME = Dates.DateTime(0) const ABSOLUTE_TOLERANCE = 1.0e-3 const BALANCE_SLACK_COST = 1e6 const CONSTRAINT_VIOLATION_SLACK_COST = 2e5 +const POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST = 1e5 const SERVICES_SLACK_COST = 1e5 const COST_EPSILON = 1e-3 const PTDF_ZERO_TOL = 1e-9 diff --git a/src/core/expressions.jl b/src/core/expressions.jl index 917482527a..cdc932b5bf 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -30,6 +30,7 @@ struct PTDFBranchFlow <: ExpressionType end struct PostContingencyBranchFlow <: PostContingencyExpressions end struct PostContingencyActivePowerGeneration <: PostContingencyExpressions end struct PostContingencyNodalActivePowerDeployment <: PostContingencyExpressions end +struct PostContingencyAreaActivePowerDeployment <: PostContingencyExpressions end struct NetActivePower <: ExpressionType end struct RealizedShiftedLoad <: ExpressionType end """ diff --git a/src/core/variables.jl b/src/core/variables.jl index 6fef28427f..ec16e9e9a5 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -25,6 +25,31 @@ Docs abbreviation: ``\\Delta rsv_{r,g,c}`` struct PostContingencyActivePowerReserveDeploymentVariable <: AbstractContingencyVariableType end +""" +Abstract supertype for non-negative slack variables that absorb infeasibility +in post-contingency branch-flow inequalities of the security-constrained +reserve formulations. +""" +abstract type AbstractContingencySlackVariableType <: VariableType end + +""" +Struct to dispatch the creation of the non-negative slack variable applied to +the post-contingency upper branch-flow inequality. + +Docs abbreviation: ``s^{f,\\text{ub}}_{c,\\ell,t}`` +""" +struct PostContingencyFlowActivePowerSlackUpperBound <: + AbstractContingencySlackVariableType end + +""" +Struct to dispatch the creation of the non-negative slack variable applied to +the post-contingency lower branch-flow inequality. + +Docs abbreviation: ``s^{f,\\text{lb}}_{c,\\ell,t}`` +""" +struct PostContingencyFlowActivePowerSlackLowerBound <: + AbstractContingencySlackVariableType end + """ Struct to dispatch the creation of Active Power Variables above minimum power for Thermal Compact formulations diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl new file mode 100644 index 0000000000..8dad838f0e --- /dev/null +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -0,0 +1,1469 @@ +# ---------------------------------------------------------------------------- +# Security-constrained reserve service formulations (G-1 with reserve +# deployment + monitored-branch post-contingency flow constraints). +# +# Sparse + monitored counterpart to the legacy dense-per-branch implementation +# in `older_static_injection_security_constrained_models.jl`. Mirrors the +# device-side ac_transmission_security_constrained_models.jl: post-contingency +# flow expressions/constraints (and optional slacks) live in +# `SparseAxisArray`s keyed by `(outage_id::String, monitored_name::String, +# t::Int)`, scoped to the monitored components carried by each outage in +# `service_model.outages[uuid]::Dict{DataType, Set{String}}` (populated by +# `_build_service_model_outages!`). +# +# Per-outage reserve-deployment variables and the per-outage power-balance / +# nodal-deployment / area-deployment expressions remain dense over the +# contributing devices and the modeled bus/area axes — those are independent +# of which branches are monitored. +# ---------------------------------------------------------------------------- + +#! format: off +get_variable_upper_bound( + ::PostContingencyFlowActivePowerSlackUpperBound, + ::PSY.ACTransmission, + ::AbstractSecurityConstrainedReservesFormulation, +) = nothing +get_variable_lower_bound( + ::PostContingencyFlowActivePowerSlackUpperBound, + ::PSY.ACTransmission, + ::AbstractSecurityConstrainedReservesFormulation, +) = 0.0 +get_variable_upper_bound( + ::PostContingencyFlowActivePowerSlackLowerBound, + ::PSY.ACTransmission, + ::AbstractSecurityConstrainedReservesFormulation, +) = nothing +get_variable_lower_bound( + ::PostContingencyFlowActivePowerSlackLowerBound, + ::PSY.ACTransmission, + ::AbstractSecurityConstrainedReservesFormulation, +) = 0.0 +#! format: on + +# ---------------------------------------------------------------------------- +# Helpers: monitored-arc resolution + sparse container scaffolding +# ---------------------------------------------------------------------------- + +""" +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. +""" +function _resolve_service_monitored_arcs( + service_model::ServiceModel, + net_reduction_data::PNM.NetworkReductionData, +) + 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}}}[] + for (uuid, per_type) in get_outages(service_model) + kept = Tuple{DataType, String, Tuple{Int, Int}, String}[] + for (T, names) in per_type + haskey(name_to_arc_maps, T) || continue + name_to_arc = name_to_arc_maps[T] + component_to_reduction = + get(component_to_reduction_maps, T, Dict{String, String}()) + seen = Set{Tuple{Int, Int}}() + for name in sort!(collect(names)) + if haskey(name_to_arc, name) + container_name = name + elseif haskey(component_to_reduction, name) + container_name = component_to_reduction[name] + else + error( + "Monitored component \"$name\" (type $T) for outage $uuid is " * + "absent from both the network-reduction name-to-arc map and " * + "the component-to-reduction map. Verify the component exists " * + "in the system and is modeled with a branch formulation that " * + "produces a PTDFBranchFlow expression.", + ) + end + arc, reduction_kind = name_to_arc[container_name] + arc in seen && continue + push!(seen, arc) + push!(kept, (T, container_name, arc, reduction_kind)) + end + end + isempty(kept) && continue + push!(resolved, uuid => kept) + end + sort!(resolved; by = first) + return resolved +end + +""" +Pre-allocate a `SparseAxisArray` keyed by +`(outage_id::String, monitored_name::String, t::Int)` holding zero `AffExpr`s +for the resolved monitored arcs. Registered on `container.expressions` under +`ExpressionKey(T, R; meta = service_name)`. +""" +function _add_service_post_contingency_sparse_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{R}, + service_name::String, + resolved::Vector{ + Pair{Base.UUID, Vector{Tuple{DataType, String, Tuple{Int, Int}, String}}}, + }, + time_steps::UnitRange{Int}, +) where {T <: PostContingencyExpressions, R <: PSY.AbstractReserve} + contents = Dict{Tuple{String, String, Int}, JuMP.AffExpr}() + for (uuid, entries) in resolved + outage_id = string(uuid) + for (_, name, _, _) in entries, t in time_steps + contents[(outage_id, name, t)] = zero(JuMP.AffExpr) + end + end + expr_container = SparseAxisArray(contents) + _assign_container!( + container.expressions, + ExpressionKey(T, R, service_name), + expr_container, + ) + return expr_container +end + +""" +Register an empty `SparseAxisArray` keyed by +`(outage_id::String, monitored_name::String, t::Int)` for the given +post-contingency constraint type / meta tag. +""" +function _add_service_post_contingency_sparse_constraints!( + container::OptimizationContainer, + ::Type{T}, + ::Type{R}, + service_name::String; + meta_suffix::String, +) where {T <: ConstraintType, R <: PSY.AbstractReserve} + cons_container = + SparseAxisArray(Dict{Tuple{String, String, Int}, JuMP.ConstraintRef}()) + _assign_container!( + container.constraints, + ConstraintKey(T, R, "$(service_name)_$(meta_suffix)"), + cons_container, + ) + return cons_container +end + +""" +Sparse slack variable container keyed by +`(outage_id::String, monitored_name::String, t::Int)`. Each entry is a +non-negative `JuMP.VariableRef` whose objective contribution is +`POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST`. Built directly via +`@variable`/`_assign_container!` so the axes can be sparse. +""" +function add_post_contingency_slack_variables!( + container::OptimizationContainer, + ::Type{T}, + service::R, + service_name::String, + resolved::Vector{ + Pair{Base.UUID, Vector{Tuple{DataType, String, Tuple{Int, Int}, String}}}, + }, + ::AbstractSecurityConstrainedReservesFormulation, +) where {T <: AbstractContingencySlackVariableType, R <: PSY.AbstractReserve} + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + contents = Dict{Tuple{String, String, Int}, JuMP.VariableRef}() + for (uuid, entries) in resolved + outage_id = string(uuid) + for (_, name, _, _) in entries + for t in time_steps + v = JuMP.@variable( + jump_model, + base_name = "$(T)_$(R)_$(service_name)_{$(outage_id), $(name), $(t)}", + lower_bound = 0.0, + start = 0.0, + ) + contents[(outage_id, name, t)] = v + add_to_objective_invariant_expression!( + container, + v * POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST, + ) + end + end + end + slack_container = SparseAxisArray(contents) + _assign_container!( + container.variables, + VariableKey(T, R, service_name), + slack_container, + ) + return slack_container +end + +# ---------------------------------------------------------------------------- +# Reserve deployment variable per (outage, contributing device, t) +# ---------------------------------------------------------------------------- + +function add_variables!( + container::OptimizationContainer, + sys::PSY.System, + variable_type::Type{T}, + service::R, + contributing_devices::Vector{V}, + formulation::AbstractSecurityConstrainedReservesFormulation, +) where { + T <: AbstractContingencyVariableType, + R <: PSY.AbstractReserve, + V <: PSY.StaticInjection, +} + @assert !isempty(contributing_devices) + service_model_outages = nothing # populated below if found via lookup + time_steps = get_time_steps(container) + binary = get_variable_binary(variable_type(), R, formulation) + service_name = PSY.get_name(service) + + # Outages claimed by this service are passed through `service_model.outages` + # which is opaque here; we read the per-outage attached generators directly + # off the supplemental attribute so the deployment variable is pinned to + # zero for the outaged generator under its own contingency. + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + outage_ids = string.(IS.get_uuid.(associated_outages)) + + variable = lazy_container_addition!( + container, + variable_type(), + R, + outage_ids, + [PSY.get_name(d) for d in contributing_devices], + time_steps; + meta = service_name, + ) + + for outage in associated_outages + outage_id = string(IS.get_uuid(outage)) + associated_devices = + PSY.get_associated_components(sys, outage; component_type = PSY.Generator) + for device in contributing_devices + name = PSY.get_name(device) + device_outaged = device in associated_devices + for t in time_steps + v = JuMP.@variable( + get_jump_model(container), + base_name = "$(T)_$(R)_$(service_name)_{$(outage_id), $(name), $(t)}", + binary = binary, + ) + variable[outage_id, name, t] = v + if device_outaged + # The outaged generator cannot deploy reserves for its own + # contingency; force the variable to zero. + JuMP.set_upper_bound(v, 0.0) + JuMP.set_lower_bound(v, 0.0) + JuMP.set_start_value(v, 0.0) + continue + end + ub = get_variable_upper_bound(variable_type(), service, device, formulation) + ub === nothing || JuMP.set_upper_bound(v, ub) + lb = get_variable_lower_bound(variable_type(), service, device, formulation) + (lb === nothing || binary) || JuMP.set_lower_bound(v, lb) + init = get_variable_warm_start_value(variable_type(), device, formulation) + init === nothing || JuMP.set_start_value(v, init) + end + end + end + return +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. +# ---------------------------------------------------------------------------- + +function add_to_expression!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{U}, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:PM.AbstractPowerModel}, +) where { + T <: PostContingencyActivePowerBalance, + U <: AbstractContingencyVariableType, + V <: PSY.Generator, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + expression = lazy_container_addition!( + container, + T(), + R, + string.(IS.get_uuid.(associated_outages)), + 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 + name = PSY.get_name(device) + mult = device in associated_devices ? 0.0 : mult_default + for t in time_steps + _add_to_jump_expression!( + expression[outage_id, t], + reserve_deployment_variable[outage_id, name, t], + mult, + ) + end + end + end + return +end + +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + attribute_device_map::Vector{ + NamedTuple{(:component, :supplemental_attribute), Tuple{V, PSY.UnplannedOutage}}, + }, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:PM.AbstractPowerModel}, +) where { + T <: PostContingencyActivePowerBalance, + U <: VariableType, + V <: PSY.Generator, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + Set(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) + expression = get_expression(container, T(), R, service_name) + for (d, outage) in attribute_device_map + outage in associated_outages || continue + outage_id = string(IS.get_uuid(outage)) + name = PSY.get_name(d) + variable = get_variable(container, U(), typeof(d)) + mult = get_variable_multiplier(U(), typeof(d), F()) + for t in time_steps + _add_to_jump_expression!( + expression[outage_id, t], + variable[name, t], + mult, + ) + end + end + return +end + +function add_to_expression!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{U}, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::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 = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + 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 + mult = device in associated_devices ? 0.0 : mult_default + name = PSY.get_name(device) + bus_number = + PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) + for t in time_steps + _add_to_jump_expression!( + expression[outage_id, bus_number, t], + reserve_deployment_variable[outage_id, name, t], + mult, + ) + end + end + end + return +end + +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + attribute_device_map::Vector{ + NamedTuple{(:component, :supplemental_attribute), Tuple{V, PSY.UnplannedOutage}}, + }, + service::R, + ::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(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) + 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) + 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_to_jump_expression!( + expression[outage_id, bus_number, t], + variable[name, t], + mult, + ) + end + end + return +end + +function add_to_expression!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{U}, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::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 = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + 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 + mult = device in associated_devices ? 0.0 : mult_default + name = PSY.get_name(device) + area_name = PSY.get_name(PSY.get_area(PSY.get_bus(device))) + for t in time_steps + _add_to_jump_expression!( + expression[outage_id, area_name, t], + reserve_deployment_variable[outage_id, name, t], + mult, + ) + end + end + end + return +end + +function add_to_expression!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{U}, + service::R, + ::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.UnplannedOutage, + sys, + ) + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + Set(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) + 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) + 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_to_jump_expression!( + expression[outage_id, area_name, t], + variable[name, t], + mult, + ) + end + end + return +end + +# Per-(outage, generator, t) post-contingency active power expression. +# Used when no reserve requirement time series is configured (the older +# `has_requirement_ts` branch). The expression is the pre-contingency +# generator dispatch plus the reserve-deployment variable, with the +# outaged generator contributing zero. `PostContingencyActivePowerGeneration` +# is dense over the contributing devices so per-generator min/max bounds +# can be applied directly. +function add_to_expression!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:PM.AbstractActivePowerModel}, +) where { + T <: PostContingencyActivePowerGeneration, + V <: PSY.Generator, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + expression = add_expression_container!( + container, + T(), + R, + string.(IS.get_uuid.(associated_outages)), + PSY.get_name.(contributing_devices), + time_steps; + meta = service_name, + ) + reserve_deployment_variable = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable(), + R, + service_name, + ) + for device in contributing_devices + 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, + ) + outage_id = string(IS.get_uuid(outage)) + gen_outaged = device in associated_devices + for t in time_steps + _add_to_jump_expression!( + expression[outage_id, gen_name, t], + reserve_deployment_variable[outage_id, gen_name, t], + 1.0, + ) + gen_outaged && continue + _add_to_jump_expression!( + expression[outage_id, gen_name, t], + gen_var[gen_name, t], + 1.0, + ) + end + end + end + return +end + +# ---------------------------------------------------------------------------- +# Sparse-monitored post-contingency flow expression (PTDF only): +# flow[c, ℓ, t] = pre_flow[ℓ, t] + Σ_b PTDF[ℓ, b] * deployment[c, b, t] +# Only built for monitored components carried by the service-claimed outages +# in `service_model.outages`. The branch type is taken from the monitored +# tuple so the correct `PTDFBranchFlow` container is consulted per component. +# ---------------------------------------------------------------------------- + +function add_post_contingency_flow_expressions!( + container::OptimizationContainer, + ::Type{T}, + service::R, + service_model::ServiceModel{R, F}, + network_model::NetworkModel{N}, +) where { + T <: PostContingencyBranchFlow, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, + N <: AbstractPTDFModel, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + net_reduction_data = network_model.network_reduction + resolved = _resolve_service_monitored_arcs(service_model, net_reduction_data) + expression_container = _add_service_post_contingency_sparse_expression!( + container, T, R, service_name, resolved, time_steps, + ) + isempty(resolved) && return expression_container + + nodal_deployment = get_expression( + container, + PostContingencyNodalActivePowerDeployment(), + R, + service_name, + ) + ptdf = get_PTDF_matrix(network_model) + + # Cache PTDF columns per (monitored_type, arc) — multiple outages may + # monitor the same arc. + pre_flow_cache = Dict{DataType, Any}() + for (uuid, entries) in resolved + outage_id = string(uuid) + for (entry_type, name, arc, _) in entries + pre_flow = get!(pre_flow_cache, entry_type) do + get_expression(container, PTDFBranchFlow(), entry_type) + end + ptdf_col = ptdf[arc, :] + for t in time_steps + acc = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(acc, pre_flow[name, t]) + @inbounds for b in eachindex(ptdf_col) + coef = ptdf_col[b] + abs(coef) < PTDF_ZERO_TOL && continue + JuMP.add_to_expression!( + acc, coef, nodal_deployment[outage_id, b, t], + ) + end + expression_container[outage_id, name, t] = acc + end + end + end + return expression_container +end + +# ---------------------------------------------------------------------------- +# Post-contingency constraints +# ---------------------------------------------------------------------------- + +""" +Per-outage system-wide generation-balance constraint: the +`PostContingencyActivePowerBalance` expression (sum of reserve deployments +minus the outaged generation) must close to zero for every outage and time. +""" +function add_constraints!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + ::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:PM.AbstractPowerModel}, +) where { + T <: PostContingencyGenerationBalanceConstraint, + U <: PostContingencyActivePowerBalance, + V <: PSY.Generator, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + expressions = get_expression(container, U(), R, service_name) + constraint = add_constraints_container!( + container, + T(), + R, + [string(IS.get_uuid(o)) for o in associated_outages], + time_steps; + meta = service_name, + ) + 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) + end + return +end + +""" +Sparse-monitored post-contingency branch flow inequalities. The container is +keyed by `(outage_id::String, monitored_name::String, t::Int)` and only +entries resolved from `service_model.outages` are populated. Limits use the +monitored branch's emergency rating. Optional non-negative slacks relax the +inequalities at `POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST`. +""" +function add_constraints!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + service::R, + service_model::ServiceModel{R, F}, + network_model::NetworkModel{<:AbstractPTDFModel}, +) where { + T <: PostContingencyFlowRateConstraint, + U <: PostContingencyBranchFlow, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + net_reduction_data = network_model.network_reduction + all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) + resolved = _resolve_service_monitored_arcs(service_model, net_reduction_data) + + con_lb = _add_service_post_contingency_sparse_constraints!( + container, T, R, service_name; meta_suffix = "lb", + ) + con_ub = _add_service_post_contingency_sparse_constraints!( + container, T, R, service_name; meta_suffix = "ub", + ) + isempty(resolved) && return + + post_cont_flow = get_expression(container, U(), R, service_name) + jump_model = get_jump_model(container) + + use_slacks = get_use_slacks(service_model) + slack_ub = use_slacks ? + add_post_contingency_slack_variables!( + container, + PostContingencyFlowActivePowerSlackUpperBound, + service, + service_name, + resolved, + F(), + ) : nothing + slack_lb = use_slacks ? + add_post_contingency_slack_variables!( + container, + PostContingencyFlowActivePowerSlackLowerBound, + service, + service_name, + resolved, + F(), + ) : nothing + + for (uuid, entries) in resolved + outage_id = string(uuid) + for (entry_type, name, arc, reduction_kind) in entries + reduction_entry = + all_branch_maps_by_type[reduction_kind][entry_type][arc] + limits = get_emergency_min_max_limits( + reduction_entry, T, StaticBranch, + ) + for t in time_steps + if use_slacks + con_ub[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] - + slack_ub[outage_id, name, t] <= limits.max, + ) + con_lb[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] + + slack_lb[outage_id, name, t] >= limits.min, + ) + else + con_ub[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] <= limits.max, + ) + con_lb[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] >= limits.min, + ) + end + end + end + end + return +end + +""" +Per-outage upper bound on the reserve-deployment variable by the +pre-contingency reserve variable. Outaged generators are pinned to zero. +""" +function add_constraints!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{X}, + ::Type{U}, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:PM.AbstractPowerModel}, +) where { + T <: PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + X <: VariableType, + U <: AbstractContingencyVariableType, + V <: PSY.Generator, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + constraint = add_constraints_container!( + container, + T(), + R, + [string(IS.get_uuid(o)) for o in associated_outages], + [PSY.get_name(d) for d in contributing_devices], + time_steps; + meta = service_name, + ) + variable = get_variable(container, X(), R, service_name) + variable_outage = get_variable(container, U(), R, service_name) + jump_model = get_jump_model(container) + 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 + name = PSY.get_name(device) + gen_outaged = device in associated_devices + for t in time_steps + if gen_outaged + constraint[outage_id, name, t] = JuMP.@constraint( + jump_model, + variable_outage[outage_id, name, t] == 0.0, + ) + continue + end + constraint[outage_id, name, t] = JuMP.@constraint( + jump_model, + variable_outage[outage_id, name, t] <= variable[name, t], + ) + end + end + end + return +end + +""" +Per-(outage, generator, t) min/max bounds on the +`PostContingencyActivePowerGeneration` expression. Used when the service +has no reserve requirement time series. +""" +function add_constraints!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:PM.AbstractActivePowerModel}, +) where { + T <: PostContingencyActivePowerGenerationLimitsConstraint, + V <: PSY.Generator, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + con_lb = add_constraints_container!( + container, + T(), + R, + string.(IS.get_uuid.(associated_outages)), + PSY.get_name.(contributing_devices), + time_steps; + meta = "$(service_name)_lb", + ) + con_ub = add_constraints_container!( + container, + T(), + R, + string.(IS.get_uuid.(associated_outages)), + PSY.get_name.(contributing_devices), + time_steps; + meta = "$(service_name)_ub", + ) + expressions = + get_expression(container, PostContingencyActivePowerGeneration(), R, service_name) + jump_model = get_jump_model(container) + for device in contributing_devices + name = PSY.get_name(device) + limits = PSY.get_active_power_limits(device) + 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 + if gen_outaged + con_ub[outage_id, name, t] = JuMP.@constraint( + jump_model, expressions[outage_id, name, t] == 0.0, + ) + con_lb[outage_id, name, t] = JuMP.@constraint( + jump_model, expressions[outage_id, name, t] == 0.0, + ) + continue + end + con_ub[outage_id, name, t] = JuMP.@constraint( + jump_model, expressions[outage_id, name, t] <= limits.max, + ) + con_lb[outage_id, name, t] = JuMP.@constraint( + jump_model, expressions[outage_id, name, t] >= limits.min, + ) + end + end + end + return +end + +""" +Per-(outage, area, t) area balance for the `AreaBalancePowerModel`: the +post-contingency area-deployment expression plus the pre-contingency area +`ActivePowerBalance` must close to zero. +""" +function add_constraints!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{U}, + ::Type{Y}, + service::R, + ::ServiceModel{R, F}, + ::NetworkModel{<:AreaBalancePowerModel}, +) where { + T <: PostContingencyCopperPlateBalanceConstraint, + U <: PostContingencyAreaActivePowerDeployment, + Y <: ActivePowerBalance, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + devices = PSY.get_components(PSY.Area, sys) + area_names = PSY.get_name.(devices) + service_name = PSY.get_name(service) + associated_outages = + sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); + by = IS.get_uuid) + con = add_constraints_container!( + container, + T(), + R, + string.(IS.get_uuid.(associated_outages)), + area_names, + time_steps; + meta = service_name, + ) + contingency_expression = get_expression(container, U(), R, service_name) + area_expression = get_expression(container, Y(), PSY.Area) + jump_model = get_jump_model(container) + for outage in associated_outages + outage_id = string(IS.get_uuid(outage)) + for area in devices + area_name = PSY.get_name(area) + for t in time_steps + con[outage_id, area_name, t] = JuMP.@constraint( + jump_model, + contingency_expression[outage_id, area_name, t] + + area_expression[area_name, t] == 0.0, + ) + end + end + end + return +end + +# ---------------------------------------------------------------------------- +# construct_service! dispatches: argument + model construct stages for +# (SecurityConstrainedContingencyReserve, SecurityConstrainedRampReserve) × +# (PTDF, CopperPlate, AreaBalance). +# ---------------------------------------------------------------------------- + +# Shared ArgumentConstructStage helper used by both formulations: builds +# pre-contingency reserve variable + post-contingency deployment variable. +function _construct_service_arguments_sc!( + container::OptimizationContainer, + sys::PSY.System, + model::ServiceModel{SR, F}, + devices_template::Dict{Symbol, DeviceModel}, + require_ts::Bool, +) where { + SR <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + name = get_service_name(model) + service = PSY.get_component(SR, sys, name) + !PSY.get_available(service) && return false + contributing_devices = get_contributing_devices(model) + + has_requirement_ts = + haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && + length(PSY.get_time_series_keys(service)) > 0 + if has_requirement_ts || require_ts + add_parameters!(container, RequirementTimeSeriesParameter, service, model) + add_variables!( + container, + ActivePowerReserveVariable, + service, + contributing_devices, + F(), + ) + add_to_expression!(container, ActivePowerReserveVariable, model, devices_template) + end + add_feedforward_arguments!(container, model, service) + + associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + if isempty(associated_outages) + @warn "Service $(SR)('$name'): no UnplannedOutage supplemental attributes \ + are attached; the security-constrained formulation $(F) will not \ + add any post-contingency variables or constraints." + return false + end + + add_variables!( + container, + sys, + PostContingencyActivePowerReserveDeploymentVariable, + service, + contributing_devices, + F(), + ) + return true +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + model::ServiceModel{SR, SecurityConstrainedContingencyReserve}, + devices_template::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:PM.AbstractActivePowerModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_arguments_sc!(container, sys, model, devices_template, false) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + model::ServiceModel{SR, SecurityConstrainedRampReserve}, + devices_template::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:PM.AbstractActivePowerModel}, +) where {SR <: PSY.AbstractReserve} + # Ramp reserve formulation always requires the requirement time series. + _construct_service_arguments_sc!(container, sys, model, devices_template, true) + return +end + +# Shared ModelConstructStage helper for the pre-contingency requirement, +# ramp, participation, objective, feedforward and dual hookups. +function _construct_service_pre_contingency!( + container::OptimizationContainer, + sys::PSY.System, + service::PSY.AbstractReserve, + contributing_devices, + model::ServiceModel{SR, F}, + has_requirement_ts::Bool, + include_ramp::Bool, +) where { + SR <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + if has_requirement_ts + add_constraints!(container, RequirementConstraint, service, contributing_devices, model) + include_ramp && add_constraints!( + container, RampConstraint, service, contributing_devices, model, + ) + add_constraints!( + container, + ParticipationFractionConstraint, + service, + contributing_devices, + model, + ) + objective_function!(container, service, model) + end + add_feedforward_constraints!(container, model, service) + add_constraint_dual!(container, sys, model) + return +end + +# Shared helper for the post-contingency power-balance + generation-balance +# expression/constraint stack that's common to every network model. +function _construct_service_post_contingency_balance!( + container::OptimizationContainer, + sys::PSY.System, + service::PSY.AbstractReserve, + contributing_devices, + model::ServiceModel{SR, F}, + network_model::NetworkModel, +) where { + SR <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + add_to_expression!( + container, sys, PostContingencyActivePowerBalance, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, + ) + attribute_device_map = PSY.get_component_supplemental_attribute_pairs( + PSY.Generator, PSY.UnplannedOutage, sys, + ) + add_to_expression!( + container, PostContingencyActivePowerBalance, ActivePowerVariable, + attribute_device_map, service, model, network_model, + ) + add_constraints!( + container, PostContingencyGenerationBalanceConstraint, + PostContingencyActivePowerBalance, + contributing_devices, service, model, network_model, + ) + return attribute_device_map +end + +# ----- PTDF (DC) network model ----- + +function _construct_service_model_ptdf!( + container::OptimizationContainer, + sys::PSY.System, + model::ServiceModel{SR, F}, + network_model::NetworkModel{<:PM.AbstractDCPModel}, + has_requirement_ts_default::Bool, + include_ramp::Bool, +) where { + SR <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + name = get_service_name(model) + service = PSY.get_component(SR, sys, name) + !PSY.get_available(service) && return + contributing_devices = get_contributing_devices(model) + + has_requirement_ts = + has_requirement_ts_default || ( + haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && + length(PSY.get_time_series_keys(service)) > 0 + ) + _construct_service_pre_contingency!( + container, sys, service, contributing_devices, model, + has_requirement_ts, include_ramp, + ) + + associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + isempty(associated_outages) && return + + attribute_device_map = _construct_service_post_contingency_balance!( + container, sys, service, contributing_devices, model, network_model, + ) + add_to_expression!( + container, sys, PostContingencyNodalActivePowerDeployment, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, + ) + add_to_expression!( + container, PostContingencyNodalActivePowerDeployment, ActivePowerVariable, + attribute_device_map, service, model, network_model, + ) + add_post_contingency_flow_expressions!( + container, PostContingencyBranchFlow, service, model, network_model, + ) + add_constraints!( + container, PostContingencyFlowRateConstraint, PostContingencyBranchFlow, + service, model, network_model, + ) + + if has_requirement_ts + add_constraints!( + container, sys, + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + ActivePowerReserveVariable, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, + ) + else + add_to_expression!( + container, sys, PostContingencyActivePowerGeneration, + contributing_devices, service, model, network_model, + ) + add_constraints!( + container, sys, PostContingencyActivePowerGenerationLimitsConstraint, + contributing_devices, service, model, network_model, + ) + end + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, SecurityConstrainedContingencyReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:PM.AbstractDCPModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_model_ptdf!(container, sys, model, network_model, false, false) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, SecurityConstrainedRampReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:PM.AbstractDCPModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_model_ptdf!(container, sys, model, network_model, true, true) + return +end + +# ----- CopperPlate network model ----- + +function _construct_service_model_copperplate!( + container::OptimizationContainer, + sys::PSY.System, + model::ServiceModel{SR, F}, + network_model::NetworkModel{<:CopperPlatePowerModel}, + has_requirement_ts_default::Bool, + include_ramp::Bool, +) where { + SR <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + name = get_service_name(model) + service = PSY.get_component(SR, sys, name) + !PSY.get_available(service) && return + contributing_devices = get_contributing_devices(model) + + has_requirement_ts = + has_requirement_ts_default || ( + haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && + length(PSY.get_time_series_keys(service)) > 0 + ) + _construct_service_pre_contingency!( + container, sys, service, contributing_devices, model, + has_requirement_ts, include_ramp, + ) + + associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + isempty(associated_outages) && return + + _construct_service_post_contingency_balance!( + container, sys, service, contributing_devices, model, network_model, + ) + + if has_requirement_ts + add_constraints!( + container, sys, + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + ActivePowerReserveVariable, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, + ) + else + add_to_expression!( + container, sys, PostContingencyActivePowerGeneration, + contributing_devices, service, model, network_model, + ) + add_constraints!( + container, sys, PostContingencyActivePowerGenerationLimitsConstraint, + contributing_devices, service, model, network_model, + ) + end + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, SecurityConstrainedContingencyReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:CopperPlatePowerModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_model_copperplate!( + container, sys, model, network_model, false, false, + ) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, SecurityConstrainedRampReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:CopperPlatePowerModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_model_copperplate!( + container, sys, model, network_model, true, true, + ) + return +end + +# ----- AreaBalance network model ----- + +function _construct_service_model_areabalance!( + container::OptimizationContainer, + sys::PSY.System, + model::ServiceModel{SR, F}, + network_model::NetworkModel{<:AreaBalancePowerModel}, + has_requirement_ts_default::Bool, + include_ramp::Bool, +) where { + SR <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + name = get_service_name(model) + service = PSY.get_component(SR, sys, name) + !PSY.get_available(service) && return + contributing_devices = get_contributing_devices(model) + + has_requirement_ts = + has_requirement_ts_default || ( + haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && + length(PSY.get_time_series_keys(service)) > 0 + ) + _construct_service_pre_contingency!( + container, sys, service, contributing_devices, model, + has_requirement_ts, include_ramp, + ) + + associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + isempty(associated_outages) && return + + _construct_service_post_contingency_balance!( + container, sys, service, contributing_devices, model, network_model, + ) + add_to_expression!( + container, sys, PostContingencyAreaActivePowerDeployment, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, + ) + add_to_expression!( + container, sys, PostContingencyAreaActivePowerDeployment, ActivePowerVariable, + service, model, network_model, + ) + add_constraints!( + container, sys, PostContingencyCopperPlateBalanceConstraint, + PostContingencyAreaActivePowerDeployment, ActivePowerBalance, + service, model, network_model, + ) + + if has_requirement_ts + add_constraints!( + container, sys, + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + ActivePowerReserveVariable, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, + ) + else + add_to_expression!( + container, sys, PostContingencyActivePowerGeneration, + contributing_devices, service, model, network_model, + ) + add_constraints!( + container, sys, PostContingencyActivePowerGenerationLimitsConstraint, + contributing_devices, service, model, network_model, + ) + end + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, SecurityConstrainedContingencyReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:AreaBalancePowerModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_model_areabalance!( + container, sys, model, network_model, false, false, + ) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, SecurityConstrainedRampReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:AreaBalancePowerModel}, +) where {SR <: PSY.AbstractReserve} + _construct_service_model_areabalance!( + container, sys, model, network_model, true, true, + ) + return +end From 712939cd8c02ee0999db74347f9b9f889a751dff Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Mon, 25 May 2026 06:08:34 -0600 Subject: [PATCH 05/17] Add test_static_injection_security_constrained_models.jl --- ...c_injection_security_constrained_models.jl | 2106 +++++++++++++++++ 1 file changed, 2106 insertions(+) create mode 100644 test/test_static_injection_security_constrained_models.jl diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl new file mode 100644 index 0000000000..be8d4e91a0 --- /dev/null +++ b/test/test_static_injection_security_constrained_models.jl @@ -0,0 +1,2106 @@ +function get_outage_total_power_by_step_dict( + sys::PSY.System, + variables::Dict{String, DataFrame}, + var_name::String, + associated_outages::Vector{PSY.UnplannedOutage}; + col_name::String = "name", +) + required_variables = variables[var_name] + total_variable_dict = Dict{String, Vector{Float64}}() + for outage in associated_outages + outage_name = string(IS.get_uuid(outage)) + outage_power_v = Vector{Float64}() + devices = PSY.get_associated_components( + sys, + outage; + component_type = PSY.Generator, + ) + for (i, device) in enumerate(devices) + device_name = PSY.get_name(device) + current_v = + filter(x -> x[col_name] == device_name, required_variables)[!, "value"] + if i == 1 + outage_power_v = current_v + else + outage_power_v .+= current_v + end + end + total_variable_dict[outage_name] = outage_power_v + end + return total_variable_dict +end + +function get_reserve_total_power_by_step_dict( + variables::Dict{String, DataFrame}, + var_name::String, + associated_outages::Vector{PSY.UnplannedOutage}, + contributing_devices::Union{ + IS.FlattenIteratorWrapper{<:PSY.Generator}, + Vector{<:PSY.Generator}, + }; + col_name::String = "name2", +) + required_variables = variables[var_name] + total_variable_dict = Dict{String, Vector{Float64}}() + for outage in associated_outages + outage_name = string(IS.get_uuid(outage)) + outage_power_v = Vector{Float64}() + for (i, device) in enumerate(contributing_devices) + device_name = PSY.get_name(device) + current_v = + filter(x -> x[col_name] == device_name, required_variables)[!, "value"] + if i == 1 + outage_power_v = current_v + else + outage_power_v .+= current_v + end + end + total_variable_dict[outage_name] = outage_power_v + end + return total_variable_dict +end + +function test_reserves_deployment( + power_outage::Float64, + reserve_deployment::Float64; + tol::Float64 = 1e-3, +) + @test isapprox(power_outage, reserve_deployment, atol = tol) +end + +function compare_outage_power_and_deployed_reserves( + sys::PSY.System, + res::OptimizationProblemResults, + service::PSY.VariableReserve; + tolerance::Float64 = 1e-3, +) + variablesdict = read_variables(res) + associated_outages = + collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) + # Fall back: in the new G-1 pattern, outages are attached to the outaged + # generator, not the reserve service. Resolve from system if empty. + if isempty(associated_outages) + all_outages = collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, sys)) + associated_outages = all_outages + end + outage_dict = get_outage_total_power_by_step_dict( + sys, + variablesdict, + "ActivePowerVariable__ThermalStandard", + associated_outages; + col_name = "name", + ) + contributing_devices = PSY.get_contributing_devices(sys, service) + service_name = PSY.get_name(service) + reserve_dict = get_reserve_total_power_by_step_dict( + variablesdict, + "PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__" * + service_name, + associated_outages, + contributing_devices; + col_name = "name2", + ) + for outage in associated_outages + outage_name = string(IS.get_uuid(outage)) + for i in 1:length(outage_dict[outage_name]) + test_reserves_deployment( + outage_dict[outage_name][i], + reserve_dict[outage_name][i], + ) + end + end +end + +@testset "G-n with Ramp reserve deliverability constraints Dispatch with responding reserves only up, including reduction of parallel circuits" begin + for add_parallel_line in [true, false] + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + if add_parallel_line + l4 = get_component(Line, c_sys5, "4") + add_equivalent_ac_transmission_with_parallel_circuits!(c_sys5, l4, PSY.Line) + end + systems = [c_sys5] + objfuncs = [GAEVF, GQEVF, GQEVF] + constraint_keys = [ + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "lb", + ), + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "ub", + ), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5 => [360, 0, 600, 432, 72], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5 => 329000.0, + ) + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + gen = get_component(ThermalStandard, sys, "Solitude") + set_ramp_limits!(gen, (up = 0.4, down = 0.4)) #Increase ramp limits to make the problem feasible + components_outages_names = components_outages_cases[sys] + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + end + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with contingency reserves deliverability constraints including responding reserves only up, reserve requirement, and reduction of parallel circuits" begin + for add_parallel_line in [true, false] + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + if add_parallel_line + l4 = get_component(Line, c_sys5, "4") + add_equivalent_ac_transmission_with_parallel_circuits!(c_sys5, l4, PSY.Line) + end + systems = [c_sys5] + objfuncs = [GAEVF, GQEVF, GQEVF] + constraint_keys = [ + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "lb", + ), + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "ub", + ), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5 => [360, 0, 600, 432, 72], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5 => 329000.0, + ) + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + gen = get_component(ThermalStandard, sys, "Solitude") + set_ramp_limits!(gen, (up = 0.4, down = 0.4)) #Increase ramp limits to make the problem feasible + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + end + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with contingency reserves deliverability constraints including responding reserves only up, NO reserve requirement, and reduction of parallel circuits" begin + for add_parallel_line in [true, false] + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + if add_parallel_line + l4 = get_component(Line, c_sys5, "4") + add_equivalent_ac_transmission_with_parallel_circuits!(c_sys5, l4, PSY.Line) + end + systems = [c_sys5] + objfuncs = [GAEVF, GQEVF, GQEVF] + constraint_keys = [ + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "lb", + ), + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "ub", + ), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -lb", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -ub", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5 => [240, 0, 504, 504, 96], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5 => 329000.0, + ) + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + gen = get_component(ThermalStandard, sys, "Solitude") + set_ramp_limits!(gen, (up = 0.4, down = 0.4)) #Increase ramp limits to make the problem feasible + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + remove_time_series!( + sys, + Deterministic, + reserve_up, + "requirement", + ) + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + end + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +#This test ensures that the security constrained models build even when there are devices without set_device_model!() +@testset "Test if G-n with Ramp reserve deliverability constraints builds when there is a device without set_device_model!()" begin + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + l4 = get_component(Line, c_sys5, "4") + add_equivalent_ac_transmission_with_parallel_circuits!( + c_sys5, + l4, + PSY.Line, + PSY.MonitoredLine, + ) + remove_component!(c_sys5, l4) + + systems = [c_sys5] + + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + end + + template = + ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys])) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + set_device_model!(template, PowerLoad, StaticPowerLoad) + #set_device_model!(template, MonitoredLine, StaticBranchBounds) + set_device_model!(template, Line, StaticBranch) + set_device_model!(template, Transformer2W, StaticBranch) + set_device_model!(template, TapTransformer, StaticBranch) + set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + end +end +@testset "Test SecurityConstrainedContingencyReserve with different BranchFormulations" begin + for line_formulation in [StaticBranch, StaticBranchUnbounded, StaticBranchBounds] + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + l4 = get_component(Line, c_sys5, "4") + add_equivalent_ac_transmission_with_parallel_circuits!( + c_sys5, + l4, + PSY.Line, + PSY.MonitoredLine, + ) + remove_component!(c_sys5, l4) + + systems = [c_sys5] + + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + end + + template = + ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys])) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + set_device_model!(template, PowerLoad, StaticPowerLoad) + #set_device_model!(template, MonitoredLine, StaticBranchBounds) + set_device_model!(template, Line, line_formulation) + set_device_model!(template, Transformer2W, StaticBranch) + set_device_model!(template, TapTransformer, StaticBranch) + set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + constraints = ps_model.internal.container.constraints + flow_rate_cons = constraints[PSI.ConstraintKey{ + PostContingencyFlowRateConstraint, + VariableReserve{ReserveUp}, + }( + "Reserve1 -lb", + )] + @test size(flow_rate_cons) == (1, 5, 24) + end + end +end + +@testset "Test if G-n with Contingency reserve deliverability constraints builds when there is a device without set_device_model!()" begin + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + l4 = get_component(Line, c_sys5, "4") + add_equivalent_ac_transmission_with_parallel_circuits!( + c_sys5, + l4, + PSY.Line, + PSY.MonitoredLine, + ) + remove_component!(c_sys5, l4) + + systems = [c_sys5] + + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + end + + template = + ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys])) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + set_device_model!(template, PowerLoad, StaticPowerLoad) + #set_device_model!(template, MonitoredLine, StaticBranchBounds) + set_device_model!(template, Line, StaticBranch) + set_device_model!(template, Transformer2W, StaticBranch) + set_device_model!(template, TapTransformer, StaticBranch) + set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + end +end + +@testset "G-n with Ramp reserve deliverability constraints UC allowing 2 reserve products to respond" begin + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + systems = [c_sys5] + objfuncs = [GAEVF, GQEVF, GQEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve11 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve11 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve11", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve11", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve11", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve11", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5 => [960, 0, 1296, 600, 240], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5 => 254242.0, + ) + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up2 = get_component(VariableReserve{ReserveUp}, sys, "Reserve11") + end + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + + set_device_model!( + template, + ThermalStandard, + ThermalStandardUnitCommitment, + ) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve11", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + true, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + end +end + +@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFPowerModel w/wo Reserve Slacks" begin + reserve_slacks = [false, true] + objfuncs = [GAEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + test_results = IdDict{Bool, Vector{Int}}( + reserve_slacks[1] => [744, 0, 1536, 1200, 168], + reserve_slacks[2] => [1416, 0, 1536, 1200, 168], + ) + test_obj_values = IdDict{Bool, Float64}( + reserve_slacks[1] => 497000.0, + reserve_slacks[2] => 497000.0, + ) + components_outages_cases = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + for reserve_slack in reserve_slacks + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + + components_outages_names, reserve_names = components_outages_cases + for (component_name, reserve_name) in + zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_1"; + use_slacks = reserve_slack, + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_2"; + use_slacks = reserve_slack, + )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[reserve_slack]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[1]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[reserve_slack], + 10000, + ) + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, reserve requirement" begin + reserve_slacks = [false, true] + objfuncs = [GAEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + test_results = IdDict{Bool, Vector{Int}}( + reserve_slacks[1] => [744, 0, 1536, 1200, 168], + reserve_slacks[2] => [1416, 0, 1536, 1200, 168], + ) + test_obj_values = IdDict{Bool, Float64}( + reserve_slacks[1] => 497000.0, + reserve_slacks[2] => 497000.0, + ) + components_outages_cases = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + for reserve_slack in reserve_slacks + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + components_outages_names, reserve_names = components_outages_cases + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_1"; + use_slacks = reserve_slack, + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_2"; + use_slacks = reserve_slack, + )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[reserve_slack]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[1]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[reserve_slack], + 10000, + ) + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, NO reserve requirement" begin + c_sys5_2area = PSB.build_system(PSISystems, "two_area_pjm_DA") + transform_single_time_series!(c_sys5_2area, Hour(24), Hour(1)) + systems = [c_sys5_2area] + objfuncs = [GAEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -lb", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -ub", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -lb", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -ub", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5_2area => PTDF(c_sys5_2area), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5_2area => [504, 0, 1344, 1344, 216], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5_2area => 497000.0, + ) + components_outages_cases = IdDict{System, Tuple{Vector{String}, Vector{String}}}( + c_sys5_2area => (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]), + ) + for (ix, sys) in enumerate(systems) + components_outages_names, reserve_names = components_outages_cases[sys] + contributing_devices = get_components( + g -> get_name(get_area(get_bus(g))) == "Area1", + ThermalStandard, + sys, + ) + add_reserve_product_without_requirement_time_series!( + sys, + "Reserve1_1", + "Up", + contributing_devices, + ) + contributing_devices = get_components( + g -> get_name(get_area(get_bus(g))) == "Area2", + ThermalStandard, + sys, + ) + add_reserve_product_without_requirement_time_series!( + sys, + "Reserve1_2", + "Up", + contributing_devices, + ) + + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_2", + )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with Ramp reserve deliverability constraints with CopperPlatePowerModel" begin + c_sys5_2area = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(c_sys5_2area, Hour(24), Hour(1)) + systems = [c_sys5_2area] + objfuncs = [GAEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5_2area => PTDF(c_sys5_2area), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5_2area => [720, 0, 624, 288, 120], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5_2area => 497494.48, + ) + components_outages_cases = IdDict{System, Tuple{Vector{String}, Vector{String}}}( + c_sys5_2area => (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]), + ) + for (ix, sys) in enumerate(systems) + components_outages_names, reserve_names = components_outages_cases[sys] + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_2", + )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with Contingency reserve deliverability constraints with CopperPlatePowerModel with Reserve Requirement" begin + c_sys5_2area = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(c_sys5_2area, Hour(24), Hour(1)) + systems = [c_sys5_2area] + objfuncs = [GAEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5_2area => PTDF(c_sys5_2area), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5_2area => [720, 0, 624, 288, 120], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5_2area => 497494.48, + ) + components_outages_cases = IdDict{System, Tuple{Vector{String}, Vector{String}}}( + c_sys5_2area => (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]), + ) + for (ix, sys) in enumerate(systems) + components_outages_names, reserve_names = components_outages_cases[sys] + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_2", + )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end + end +end + +@testset "G-n with Contingency reserve deliverability constraints with CopperPlatePowerModel with NO Reserve Requirement" begin + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + systems = [c_sys5] + objfuncs = [GAEVF] + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -lb", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1 -ub", + )] + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + test_results = IdDict{System, Vector{Int}}( + c_sys5 => [240, 0, 216, 216, 96], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5 => 329000.0, + ) + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + remove_time_series!( + sys, + Deterministic, + reserve_up, + "requirement", + ) + + components_outages_names = components_outages_cases[sys] + for component_name in components_outages_names + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end +end + +@testset "G-n with Ramp reserve deliverability constraints with AreaBalance PowerModel" begin + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyCopperPlateBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyCopperPlateBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + + c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(c_sys, Hour(24), Hour(1)) + components_outages_names, reserve_names = + (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, c_sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, c_sys, component_name) + add_supplemental_attribute!(c_sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + end + + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + set_device_model!(template, AreaInterchange, StaticBranch) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_2", + )) + + ps_model = + DecisionModel(template, c_sys; resolution = Hour(1), optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + psi_constraint_test(ps_model, constraint_keys) + + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + + moi_tests(ps_model, 744, 0, 648, 312, 240, false) + + opt_container = PSI.get_optimization_container(ps_model) + copper_plate_constraints = + PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) + @test size(copper_plate_constraints) == (2, 24) + + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 497494, 1) + + results = OptimizationProblemResults(ps_model) + interarea_flow = read_variable( + results, + "FlowActivePowerVariable__AreaInterchange"; + table_format = TableFormat.WIDE, + ) + # The values for these tests come from the data + @test all(interarea_flow[!, "1_2"] .<= 150) + @test all(interarea_flow[!, "1_2"] .>= -150) + + load = read_parameter( + results, + "ActivePowerTimeSeriesParameter__PowerLoad"; + table_format = TableFormat.WIDE, + ) + thermal_gen = read_variable( + results, + "ActivePowerVariable__ThermalStandard"; + table_format = TableFormat.WIDE, + ) + + zone_1_load = sum(eachcol(load[!, ["Bus4_1", "Bus3_1", "Bus2_1"]])) + zone_1_gen = sum( + eachcol( + thermal_gen[ + !, + ["Solitude_1", "Park City_1", "Sundance_1", "Brighton_1", "Alta_1"], + ], + ), + ) + @test all( + isapprox.( + sum(zone_1_gen .+ zone_1_load .- interarea_flow[!, "1_2"]; dims = 2), + 0.0; + atol = 1e-3, + ), + ) + + zone_2_load = sum(eachcol(load[!, ["Bus4_2", "Bus3_2", "Bus2_2"]])) + zone_2_gen = sum( + eachcol( + thermal_gen[ + !, + ["Solitude_2", "Park City_2", "Sundance_2", "Brighton_2", "Alta_2"], + ], + ), + ) + @test all( + isapprox.( + sum(zone_2_gen .+ zone_2_load .+ interarea_flow[!, "1_2"]; dims = 2), + 0.0; + atol = 1e-3, + ), + ) + + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + compare_outage_power_and_deployed_reserves( + c_sys, + res, + reserve_up) + end +end + +@testset "G-n with Contingency reserve deliverability constraints with AreaBalancePowerModel with Reserve Requirement" begin + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyCopperPlateBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyCopperPlateBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + + c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(c_sys, Hour(24), Hour(1)) + components_outages_names, reserve_names = + (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, c_sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, c_sys, component_name) + add_supplemental_attribute!(c_sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + end + + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + set_device_model!(template, AreaInterchange, StaticBranch) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_2", + )) + + ps_model = + DecisionModel(template, c_sys; resolution = Hour(1), optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + psi_constraint_test(ps_model, constraint_keys) + + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + + moi_tests(ps_model, 744, 0, 648, 312, 240, false) + + opt_container = PSI.get_optimization_container(ps_model) + copper_plate_constraints = + PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) + @test size(copper_plate_constraints) == (2, 24) + + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 500505.113, 1) + + results = OptimizationProblemResults(ps_model) + interarea_flow = read_variable( + results, + "FlowActivePowerVariable__AreaInterchange"; + table_format = TableFormat.WIDE, + ) + # The values for these tests come from the data + @test all(interarea_flow[!, "1_2"] .<= 150) + @test all(interarea_flow[!, "1_2"] .>= -150) + + load = read_parameter( + results, + "ActivePowerTimeSeriesParameter__PowerLoad"; + table_format = TableFormat.WIDE, + ) + thermal_gen = read_variable( + results, + "ActivePowerVariable__ThermalStandard"; + table_format = TableFormat.WIDE, + ) + + zone_1_load = sum(eachcol(load[!, ["Bus4_1", "Bus3_1", "Bus2_1"]])) + zone_1_gen = sum( + eachcol( + thermal_gen[ + !, + ["Solitude_1", "Park City_1", "Sundance_1", "Brighton_1", "Alta_1"], + ], + ), + ) + @test all( + isapprox.( + sum(zone_1_gen .+ zone_1_load .- interarea_flow[!, "1_2"]; dims = 2), + 0.0; + atol = 1e-3, + ), + ) + + zone_2_load = sum(eachcol(load[!, ["Bus4_2", "Bus3_2", "Bus2_2"]])) + zone_2_gen = sum( + eachcol( + thermal_gen[ + !, + ["Solitude_2", "Park City_2", "Sundance_2", "Brighton_2", "Alta_2"], + ], + ), + ) + @test all( + isapprox.( + sum(zone_2_gen .+ zone_2_load .+ interarea_flow[!, "1_2"]; dims = 2), + 0.0; + atol = 1e-3, + ), + ) + + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + compare_outage_power_and_deployed_reserves( + c_sys, + res, + reserve_up) + end +end + +@testset "G-n with Contingency reserve deliverability constraints with AreaBalancePowerModel with NO Reserve Requirement" begin + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -lb", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1 -ub", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -lb", + ), + PSI.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2 -ub", + ), + PSI.ConstraintKey( + PostContingencyCopperPlateBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyCopperPlateBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + + c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, "Reserve1_1") + remove_time_series!( + c_sys, + SingleTimeSeries, + reserve_up, + "requirement", + ) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, "Reserve1_2") + remove_time_series!( + c_sys, + SingleTimeSeries, + reserve_up, + "requirement", + ) + + transform_single_time_series!(c_sys, Hour(24), Hour(1)) + components_outages_names, reserve_names = + (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + #TODO REMOVE RESERVE REQUIREMENT TIME SERIES AND ADAPT CODE + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + # --- Create Outage Data --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, c_sys)), + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, c_sys, component_name) + add_supplemental_attribute!(c_sys, component, transition_data) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + end + + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + set_device_model!(template, AreaInterchange, StaticBranch) + + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_2", + )) + + ps_model = + DecisionModel(template, c_sys; resolution = Hour(1), optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + psi_constraint_test(ps_model, constraint_keys) + + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + + moi_tests(ps_model, 504, 0, 456, 456, 288, false) + + opt_container = PSI.get_optimization_container(ps_model) + copper_plate_constraints = + PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) + @test size(copper_plate_constraints) == (2, 24) + + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 500505.113, 1) + + results = OptimizationProblemResults(ps_model) + interarea_flow = read_variable( + results, + "FlowActivePowerVariable__AreaInterchange"; + table_format = TableFormat.WIDE, + ) + # The values for these tests come from the data + @test all(interarea_flow[!, "1_2"] .<= 150) + @test all(interarea_flow[!, "1_2"] .>= -150) + + load = read_parameter( + results, + "ActivePowerTimeSeriesParameter__PowerLoad"; + table_format = TableFormat.WIDE, + ) + thermal_gen = read_variable( + results, + "ActivePowerVariable__ThermalStandard"; + table_format = TableFormat.WIDE, + ) + + zone_1_load = sum(eachcol(load[!, ["Bus4_1", "Bus3_1", "Bus2_1"]])) + zone_1_gen = sum( + eachcol( + thermal_gen[ + !, + ["Solitude_1", "Park City_1", "Sundance_1", "Brighton_1", "Alta_1"], + ], + ), + ) + @test all( + isapprox.( + sum(zone_1_gen .+ zone_1_load .- interarea_flow[!, "1_2"]; dims = 2), + 0.0; + atol = 1e-3, + ), + ) + + zone_2_load = sum(eachcol(load[!, ["Bus4_2", "Bus3_2", "Bus2_2"]])) + zone_2_gen = sum( + eachcol( + thermal_gen[ + !, + ["Solitude_2", "Park City_2", "Sundance_2", "Brighton_2", "Alta_2"], + ], + ), + ) + @test all( + isapprox.( + sum(zone_2_gen .+ zone_2_load .+ interarea_flow[!, "1_2"]; dims = 2), + 0.0; + atol = 1e-3, + ), + ) + + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + compare_outage_power_and_deployed_reserves( + c_sys, + res, + reserve_up) + end +end \ No newline at end of file From 1ecf21cbd288ee14561995866fd16a1f68d90e29 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Mon, 25 May 2026 10:50:39 -0600 Subject: [PATCH 06/17] SC reserves: service-level outage threading, dual supplemental attachment, MOI rebaseline - Replace global outage discovery with service-scoped _service_outages(sys, service_model) helper across all 16 attachment sites in static_injection_security_constrained_models.jl; thread sys/service_model through signatures. - Add get_default_time_series_names(::Reserve, ::AbstractSecurityConstrainedReservesFormulation) so the SC reserve formulations pick up the requirement time series like the legacy formulations. - Enable RampConstraint for SecurityConstrainedContingencyReserve (matches the older behavior when requirement_ts is present). - AreaPTDFPowerModel: switch nodal_deployment slicing to positional .data access to match ptdf column positional indices on the area-reduced bus axis. - Tests: normalize meta strings (Reserve1 -lb -> Reserve1_lb etc), attach the UnplannedOutage supplemental attribute to both the contributing component and the reserve service, replace size() on SparseAxisArray with length-based check, and rebaseline MOI count expectations for the new sparse post-contingency containers. --- src/services_models/reserves.jl | 9 ++ ...c_injection_security_constrained_models.jl | 135 +++++++++--------- ...c_injection_security_constrained_models.jl | 135 ++++++++++-------- 3 files changed, 155 insertions(+), 124 deletions(-) diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index 6172ab8ebb..58ca666e81 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -70,6 +70,15 @@ function get_default_time_series_names( ) end +function get_default_time_series_names( + ::Type{<:PSY.Reserve}, + ::Type{<:AbstractSecurityConstrainedReservesFormulation}, +) + return Dict{Type{<:TimeSeriesParameter}, String}( + RequirementTimeSeriesParameter => "requirement", + ) +end + function get_default_time_series_names( ::Type{<:PSY.ReserveNonSpinning}, ::Type{NonSpinningReserve}, diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 8dad838f0e..287e2e2712 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -99,6 +99,25 @@ function _resolve_service_monitored_arcs( return resolved end +""" +Resolve the outages claimed by `service_model.outages` to the +`PSY.UnplannedOutage` supplemental attribute objects attached to generators +in `sys`. Returned vector is sorted by UUID for deterministic axes. + +This is the service-side counterpart to iterating `get_outages(device_model)` +on the AC-branch side: outages are attached to the *outaged generator*, not +to the reserve service, so resolution requires a UUID lookup against the +system. Callers use the resolved objects to query +`PSY.get_associated_components(sys, outage; component_type = PSY.Generator)` +and pin the outaged generator's deployment variable to zero. +""" +function _service_outages(sys::PSY.System, service_model::ServiceModel) + outage_uuids = sort!(collect(keys(get_outages(service_model)))) + return PSY.UnplannedOutage[ + PSY.get_supplemental_attribute(sys, uuid) for uuid in outage_uuids + ] +end + """ Pre-allocate a `SparseAxisArray` keyed by `(outage_id::String, monitored_name::String, t::Int)` holding zero `AffExpr`s @@ -209,6 +228,7 @@ function add_variables!( sys::PSY.System, variable_type::Type{T}, service::R, + service_model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, contributing_devices::Vector{V}, formulation::AbstractSecurityConstrainedReservesFormulation, ) where { @@ -217,18 +237,15 @@ function add_variables!( V <: PSY.StaticInjection, } @assert !isempty(contributing_devices) - service_model_outages = nothing # populated below if found via lookup time_steps = get_time_steps(container) binary = get_variable_binary(variable_type(), R, formulation) service_name = PSY.get_name(service) - # Outages claimed by this service are passed through `service_model.outages` - # which is opaque here; we read the per-outage attached generators directly - # off the supplemental attribute so the deployment variable is pinned to - # zero for the outaged generator under its own contingency. - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + # Outages claimed by this service live on `service_model.outages` (UUID + # keys). Resolve them to the supplemental attribute objects so we can + # query the associated outaged generators and pin their deployment + # variables to zero under their own contingency. + associated_outages = _service_outages(sys, service_model) outage_ids = string.(IS.get_uuid.(associated_outages)) variable = lazy_container_addition!( @@ -288,7 +305,7 @@ function add_to_expression!( ::Type{U}, contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractPowerModel}, ) where { T <: PostContingencyActivePowerBalance, @@ -299,9 +316,7 @@ function add_to_expression!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) expression = lazy_container_addition!( container, T(), @@ -333,13 +348,14 @@ end function add_to_expression!( container::OptimizationContainer, + sys::PSY.System, ::Type{T}, ::Type{U}, attribute_device_map::Vector{ NamedTuple{(:component, :supplemental_attribute), Tuple{V, PSY.UnplannedOutage}}, }, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractPowerModel}, ) where { T <: PostContingencyActivePowerBalance, @@ -350,8 +366,7 @@ function add_to_expression!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - Set(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) + associated_outages = Set(_service_outages(sys, service_model)) expression = get_expression(container, T(), R, service_name) for (d, outage) in attribute_device_map outage in associated_outages || continue @@ -377,7 +392,7 @@ function add_to_expression!( ::Type{U}, contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, network_model::NetworkModel{N}, ) where { T <: PostContingencyNodalActivePowerDeployment, @@ -389,9 +404,7 @@ function add_to_expression!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) ptdf = get_PTDF_matrix(network_model) bus_numbers = PNM.get_bus_axis(ptdf) expression = lazy_container_addition!( @@ -429,13 +442,14 @@ end function add_to_expression!( container::OptimizationContainer, + sys::PSY.System, ::Type{T}, ::Type{U}, attribute_device_map::Vector{ NamedTuple{(:component, :supplemental_attribute), Tuple{V, PSY.UnplannedOutage}}, }, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, network_model::NetworkModel{N}, ) where { T <: PostContingencyNodalActivePowerDeployment, @@ -447,8 +461,7 @@ function add_to_expression!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - Set(PSY.get_supplemental_attributes(PSY.UnplannedOutage, 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 @@ -476,7 +489,7 @@ function add_to_expression!( ::Type{U}, contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:AreaBalancePowerModel}, ) where { T <: PostContingencyAreaActivePowerDeployment, @@ -487,9 +500,7 @@ function add_to_expression!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) area_names = PSY.get_name.(PSY.get_components(PSY.Area, sys)) expression = lazy_container_addition!( container, @@ -528,7 +539,7 @@ function add_to_expression!( ::Type{T}, ::Type{U}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:AreaBalancePowerModel}, ) where { T <: PostContingencyAreaActivePowerDeployment, @@ -543,8 +554,7 @@ function add_to_expression!( ) time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - Set(PSY.get_supplemental_attributes(PSY.UnplannedOutage, 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 @@ -577,7 +587,7 @@ function add_to_expression!( ::Type{T}, contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractActivePowerModel}, ) where { T <: PostContingencyActivePowerGeneration, @@ -587,9 +597,7 @@ function add_to_expression!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) expression = add_expression_container!( container, T(), @@ -674,6 +682,11 @@ function add_post_contingency_flow_expressions!( pre_flow_cache = Dict{DataType, Any}() for (uuid, entries) in resolved outage_id = string(uuid) + # Positional slice over the bus/time axes; matches `ptdf_col`'s + # positional indexing and avoids keyed lookup mismatches when the + # nodal expression's bus axis is a subset of the PTDF column space + # (e.g. AreaPTDFPowerModel). + post_cont_expr = nodal_deployment[outage_id, :, :].data for (entry_type, name, arc, _) in entries pre_flow = get!(pre_flow_cache, entry_type) do get_expression(container, PTDFBranchFlow(), entry_type) @@ -685,9 +698,7 @@ function add_post_contingency_flow_expressions!( @inbounds for b in eachindex(ptdf_col) coef = ptdf_col[b] abs(coef) < PTDF_ZERO_TOL && continue - JuMP.add_to_expression!( - acc, coef, nodal_deployment[outage_id, b, t], - ) + JuMP.add_to_expression!(acc, coef, post_cont_expr[b, t]) end expression_container[outage_id, name, t] = acc end @@ -707,11 +718,12 @@ minus the outaged generation) must close to zero for every outage and time. """ function add_constraints!( container::OptimizationContainer, + sys::PSY.System, ::Type{T}, ::Type{U}, ::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractPowerModel}, ) where { T <: PostContingencyGenerationBalanceConstraint, @@ -722,9 +734,7 @@ function add_constraints!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) expressions = get_expression(container, U(), R, service_name) constraint = add_constraints_container!( container, @@ -848,7 +858,7 @@ function add_constraints!( ::Type{U}, contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractPowerModel}, ) where { T <: PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, @@ -860,9 +870,7 @@ function add_constraints!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) constraint = add_constraints_container!( container, T(), @@ -912,7 +920,7 @@ function add_constraints!( ::Type{T}, contributing_devices::Union{IS.FlattenIteratorWrapper{V}, Vector{V}}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:PM.AbstractActivePowerModel}, ) where { T <: PostContingencyActivePowerGenerationLimitsConstraint, @@ -922,9 +930,7 @@ function add_constraints!( } time_steps = get_time_steps(container) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) con_lb = add_constraints_container!( container, T(), @@ -989,7 +995,7 @@ function add_constraints!( ::Type{U}, ::Type{Y}, service::R, - ::ServiceModel{R, F}, + service_model::ServiceModel{R, F}, ::NetworkModel{<:AreaBalancePowerModel}, ) where { T <: PostContingencyCopperPlateBalanceConstraint, @@ -1002,9 +1008,7 @@ function add_constraints!( devices = PSY.get_components(PSY.Area, sys) area_names = PSY.get_name.(devices) service_name = PSY.get_name(service) - associated_outages = - sort!(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)); - by = IS.get_uuid) + associated_outages = _service_outages(sys, service_model) con = add_constraints_container!( container, T(), @@ -1072,11 +1076,11 @@ function _construct_service_arguments_sc!( end add_feedforward_arguments!(container, model, service) - associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + associated_outages = _service_outages(sys, model) if isempty(associated_outages) - @warn "Service $(SR)('$name'): no UnplannedOutage supplemental attributes \ - are attached; the security-constrained formulation $(F) will not \ - add any post-contingency variables or constraints." + @warn "Service $(SR)('$name'): `service_model.outages` is empty; the \ + security-constrained formulation $(F) will not add any \ + post-contingency variables or constraints." return false end @@ -1085,6 +1089,7 @@ function _construct_service_arguments_sc!( sys, PostContingencyActivePowerReserveDeploymentVariable, service, + model, contributing_devices, F(), ) @@ -1173,11 +1178,11 @@ function _construct_service_post_contingency_balance!( PSY.Generator, PSY.UnplannedOutage, sys, ) add_to_expression!( - container, PostContingencyActivePowerBalance, ActivePowerVariable, + container, sys, PostContingencyActivePowerBalance, ActivePowerVariable, attribute_device_map, service, model, network_model, ) add_constraints!( - container, PostContingencyGenerationBalanceConstraint, + container, sys, PostContingencyGenerationBalanceConstraint, PostContingencyActivePowerBalance, contributing_devices, service, model, network_model, ) @@ -1212,7 +1217,7 @@ function _construct_service_model_ptdf!( has_requirement_ts, include_ramp, ) - associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + associated_outages = _service_outages(sys, model) isempty(associated_outages) && return attribute_device_map = _construct_service_post_contingency_balance!( @@ -1224,7 +1229,7 @@ function _construct_service_model_ptdf!( contributing_devices, service, model, network_model, ) add_to_expression!( - container, PostContingencyNodalActivePowerDeployment, ActivePowerVariable, + container, sys, PostContingencyNodalActivePowerDeployment, ActivePowerVariable, attribute_device_map, service, model, network_model, ) add_post_contingency_flow_expressions!( @@ -1265,7 +1270,7 @@ function construct_service!( ::Set{<:DataType}, network_model::NetworkModel{<:PM.AbstractDCPModel}, ) where {SR <: PSY.AbstractReserve} - _construct_service_model_ptdf!(container, sys, model, network_model, false, false) + _construct_service_model_ptdf!(container, sys, model, network_model, false, true) return end @@ -1310,7 +1315,7 @@ function _construct_service_model_copperplate!( has_requirement_ts, include_ramp, ) - associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + associated_outages = _service_outages(sys, model) isempty(associated_outages) && return _construct_service_post_contingency_balance!( @@ -1348,7 +1353,7 @@ function construct_service!( network_model::NetworkModel{<:CopperPlatePowerModel}, ) where {SR <: PSY.AbstractReserve} _construct_service_model_copperplate!( - container, sys, model, network_model, false, false, + container, sys, model, network_model, false, true, ) return end @@ -1396,7 +1401,7 @@ function _construct_service_model_areabalance!( has_requirement_ts, include_ramp, ) - associated_outages = PSY.get_supplemental_attributes(PSY.UnplannedOutage, service) + associated_outages = _service_outages(sys, model) isempty(associated_outages) && return _construct_service_post_contingency_balance!( @@ -1448,7 +1453,7 @@ function construct_service!( network_model::NetworkModel{<:AreaBalancePowerModel}, ) where {SR <: PSY.AbstractReserve} _construct_service_model_areabalance!( - container, sys, model, network_model, false, false, + container, sys, model, network_model, false, true, ) return end diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index be8d4e91a0..311bd66111 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -136,12 +136,12 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -lb", + "Reserve1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -ub", + "Reserve1_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), @@ -193,6 +193,7 @@ end # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), @@ -256,12 +257,12 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -lb", + "Reserve1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -ub", + "Reserve1_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), @@ -314,6 +315,7 @@ end # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), @@ -377,12 +379,12 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -lb", + "Reserve1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -ub", + "Reserve1_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), @@ -394,12 +396,12 @@ end PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -lb", + "Reserve1_lb", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -ub", + "Reserve1_ub", ), ] PTDF_ref = IdDict{System, PTDF}( @@ -435,6 +437,7 @@ end # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), @@ -505,8 +508,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = @@ -564,8 +568,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = @@ -594,9 +599,9 @@ end PostContingencyFlowRateConstraint, VariableReserve{ReserveUp}, }( - "Reserve1 -lb", + "Reserve1_lb", )] - @test size(flow_rate_cons) == (1, 5, 24) + @test length(flow_rate_cons) == 1 * 5 * 24 end end end @@ -633,8 +638,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = @@ -673,22 +679,22 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -lb", + "Reserve1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -ub", + "Reserve1_ub", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve11 -lb", + "Reserve11_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve11 -ub", + "Reserve11_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), @@ -756,9 +762,11 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) reserve_up2 = get_component(VariableReserve{ReserveUp}, sys, "Reserve11") + add_supplemental_attribute!(sys, reserve_up2, transition_data) end template = get_thermal_dispatch_template_network( @@ -816,22 +824,22 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -lb", + "Reserve1_1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -lb", + "Reserve1_2_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -ub", + "Reserve1_1_ub", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -ub", + "Reserve1_2_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), PSI.ConstraintKey( @@ -876,8 +884,8 @@ end ), ] test_results = IdDict{Bool, Vector{Int}}( - reserve_slacks[1] => [744, 0, 1536, 1200, 168], - reserve_slacks[2] => [1416, 0, 1536, 1200, 168], + reserve_slacks[1] => [984, 0, 2400, 1824, 216], + reserve_slacks[2] => [3528, 0, 2400, 1824, 216], ) test_obj_values = IdDict{Bool, Float64}( reserve_slacks[1] => 497000.0, @@ -900,8 +908,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( @@ -960,22 +969,22 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -lb", + "Reserve1_1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -lb", + "Reserve1_2_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -ub", + "Reserve1_1_ub", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -ub", + "Reserve1_2_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), PSI.ConstraintKey( @@ -1020,8 +1029,8 @@ end ), ] test_results = IdDict{Bool, Vector{Int}}( - reserve_slacks[1] => [744, 0, 1536, 1200, 168], - reserve_slacks[2] => [1416, 0, 1536, 1200, 168], + reserve_slacks[1] => [984, 0, 2400, 1824, 216], + reserve_slacks[2] => [3528, 0, 2400, 1824, 216], ) test_obj_values = IdDict{Bool, Float64}( reserve_slacks[1] => 497000.0, @@ -1042,8 +1051,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( @@ -1104,22 +1114,22 @@ end PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -lb", + "Reserve1_1_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -lb", + "Reserve1_2_lb", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -ub", + "Reserve1_1_ub", ), PSI.ConstraintKey( PostContingencyFlowRateConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -ub", + "Reserve1_2_ub", ), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), @@ -1136,29 +1146,29 @@ end PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -lb", + "Reserve1_1_lb", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -ub", + "Reserve1_1_ub", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -lb", + "Reserve1_2_lb", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -ub", + "Reserve1_2_ub", ), ] PTDF_ref = IdDict{System, PTDF}( c_sys5_2area => PTDF(c_sys5_2area), ) test_results = IdDict{System, Vector{Int}}( - c_sys5_2area => [504, 0, 1344, 1344, 216], + c_sys5_2area => [744, 0, 2208, 2208, 264], ) test_obj_values = IdDict{System, Float64}( c_sys5_2area => 497000.0, @@ -1200,8 +1210,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( @@ -1300,7 +1311,7 @@ end c_sys5_2area => PTDF(c_sys5_2area), ) test_results = IdDict{System, Vector{Int}}( - c_sys5_2area => [720, 0, 624, 288, 120], + c_sys5_2area => [960, 0, 864, 288, 168], ) test_obj_values = IdDict{System, Float64}( c_sys5_2area => 497494.48, @@ -1319,8 +1330,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( @@ -1419,7 +1431,7 @@ end c_sys5_2area => PTDF(c_sys5_2area), ) test_results = IdDict{System, Vector{Int}}( - c_sys5_2area => [720, 0, 624, 288, 120], + c_sys5_2area => [960, 0, 864, 288, 168], ) test_obj_values = IdDict{System, Float64}( c_sys5_2area => 497494.48, @@ -1438,8 +1450,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) - add_supplemental_attribute!(sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( @@ -1501,12 +1514,12 @@ end PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -lb", + "Reserve1_lb", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1 -ub", + "Reserve1_ub", )] PTDF_ref = IdDict{System, PTDF}( c_sys5 => PTDF(c_sys5), @@ -1540,6 +1553,7 @@ end # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, sys, component_name) add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network( @@ -1647,8 +1661,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, c_sys, component_name) - add_supplemental_attribute!(c_sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + add_supplemental_attribute!(c_sys, component, transition_data) + add_supplemental_attribute!(c_sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) @@ -1677,14 +1692,14 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 744, 0, 648, 312, 240, false) + moi_tests(ps_model, 984, 0, 888, 312, 384, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 497494, 1) + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 500505.113, 1) results = OptimizationProblemResults(ps_model) interarea_flow = read_variable( @@ -1821,8 +1836,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, c_sys, component_name) - add_supplemental_attribute!(c_sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + add_supplemental_attribute!(c_sys, component, transition_data) + add_supplemental_attribute!(c_sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) @@ -1851,7 +1867,7 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 744, 0, 648, 312, 240, false) + moi_tests(ps_model, 984, 0, 888, 312, 384, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = @@ -1942,22 +1958,22 @@ end PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -lb", + "Reserve1_1_lb", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_1 -ub", + "Reserve1_1_ub", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -lb", + "Reserve1_2_lb", ), PSI.ConstraintKey( PostContingencyActivePowerGenerationLimitsConstraint, PSY.VariableReserve{ReserveUp}, - "Reserve1_2 -ub", + "Reserve1_2_ub", ), PSI.ConstraintKey( PostContingencyCopperPlateBalanceConstraint, @@ -2001,8 +2017,9 @@ end ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, c_sys, component_name) - add_supplemental_attribute!(c_sys, component, transition_data) reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + add_supplemental_attribute!(c_sys, component, transition_data) + add_supplemental_attribute!(c_sys, reserve_up, transition_data) end template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) @@ -2031,7 +2048,7 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 504, 0, 456, 456, 288, false) + moi_tests(ps_model, 744, 0, 696, 696, 432, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = From b86ab36e08babadb75147cd821139391d495778d Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Mon, 25 May 2026 15:12:22 -0600 Subject: [PATCH 07/17] Apply formatter to SC model src and test files --- src/operation/template_validation.jl | 2 - ...c_injection_security_constrained_models.jl | 46 ++++++++++++------- ...c_injection_security_constrained_models.jl | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 603c6772e3..7dc3fe82db 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -616,5 +616,3 @@ function _warn_unmatched_user_service_outages( 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 287e2e2712..08110de5cf 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -791,24 +791,30 @@ function add_constraints!( jump_model = get_jump_model(container) use_slacks = get_use_slacks(service_model) - slack_ub = use_slacks ? + slack_ub = if use_slacks add_post_contingency_slack_variables!( - container, - PostContingencyFlowActivePowerSlackUpperBound, - service, - service_name, - resolved, - F(), - ) : nothing - slack_lb = use_slacks ? + container, + PostContingencyFlowActivePowerSlackUpperBound, + service, + service_name, + resolved, + F(), + ) + else + nothing + end + slack_lb = if use_slacks add_post_contingency_slack_variables!( - container, - PostContingencyFlowActivePowerSlackLowerBound, - service, - service_name, - resolved, - F(), - ) : nothing + container, + PostContingencyFlowActivePowerSlackLowerBound, + service, + service_name, + resolved, + F(), + ) + else + nothing + end for (uuid, entries) in resolved outage_id = string(uuid) @@ -1138,7 +1144,13 @@ function _construct_service_pre_contingency!( F <: AbstractSecurityConstrainedReservesFormulation, } if has_requirement_ts - add_constraints!(container, RequirementConstraint, service, contributing_devices, model) + add_constraints!( + container, + RequirementConstraint, + service, + contributing_devices, + model, + ) include_ramp && add_constraints!( container, RampConstraint, service, contributing_devices, model, ) diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 311bd66111..b29df3e727 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -2120,4 +2120,4 @@ end res, reserve_up) end -end \ No newline at end of file +end From d3ea1270e9dfb1fd10c677d865de34400bc5071a Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Mon, 25 May 2026 21:17:42 -0600 Subject: [PATCH 08/17] formatter --- ...c_injection_security_constrained_models.jl | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 08110de5cf..7e88cc291d 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -793,25 +793,25 @@ function add_constraints!( use_slacks = get_use_slacks(service_model) slack_ub = if use_slacks add_post_contingency_slack_variables!( - container, - PostContingencyFlowActivePowerSlackUpperBound, - service, - service_name, - resolved, - F(), - ) + container, + PostContingencyFlowActivePowerSlackUpperBound, + service, + service_name, + resolved, + F(), + ) else nothing end slack_lb = if use_slacks add_post_contingency_slack_variables!( - container, - PostContingencyFlowActivePowerSlackLowerBound, - service, - service_name, - resolved, - F(), - ) + container, + PostContingencyFlowActivePowerSlackLowerBound, + service, + service_name, + resolved, + F(), + ) else nothing end From 533e29a85d60d29fb06af38cd59c4eeb5f79d8e9 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Tue, 26 May 2026 13:26:04 -0600 Subject: [PATCH 09/17] Add PostContingencyAreaInterchangeFlow expression type Introduces a new PostContingencyExpressions subtype representing the post-contingency flow on an AreaInterchange, with result-writing and natural-units conversion overloads. Exported from the package alongside PostContingencyBranchFlow. --- src/PowerSimulations.jl | 1 + src/core/expressions.jl | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/PowerSimulations.jl b/src/PowerSimulations.jl index cf874cae43..81357a74ec 100644 --- a/src/PowerSimulations.jl +++ b/src/PowerSimulations.jl @@ -422,6 +422,7 @@ export FuelConsumptionExpression export ActivePowerRangeExpressionLB export ActivePowerRangeExpressionUB export PostContingencyBranchFlow +export PostContingencyAreaInterchangeFlow export PostContingencyActivePowerGeneration export PostContingencyActivePowerBalance export PostContingencyNodalActivePowerDeployment diff --git a/src/core/expressions.jl b/src/core/expressions.jl index cdc932b5bf..cb784d62ad 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -28,6 +28,7 @@ struct ComponentReserveDownBalanceExpression <: ExpressionType end struct InterfaceTotalFlow <: ExpressionType end struct PTDFBranchFlow <: ExpressionType end struct PostContingencyBranchFlow <: PostContingencyExpressions end +struct PostContingencyAreaInterchangeFlow <: PostContingencyExpressions end struct PostContingencyActivePowerGeneration <: PostContingencyExpressions end struct PostContingencyNodalActivePowerDeployment <: PostContingencyExpressions end struct PostContingencyAreaActivePowerDeployment <: PostContingencyExpressions end @@ -48,10 +49,12 @@ should_write_resulting_value(::Type{DCCurrentBalance}) = true should_write_resulting_value(::Type{PTDFBranchFlow}) = true should_write_resulting_value(::Type{RealizedShiftedLoad}) = true should_write_resulting_value(::Type{PostContingencyBranchFlow}) = true +should_write_resulting_value(::Type{PostContingencyAreaInterchangeFlow}) = true #should_write_resulting_value(::Type{PostContingencyActivePowerGeneration}) = true convert_result_to_natural_units(::Type{InterfaceTotalFlow}) = true convert_result_to_natural_units(::Type{PostContingencyBranchFlow}) = true +convert_result_to_natural_units(::Type{PostContingencyAreaInterchangeFlow}) = true convert_result_to_natural_units(::Type{PostContingencyActivePowerGeneration}) = true convert_result_to_natural_units(::Type{PTDFBranchFlow}) = true convert_result_to_natural_units(::Type{RealizedShiftedLoad}) = true From c0b322124f0e69cbf18bbcd79901d1b0bd894001 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Tue, 26 May 2026 13:26:11 -0600 Subject: [PATCH 10/17] Admit AreaInterchange as a monitored component type Extends _monitored_components_by_modeled_type to accept PSY.AreaInterchange in addition to PSY.ACTransmission so AreaBalance-based security-constrained service models can monitor area-tie flows. Adds a haskey guard in the AC transmission resolver so it safely skips AreaInterchange entries that now appear in the shared per-type outage dict. --- .../ac_transmission_security_constrained_models.jl | 5 +++++ src/operation/template_validation.jl | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/devices_models/devices/ac_transmission_security_constrained_models.jl b/src/devices_models/devices/ac_transmission_security_constrained_models.jl index 5581555e2f..22a6974bf8 100644 --- a/src/devices_models/devices/ac_transmission_security_constrained_models.jl +++ b/src/devices_models/devices/ac_transmission_security_constrained_models.jl @@ -350,6 +350,11 @@ function _resolve_monitored_arcs( for (uuid, per_type) in get_outages(device_model) kept = Tuple{DataType, String, Tuple{Int, Int}, String}[] for (T, names) in per_type + # Per-type entries may include component types absent from the + # network-reduction's branch-arc maps (e.g. `PSY.AreaInterchange` + # under AreaBalance service-side outages). Skip those here so + # device-side AC SC builders only see branch-arc types. + haskey(name_to_arc_maps, T) || continue name_to_arc = name_to_arc_maps[T] component_to_reduction = get(component_to_reduction_maps, T, Dict{String, String}()) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 7dc3fe82db..81a86ff919 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -383,9 +383,12 @@ function _monitored_components_by_modeled_type( # Post-contingency flow limits only make sense on branch arcs: the # post-contingency builder resolves every `per_type` key through # `name_to_arc_maps`, which is keyed by ACTransmission branch types - # only. A monitored component that is either not modeled or not a - # branch type would `KeyError` there, so route it to the skip path. - if comp_type <: PSY.ACTransmission && comp_type in modeled_types + # only. `PSY.AreaInterchange` is admitted separately so the + # AreaBalance service-side builder can pick it up. A monitored + # component that is neither would `KeyError` there, so route it to + # the skip path. + admissible = (comp_type <: PSY.ACTransmission || comp_type <: PSY.AreaInterchange) + if admissible && comp_type in modeled_types push!(get!(per_type, comp_type, Set{String}()), PSY.get_name(component)) else push!(uncovered, comp_type) From fdf94b97c02c64b616eaa4ef4ffbb3d4d1903f60 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Tue, 26 May 2026 13:26:19 -0600 Subject: [PATCH 11/17] Add PostContingencyFlowRateConstraint coverage for AreaInterchange under AreaBalancePowerModel Mirrors the AreaPTDFPowerModel monitored-line handling for AreaInterchange components: adds an add_post_contingency_flow_expressions! dispatch on NetworkModel{<:AreaBalancePowerModel} that builds the PostContingencyAreaInterchangeFlow expression as the baseline FlowActivePowerVariable plus the signed sum of PostContingencyActivePowerReserveDeploymentVariable across the from/to areas minus the outaged generation contribution on the outaged side; adds the matching add_constraints! dispatch enforcing -from_to <= flow <= to_from with optional slack support; wires both into _construct_service_model_areabalance!. --- ...c_injection_security_constrained_models.jl | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 7e88cc291d..b4f429e6b2 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -852,6 +852,274 @@ function add_constraints!( return end +# ---------------------------------------------------------------------------- +# AreaBalance network model: post-contingency AreaInterchange flow expression +# and rate-limit constraints. The expression is keyed by +# `(outage_id::String, area_interchange_name::String, t::Int)` and represents +# the from→to flow after the contingency: +# post_flow = pre_flow + Σ_{g ∈ from} deploy_g - Σ_{g ∈ to} deploy_g +# - sign(outaged_side) * P_outaged +# where `sign(outaged_side)` is `+1` if the outaged generator sits in the +# from-area and `-1` if it sits in the to-area. Deployment variables for the +# outaged generator are pinned to zero, so iterating contributing devices is +# safe. Only the AreaInterchanges named in `service_model.outages[uuid]` are +# instantiated. +# ---------------------------------------------------------------------------- + +""" +Resolve every monitored `PSY.AreaInterchange` carried by +`service_model.outages` to its system component. Mirrors +`_resolve_service_monitored_arcs` but for the AreaBalance path where the +monitored object is an AreaInterchange (not a branch arc) and the only +information needed downstream is the component itself. Outages with no +monitored AreaInterchanges are skipped; the returned vector is sorted by +UUID for deterministic axes. +""" +function _resolve_service_monitored_area_interchanges( + sys::PSY.System, + service_model::ServiceModel, +) + resolved = Pair{Base.UUID, Vector{Tuple{String, PSY.AreaInterchange}}}[] + for (uuid, per_type) in get_outages(service_model) + kept = Tuple{String, PSY.AreaInterchange}[] + names = get(per_type, PSY.AreaInterchange, nothing) + if names !== nothing + for name in sort!(collect(names)) + comp = PSY.get_component(PSY.AreaInterchange, sys, name) + comp === nothing && continue + push!(kept, (name, comp)) + end + end + isempty(kept) && continue + push!(resolved, uuid => kept) + end + sort!(resolved; by = first) + return resolved +end + +""" +Build the post-contingency AreaInterchange flow expression for the +AreaBalance network model. See module-level comment above for the formula. +The container is a `SparseAxisArray` keyed by +`(outage_id, area_interchange_name, t)` registered under +`ExpressionKey(PostContingencyAreaInterchangeFlow, R; meta = service_name)`. +""" +function add_post_contingency_flow_expressions!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + service::R, + service_model::ServiceModel{R, F}, + ::NetworkModel{<:AreaBalancePowerModel}, +) where { + T <: PostContingencyAreaInterchangeFlow, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + resolved = _resolve_service_monitored_area_interchanges(sys, service_model) + + contents = Dict{Tuple{String, String, Int}, JuMP.AffExpr}() + for (uuid, entries) in resolved + outage_id = string(uuid) + for (name, _) in entries, t in time_steps + contents[(outage_id, name, t)] = zero(JuMP.AffExpr) + end + end + expression_container = SparseAxisArray(contents) + _assign_container!( + container.expressions, + ExpressionKey(T, R, service_name), + expression_container, + ) + isempty(resolved) && return expression_container + + # Baseline flow variable for the AreaInterchange (from→to convention). + flow_var = get_variable(container, FlowActivePowerVariable(), PSY.AreaInterchange) + + # Reserve-deployment variable keyed by (outage_id, gen_name, t). + reserve_deployment_variable = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable(), + R, + service_name, + ) + contributing_devices = get_contributing_devices(service_model) + + # Pre-compute area assignment for each contributing device so we can + # apply +1 for from-area and -1 for to-area without `isa` checks. + device_areas = Dict{String, String}() + for device in contributing_devices + device_areas[PSY.get_name(device)] = + PSY.get_name(PSY.get_area(PSY.get_bus(device))) + end + + associated_outages = _service_outages(sys, service_model) + outage_by_uuid = Dict(IS.get_uuid(o) => o for o in associated_outages) + for (uuid, entries) in resolved + outage = outage_by_uuid[uuid] + outage_id = string(uuid) + outaged_gens = PSY.get_associated_components( + sys, outage; component_type = PSY.Generator, + ) + 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)) + for t in time_steps + expr = expression_container[outage_id, name, t] + JuMP.add_to_expression!(expr, flow_var[name, t]) + for device in contributing_devices + gen_name = PSY.get_name(device) + gen_area = device_areas[gen_name] + coef = if gen_area == from_area + 1.0 + elseif gen_area == to_area + -1.0 + else + 0.0 + end + coef == 0.0 && continue + JuMP.add_to_expression!( + expr, + coef, + reserve_deployment_variable[outage_id, gen_name, t], + ) + end + # 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), + ) + JuMP.add_to_expression!( + expr, coef, gen_var[PSY.get_name(outaged_gen), t], + ) + end + expression_container[outage_id, name, t] = expr + end + end + end + return expression_container +end + +""" +Per-(outage, area_interchange, t) flow-rate inequalities under the +AreaBalance network model. Limits come from +`PSY.get_flow_limits(area_interchange)` as `[-from_to, +to_from]`. Optional +non-negative slacks relax the inequalities at +`POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST`. +""" +function add_constraints!( + container::OptimizationContainer, + sys::PSY.System, + ::Type{T}, + ::Type{U}, + service::R, + service_model::ServiceModel{R, F}, + ::NetworkModel{<:AreaBalancePowerModel}, +) where { + T <: PostContingencyFlowRateConstraint, + U <: PostContingencyAreaInterchangeFlow, + R <: PSY.AbstractReserve, + F <: AbstractSecurityConstrainedReservesFormulation, +} + time_steps = get_time_steps(container) + service_name = PSY.get_name(service) + resolved = _resolve_service_monitored_area_interchanges(sys, service_model) + + con_lb = _add_service_post_contingency_sparse_constraints!( + container, T, R, service_name; meta_suffix = "lb", + ) + con_ub = _add_service_post_contingency_sparse_constraints!( + container, T, R, service_name; meta_suffix = "ub", + ) + isempty(resolved) && return + + post_cont_flow = get_expression(container, U(), R, service_name) + jump_model = get_jump_model(container) + + use_slacks = get_use_slacks(service_model) + # Adapt the existing sparse-slack helper: it expects the PTDF-style + # resolved tuple shape `(type, name, arc, reduction_kind)`. Build an + # equivalent shape on the fly 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_ub = if use_slacks + add_post_contingency_slack_variables!( + container, + PostContingencyFlowActivePowerSlackUpperBound, + service, + service_name, + slack_resolved, + F(), + ) + else + nothing + end + slack_lb = if use_slacks + add_post_contingency_slack_variables!( + container, + PostContingencyFlowActivePowerSlackLowerBound, + service, + service_name, + slack_resolved, + F(), + ) + else + nothing + end + + for (uuid, entries) in resolved + outage_id = string(uuid) + for (name, area_interchange) in entries + flow_limits = PSY.get_flow_limits(area_interchange) + ub = flow_limits.to_from + lb = -1.0 * flow_limits.from_to + for t in time_steps + if use_slacks + con_ub[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] - + slack_ub[outage_id, name, t] <= ub, + ) + con_lb[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] + + slack_lb[outage_id, name, t] >= lb, + ) + else + con_ub[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] <= ub, + ) + con_lb[outage_id, name, t] = JuMP.@constraint( + jump_model, + post_cont_flow[outage_id, name, t] >= lb, + ) + end + end + end + end + return +end + """ Per-outage upper bound on the reserve-deployment variable by the pre-contingency reserve variable. Outaged generators are pinned to zero. @@ -1433,6 +1701,15 @@ function _construct_service_model_areabalance!( PostContingencyAreaActivePowerDeployment, ActivePowerBalance, service, model, network_model, ) + add_post_contingency_flow_expressions!( + container, sys, PostContingencyAreaInterchangeFlow, + service, model, network_model, + ) + add_constraints!( + container, sys, PostContingencyFlowRateConstraint, + PostContingencyAreaInterchangeFlow, + service, model, network_model, + ) if has_requirement_ts add_constraints!( From 2c85ba74d121aa54903d3a592d046c468c6db043 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Tue, 26 May 2026 13:26:25 -0600 Subject: [PATCH 12/17] Cover AreaInterchange monitoring in AreaBalance security-constrained testsets Adds AreaInterchange to monitored_components in the three AreaBalance G-n testsets, adds PostContingencyFlowRateConstraint keys for Reserve1_1/Reserve1_2 (lb/ub) to each constraint_keys list, and rebaselines moi_tests less-than/greater-than counts to reflect the new flow-rate constraints. --- ...c_injection_security_constrained_models.jl | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index b29df3e727..350b15331c 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -1645,6 +1645,26 @@ end PSY.VariableReserve{ReserveUp}, "Reserve1_2", ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_ub", + ), ] c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) @@ -1657,7 +1677,10 @@ end transition_data = GeometricDistributionForcedOutage(; mean_time_to_recovery = 10, outage_transition_probability = 0.9999, - monitored_components = collect(get_components(ACTransmission, c_sys)), + monitored_components = vcat( + collect(get_components(ACTransmission, c_sys)), + collect(get_components(AreaInterchange, c_sys)), + ), ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, c_sys, component_name) @@ -1692,7 +1715,7 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 984, 0, 888, 312, 384, false) + moi_tests(ps_model, 984, 0, 984, 408, 384, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = @@ -1820,6 +1843,26 @@ end PSY.VariableReserve{ReserveUp}, "Reserve1_2", ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_ub", + ), ] c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) @@ -1832,7 +1875,10 @@ end transition_data = GeometricDistributionForcedOutage(; mean_time_to_recovery = 10, outage_transition_probability = 0.9999, - monitored_components = collect(get_components(ACTransmission, c_sys)), + monitored_components = vcat( + collect(get_components(ACTransmission, c_sys)), + collect(get_components(AreaInterchange, c_sys)), + ), ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, c_sys, component_name) @@ -1867,7 +1913,7 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 984, 0, 888, 312, 384, false) + moi_tests(ps_model, 984, 0, 984, 408, 384, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = @@ -1985,6 +2031,26 @@ end PSY.VariableReserve{ReserveUp}, "Reserve1_2", ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_ub", + ), ] c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) @@ -2007,13 +2073,16 @@ end transform_single_time_series!(c_sys, Hour(24), Hour(1)) components_outages_names, reserve_names = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) - #TODO REMOVE RESERVE REQUIREMENT TIME SERIES AND ADAPT CODE + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) # --- Create Outage Data --- transition_data = GeometricDistributionForcedOutage(; mean_time_to_recovery = 10, outage_transition_probability = 0.9999, - monitored_components = collect(get_components(ACTransmission, c_sys)), + monitored_components = vcat( + collect(get_components(ACTransmission, c_sys)), + collect(get_components(AreaInterchange, c_sys)), + ), ) # --- Add Outage Supplemental attribute to device and services that should respond --- component = get_component(ThermalStandard, c_sys, component_name) @@ -2048,7 +2117,7 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 744, 0, 696, 696, 432, false) + moi_tests(ps_model, 744, 0, 792, 792, 432, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = From 1e3f5888635c52251a7adbafd91930f3d632f5c2 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Tue, 26 May 2026 14:11:44 -0600 Subject: [PATCH 13/17] Add PTDF/AreaPTDF G-n testsets with monitored line subset Adds two new testsets to test_static_injection_security_constrained_models.jl that exercise the per-service line-scoping path in _monitored_components_by_modeled_type and the downstream PostContingencyFlowRateConstraint build when the outage monitored_components is a hand-picked subset of the system's AC lines instead of every line. - PTDFPowerModel: c_sys5_uc with SecurityConstrainedRampReserve monitoring lines 1 and 2 only. - AreaPTDFPowerModel: two_area_pjm_DA with two SecurityConstrainedRampReserve services, each monitoring two intra-area lines. MOI counts are lower than the all-lines baselines, as expected; objective values are unchanged. --- ...c_injection_security_constrained_models.jl | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 350b15331c..1328aa58d1 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -231,6 +231,132 @@ end end end +# Exercises the per-service line-scoping path in +# `_monitored_components_by_modeled_type` and the downstream +# `PostContingencyFlowRateConstraint` build for `PTDFPowerModel` when the +# reserve service monitors a strict subset of the system's AC lines instead of +# every line. The constraint key meta does not change (it remains keyed by +# service name) but the per-outage flow constraint container ends up with +# fewer entries, which lowers the MOI counts compared to the all-lines variant. +@testset "G-n with Ramp reserve deliverability constraints PTDFPowerModel with monitored line subset" begin + c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + monitored_line_names = ["1", "2"] + systems = [c_sys5] + objfuncs = [GAEVF, GQEVF, GQEVF] + constraint_keys = [ + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "lb", + ), + PSI.ConstraintKey( + ActivePowerVariableLimitsConstraint, + PSY.ThermalStandard, + "ub", + ), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1", + ), + ] + PTDF_ref = IdDict{System, PTDF}( + c_sys5 => PTDF(c_sys5), + ) + # Counts are smaller than the all-lines baseline `[360, 0, 600, 432, 72]` + # because only the monitored subset contributes + # `PostContingencyFlowRateConstraint` rows per outage step. + test_results = IdDict{System, Vector{Int}}( + c_sys5 => [360, 0, 504, 336, 72], + ) + test_obj_values = IdDict{System, Float64}( + c_sys5 => 329000.0, + ) + components_outages_cases = IdDict{System, Vector{String}}( + c_sys5 => ["Alta"], + ) + for (ix, sys) in enumerate(systems) + gen = get_component(ThermalStandard, sys, "Solitude") + set_ramp_limits!(gen, (up = 0.4, down = 0.4)) #Increase ramp limits to make the problem feasible + components_outages_names = components_outages_cases[sys] + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + monitored_subset = + [get_component(Line, sys, n) for n in monitored_line_names] + for component_name in components_outages_names + # --- Create Outage Data with a hand-picked monitored subset --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = monitored_subset, + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) + end + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF_ref[sys]), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results[sys]..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[ix]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_values[sys], + 10000, + ) + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end +end + @testset "G-n with contingency reserves deliverability constraints including responding reserves only up, reserve requirement, and reduction of parallel circuits" begin for add_parallel_line in [true, false] c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) @@ -958,6 +1084,153 @@ end end end +# Exercises the per-service line-scoping path in +# `_monitored_components_by_modeled_type` for the `AreaPTDFPowerModel` +# network model. Each reserve service monitors a different hand-picked +# subset of AC lines, which keeps the constraint key meta keyed by service +# name but reduces the number of `PostContingencyFlowRateConstraint` rows +# compared to the all-lines baseline. +@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFPowerModel and monitored line subset" begin + objfuncs = [GAEVF] + monitored_line_names_per_service = (["1_1", "2_1"], ["1_2", "2_2"]) + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_lb", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1_ub", + ), + PSI.ConstraintKey( + PostContingencyFlowRateConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2_ub", + ), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RequirementConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + RampConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + PSI.ConstraintKey( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ), + ] + # Counts are smaller than the all-lines baseline `[984, 0, 2400, 1824, 216]` + # because each service monitors only two AC lines instead of all 13. + test_results = [984, 0, 1344, 768, 216] + test_obj_value = 497000.0 + components_outages_cases = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + + components_outages_names, reserve_names = components_outages_cases + for (component_name, reserve_name, monitored_names) in zip( + components_outages_names, + reserve_names, + monitored_line_names_per_service, + ) + monitored_subset = [get_component(Line, sys, n) for n in monitored_names] + # --- Create Outage Data with a hand-picked monitored subset --- + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = monitored_subset, + ) + # --- Add Outage Supplemental attribute to device and services that should respond --- + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + add_supplemental_attribute!(sys, component, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_2", + )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests( + ps_model, + test_results..., + false, + ) + psi_checkobjfun_test(ps_model, objfuncs[1]) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + test_obj_value, + 10000, + ) + res = OptimizationProblemResults(ps_model) + for reserve_name in reserve_names + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_up) + end +end + @testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, reserve requirement" begin reserve_slacks = [false, true] objfuncs = [GAEVF] From 6a60562ab0b20961a5c8894f55d3afcc2d001e4d Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Tue, 26 May 2026 16:38:37 -0600 Subject: [PATCH 14/17] Scope SC reserve outage auto-discovery per ServiceModel Fix _assign_outage_to_sc_service_models! so coverage is only marked when an outage is actually assigned to at least one ServiceModel, and restrict auto-discovery to outages whose attached injectors overlap the service's contributing devices. Add regression tests covering both the two-reserve scoping case and the single-reserve coverage case, and rebaseline moi/objective counts and the interarea flow tolerance impacted by the removal of spurious cross-service contingency constraints. --- src/operation/template_validation.jl | 34 ++++- ...c_injection_security_constrained_models.jl | 133 +++++++++++++++--- 2 files changed, 146 insertions(+), 21 deletions(-) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 81a86ff919..844af84f2c 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -403,6 +403,14 @@ function _attached_component_types(outage::PSY.Outage, sys::PSY.System) ) end +# Sibling of `_attached_component_types` returning the actual attached +# `PSY.Component` instances. Used by the SC reserve service per-service +# scoping path to intersect attached injectors with each service's +# contributing devices. +function _attached_components(outage::PSY.Outage, sys::PSY.System) + return collect(PSY.get_associated_components(sys, outage)) +end + # Whether SC model `m` claims `outage`. `sel` is `m`'s component-type slice of # the user's explicit outage allow-list: non-empty restricts to those UUIDs; # empty means auto-discover (claim all, skipping `PlannedOutage`s unless the @@ -534,6 +542,7 @@ function _build_service_model_outages!( outage, outage_uuid, per_type, + sys, ) if !covered @warn "Outage $(outage_uuid) is attached to injector(s) of \ @@ -587,13 +596,34 @@ function _assign_outage_to_sc_service_models!( outage::PSY.Outage, outage_uuid::Base.UUID, per_type::Dict{DataType, Set{String}}, + sys::PSY.System, ) covered = false + attached = _attached_components(outage, sys) + attached_uuids = Set{Base.UUID}(IS.get_uuid(c) for c in attached) for m in sc_service_models - covered = true key = (get_component_type(m), get_service_name(m)) - if _sc_service_claims_outage(m, outage, outage_uuid, selection[key]) + sel = selection[key] + if isempty(sel) + # Auto-discovery path: only assign the outage to this service if + # one of the outage's attached injectors is among the service's + # contributing devices. The explicit allow-list path below + # bypasses this filter so users keep full control over which + # outages a service responds to. + service = PSY.get_component( + get_component_type(m), + sys, + get_service_name(m), + ) + service === nothing && continue + contributing_uuids = Set{Base.UUID}( + IS.get_uuid(c) for c in PSY.get_contributing_devices(sys, service) + ) + isempty(intersect(attached_uuids, contributing_uuids)) && continue + end + if _sc_service_claims_outage(m, outage, outage_uuid, sel) m.outages[outage_uuid] = per_type + covered = true end end return covered diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 1328aa58d1..198053280b 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -1010,8 +1010,8 @@ end ), ] test_results = IdDict{Bool, Vector{Int}}( - reserve_slacks[1] => [984, 0, 2400, 1824, 216], - reserve_slacks[2] => [3528, 0, 2400, 1824, 216], + reserve_slacks[1] => [744, 0, 1536, 1200, 168], + reserve_slacks[2] => [2040, 0, 1536, 1200, 168], ) test_obj_values = IdDict{Bool, Float64}( reserve_slacks[1] => 497000.0, @@ -1160,9 +1160,9 @@ end "Reserve1_2", ), ] - # Counts are smaller than the all-lines baseline `[984, 0, 2400, 1824, 216]` + # Counts are smaller than the all-lines baseline `[744, 0, 1536, 1200, 168]` # because each service monitors only two AC lines instead of all 13. - test_results = [984, 0, 1344, 768, 216] + test_results = [744, 0, 1008, 672, 168] test_obj_value = 497000.0 components_outages_cases = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) @@ -1302,8 +1302,8 @@ end ), ] test_results = IdDict{Bool, Vector{Int}}( - reserve_slacks[1] => [984, 0, 2400, 1824, 216], - reserve_slacks[2] => [3528, 0, 2400, 1824, 216], + reserve_slacks[1] => [744, 0, 1536, 1200, 168], + reserve_slacks[2] => [2040, 0, 1536, 1200, 168], ) test_obj_values = IdDict{Bool, Float64}( reserve_slacks[1] => 497000.0, @@ -1441,7 +1441,7 @@ end c_sys5_2area => PTDF(c_sys5_2area), ) test_results = IdDict{System, Vector{Int}}( - c_sys5_2area => [744, 0, 2208, 2208, 264], + c_sys5_2area => [504, 0, 1344, 1344, 216], ) test_obj_values = IdDict{System, Float64}( c_sys5_2area => 497000.0, @@ -1584,7 +1584,7 @@ end c_sys5_2area => PTDF(c_sys5_2area), ) test_results = IdDict{System, Vector{Int}}( - c_sys5_2area => [960, 0, 864, 288, 168], + c_sys5_2area => [720, 0, 624, 288, 120], ) test_obj_values = IdDict{System, Float64}( c_sys5_2area => 497494.48, @@ -1704,7 +1704,7 @@ end c_sys5_2area => PTDF(c_sys5_2area), ) test_results = IdDict{System, Vector{Int}}( - c_sys5_2area => [960, 0, 864, 288, 168], + c_sys5_2area => [720, 0, 624, 288, 120], ) test_obj_values = IdDict{System, Float64}( c_sys5_2area => 497494.48, @@ -1988,14 +1988,14 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 984, 0, 984, 408, 384, false) + moi_tests(ps_model, 744, 0, 696, 360, 240, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 500505.113, 1) + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 497494.4871638, 1) results = OptimizationProblemResults(ps_model) interarea_flow = read_variable( @@ -2004,8 +2004,8 @@ end table_format = TableFormat.WIDE, ) # The values for these tests come from the data - @test all(interarea_flow[!, "1_2"] .<= 150) - @test all(interarea_flow[!, "1_2"] .>= -150) + @test all(interarea_flow[!, "1_2"] .<= 150 + 1e-6) + @test all(interarea_flow[!, "1_2"] .>= -150 - 1e-6) load = read_parameter( results, @@ -2186,14 +2186,14 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 984, 0, 984, 408, 384, false) + moi_tests(ps_model, 744, 0, 696, 360, 240, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 500505.113, 1) + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 497494.4871638, 1) results = OptimizationProblemResults(ps_model) interarea_flow = read_variable( @@ -2202,8 +2202,8 @@ end table_format = TableFormat.WIDE, ) # The values for these tests come from the data - @test all(interarea_flow[!, "1_2"] .<= 150) - @test all(interarea_flow[!, "1_2"] .>= -150) + @test all(interarea_flow[!, "1_2"] .<= 150 + 1e-6) + @test all(interarea_flow[!, "1_2"] .>= -150 - 1e-6) load = read_parameter( results, @@ -2390,14 +2390,14 @@ end @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 744, 0, 792, 792, 432, false) + moi_tests(ps_model, 504, 0, 504, 504, 288, false) opt_container = PSI.get_optimization_container(ps_model) copper_plate_constraints = PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 500505.113, 1) + psi_checksolve_test(ps_model, [MOI.OPTIMAL], 482055.7647083302, 1) results = OptimizationProblemResults(ps_model) interarea_flow = read_variable( @@ -2463,3 +2463,98 @@ end reserve_up) end end + +# Regression test for the per-service outage scoping bug in +# `_assign_outage_to_sc_service_models!`. With two SC reserve services +# (Reserve1_1 in Area1, Reserve1_2 in Area2) and a generator outage attached +# to an Area1 contributor only, the outage must be assigned to Reserve1_1's +# ServiceModel and NOT to Reserve1_2's. +@testset "SC reserve outage auto-discovery is scoped per ServiceModel" begin + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + + # Restrict each VariableReserve's contributing devices to its own area so + # the per-service scoping has a meaningful intersection to test. + reserve1 = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_1") + reserve2 = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_2") + for g in get_components( + g -> get_name(get_area(get_bus(g))) == "Area2", + ThermalStandard, + sys, + ) + PSY.has_service(g, reserve1) && PSY.remove_service!(g, reserve1) + end + for g in get_components( + g -> get_name(get_area(get_bus(g))) == "Area1", + ThermalStandard, + sys, + ) + PSY.has_service(g, reserve2) && PSY.remove_service!(g, reserve2) + end + + # Attach an UnplannedOutage to a single Area1 generator (Alta_1). + alta1 = get_component(ThermalStandard, sys, "Alta_1") + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + add_supplemental_attribute!(sys, alta1, transition_data) + outage_uuid = IS.get_uuid(transition_data) + + template = get_thermal_dispatch_template_network( + NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_1", + )) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1_2", + )) + + PSI._build_service_model_outages!(template, sys) + + services = PSI.get_service_models(template) + sm1 = services[("Reserve1_1", Symbol(VariableReserve{ReserveUp}))] + sm2 = services[("Reserve1_2", Symbol(VariableReserve{ReserveUp}))] + @test haskey(sm1.outages, outage_uuid) + @test !haskey(sm2.outages, outage_uuid) +end + +# Regression test for the single-reserve auto-discovery path: an outage +# attached to a generator that contributes to the only SC reserve service +# must end up in its ServiceModel.outages dict. +@testset "SC reserve outage auto-discovery covers single-reserve case" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 10, + outage_transition_probability = 0.9999, + monitored_components = collect(get_components(ACTransmission, sys)), + ) + alta = get_component(ThermalStandard, sys, "Alta") + add_supplemental_attribute!(sys, alta, transition_data) + outage_uuid = IS.get_uuid(transition_data) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + PSI._build_service_model_outages!(template, sys) + + services = PSI.get_service_models(template) + sm = services[("Reserve1", Symbol(VariableReserve{ReserveUp}))] + @test haskey(sm.outages, outage_uuid) +end From 08a14593924d88837370f502a18879890e1ff770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20M?= <104728742+SebastianManriqueM@users.noreply.github.com> Date: Wed, 27 May 2026 13:59:30 -0600 Subject: [PATCH 15/17] Fix docstring Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core/service_model.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index 356fd95f8e..90ba7b4eef 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -30,10 +30,10 @@ model at simulation time of each entry as a key in the model's `outages::Dict{UUID, Dict{DataType, Set{String}}}` field with empty inner maps; template validation fills the inner maps with the per-type set of monitored component names (e.g., branches) that each outage - carries. An empty default triggers auto-discovery of every outage in the system - attached to a contributing device of the service whose formulation supports - outages. If `B` is not security-constrained, a non-empty value is dropped with a - warning. + carries. An empty default triggers auto-discovery of eligible injector-attached + outages in the system for formulations that support outages; this discovery is + not restricted to the service's contributing devices. If `B` is not + security-constrained, a non-empty value is dropped with a warning. # Example From 9e63e0e4c953754e12eb7a9862a0a2fca904a599 Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Wed, 27 May 2026 14:38:23 -0600 Subject: [PATCH 16/17] Widen _service_outages return eltype to PSY.Outage _service_outages built a typed Vector{PSY.UnplannedOutage}, which would throw a MethodError if a PSY.PlannedOutage UUID landed in service_model.outages via the 'include_planned_outages' opt-in supported by _assign_outage_to_sc_service_models! in template_validation.jl. Widen the return eltype to the PSY.Outage supertype. Downstream call sites filter the attribute_device_map via PSY.get_component_supplemental_attribute_pairs(..., UnplannedOutage, sys), so planned-outage UUIDs in the resolved set are silently skipped via the existing 'outage in associated_outages' membership check rather than crashing the build. --- .../static_injection_security_constrained_models.jl | 13 +++++++++---- 1 file changed, 9 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 b4f429e6b2..43c15b81fd 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -100,9 +100,14 @@ function _resolve_service_monitored_arcs( end """ -Resolve the outages claimed by `service_model.outages` to the -`PSY.UnplannedOutage` supplemental attribute objects attached to generators -in `sys`. Returned vector is sorted by UUID for deterministic axes. +Resolve the outages claimed by `service_model.outages` to the `PSY.Outage` +supplemental attribute objects attached to generators in `sys`. Returned +vector is sorted by UUID for deterministic axes. + +The element type is `PSY.Outage` (rather than `PSY.UnplannedOutage`) so that +the `"include_planned_outages"` opt-in supported by `_assign_outage_to_sc_service_models!` +in `template_validation.jl` can flow through without a `MethodError` when a +`PSY.PlannedOutage` is claimed. This is the service-side counterpart to iterating `get_outages(device_model)` on the AC-branch side: outages are attached to the *outaged generator*, not @@ -113,7 +118,7 @@ and pin the outaged generator's deployment variable to zero. """ function _service_outages(sys::PSY.System, service_model::ServiceModel) outage_uuids = sort!(collect(keys(get_outages(service_model)))) - return PSY.UnplannedOutage[ + return PSY.Outage[ PSY.get_supplemental_attribute(sys, uuid) for uuid in outage_uuids ] end From 96e57d2a1b19a247f1beb229456ea1149143409f Mon Sep 17 00:00:00 2001 From: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:54:33 -0600 Subject: [PATCH 17/17] fix: skipping post contingency constraints when outaged gen is not in contributting devices --- src/core/service_model.jl | 44 +--- src/core/variables.jl | 18 -- src/operation/template_validation.jl | 206 +++++++----------- ...c_injection_security_constrained_models.jl | 16 +- ...c_injection_security_constrained_models.jl | 76 ++++--- 5 files changed, 140 insertions(+), 220 deletions(-) diff --git a/src/core/service_model.jl b/src/core/service_model.jl index 90ba7b4eef..fc586e58da 100644 --- a/src/core/service_model.jl +++ b/src/core/service_model.jl @@ -25,15 +25,13 @@ model at simulation time - `feedforward::Array{<:AbstractAffectFeedforward}` : use to pass parameters between models - `use_service_name::Bool` : use the name as the name for the service - - `outages::AbstractVector{<:PSY.Outage}` : G-1 contingencies to model when the - formulation is security-constrained. The constructor stores `IS.get_uuid(outage)` - of each entry as a key in the model's `outages::Dict{UUID, Dict{DataType, Set{String}}}` - field with empty inner maps; template validation fills the inner maps with the - per-type set of monitored component names (e.g., branches) that each outage - carries. An empty default triggers auto-discovery of eligible injector-attached - outages in the system for formulations that support outages; this discovery is - not restricted to the service's contributing devices. If `B` is not - security-constrained, a non-empty value is dropped with a warning. + +For security-constrained reserve formulations, the set of contingencies a +service responds to is determined entirely by attaching outage supplemental +attributes to the `PSY.Service` instance via +`add_supplemental_attribute!(sys, service, outage)`. Template validation +then mirrors those attachments into `service_model.outages`. There is no +constructor-level outage allow-list. # Example @@ -59,7 +57,6 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} time_series_names = get_default_time_series_names(D, B), attributes = Dict{String, Any}(), contributing_devices_map = Dict{Type{<:PSY.Component}, Vector{<:PSY.Component}}(), - outages::AbstractVector{<:PSY.Outage} = PSY.Outage[], ) where {D <: PSY.Service, B <: AbstractServiceFormulation} attributes_for_model = get_default_attributes(D, B) for (k, v) in attributes @@ -68,7 +65,6 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} _check_service_formulation(D) _check_service_formulation(B) - outages_field = _add_service_model_outages(D, B, outages) new{D, B}( feedforwards, service_name, @@ -78,33 +74,11 @@ mutable struct ServiceModel{D <: PSY.Service, B <: AbstractServiceFormulation} attributes_for_model, contributing_devices_map, nothing, - outages_field, + Dict{Base.UUID, Dict{DataType, Set{String}}}(), ) end end -function _add_service_model_outages( - ::Type{D}, - ::Type{B}, - outages::AbstractVector{<:PSY.Outage}, -) where {D <: PSY.Service, B <: AbstractServiceFormulation} - field = Dict{Base.UUID, Dict{DataType, Set{String}}}() - isempty(outages) && return field - if !_formulation_supports_outages(B) - @warn "ServiceModel{$D, $B}: 'outages' kwarg ignored \u2014 formulation \ - does not support G-1 contingencies." - return field - end - for outage in outages - field[IS.get_uuid(outage)] = Dict{DataType, Set{String}}() - end - return field -end - -_formulation_supports_outages( - ::Type{<:AbstractSecurityConstrainedReservesFormulation}, -) = true - get_component_type( ::ServiceModel{D, B}, ) where {D <: PSY.Service, B <: AbstractServiceFormulation} = D @@ -136,7 +110,6 @@ function ServiceModel( duals = Vector{DataType}(), time_series_names = get_default_time_series_names(D, B), attributes = get_default_attributes(D, B), - outages::AbstractVector{<:PSY.Outage} = PSY.Outage[], ) where {D <: PSY.Service, B <: AbstractServiceFormulation} # If more attributes are used later, move free form string to const and organize # attributes @@ -156,7 +129,6 @@ function ServiceModel( duals, time_series_names, attributes = attributes_for_model, - outages, ) end diff --git a/src/core/variables.jl b/src/core/variables.jl index ec16e9e9a5..bec590775d 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -396,24 +396,6 @@ Docs abbreviation: ``f^\\text{sl,lo}`` """ struct FlowActivePowerSlackLowerBound <: AbstractACActivePowerFlow end -""" -Struct to dispatch the creation of post-contingency active power flow upper bound -slack variables. Relaxes the post-contingency emergency-rating upper limit of a -monitored branch under a specific outage. Indexed per `(outage_id, name, t)`. - -Docs abbreviation: ``f^\\text{sl,up}_{c}`` -""" -struct PostContingencyFlowActivePowerSlackUpperBound <: VariableType end - -""" -Struct to dispatch the creation of post-contingency active power flow lower bound -slack variables. Relaxes the post-contingency emergency-rating lower limit of a -monitored branch under a specific outage. Indexed per `(outage_id, name, t)`. - -Docs abbreviation: ``f^\\text{sl,lo}_{c}`` -""" -struct PostContingencyFlowActivePowerSlackLowerBound <: VariableType end - """ Struct to dispatch the creation of Phase Shifters Variables diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 844af84f2c..d539f84ba2 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -403,14 +403,6 @@ function _attached_component_types(outage::PSY.Outage, sys::PSY.System) ) end -# Sibling of `_attached_component_types` returning the actual attached -# `PSY.Component` instances. Used by the SC reserve service per-service -# scoping path to intersect attached injectors with each service's -# contributing devices. -function _attached_components(outage::PSY.Outage, sys::PSY.System) - return collect(PSY.get_associated_components(sys, outage)) -end - # Whether SC model `m` claims `outage`. `sel` is `m`'s component-type slice of # the user's explicit outage allow-list: non-empty restricts to those UUIDs; # empty means auto-discover (claim all, skipping `PlannedOutage`s unless the @@ -490,72 +482,81 @@ end """ Populate `service_model.outages` for every security-constrained (SC) reserve -`ServiceModel` in the template. Mirrors `_build_device_model_outages!`, but -keyed off outages whose *attached* component is an injector (typically a -generator) instead of a branch. Monitored components are still branches, and -the inner `Dict{DataType, Set{String}}` is grouped by their modeled branch -type — the post-contingency build resolves the branch type's arc map. - -Selection semantics match the device version: a non-empty `m.outages` is -treated as the user's explicit UUID allow-list; an empty `m.outages` triggers -auto-discovery honoring the `"include_planned_outages"` attribute (default -`false`). +`ServiceModel` in the template, in a single pass over each SC service model. + +The user opts a service into responding to a given contingency by attaching +the outage supplemental attribute to the `PSY.Service` instance directly +(`add_supplemental_attribute!(sys, service, outage)`). That attachment is +the sole selection mechanism: a service claims exactly the outages attached +to it, regardless of whether the outaged component is among the service's +contributing devices. + +`PlannedOutage`s attached to the service are still gated by the +`"include_planned_outages"` attribute on the SC `ServiceModel` (default +`false`). `UnplannedOutage`s and other `Outage` subtypes are always claimed. + +A warning is emitted when an outage is attached to a `PSY.Service` whose +`(component_type, name)` does not correspond to an SC `ServiceModel` in the +template — that outage will not produce any post-contingency reserve +constraints. """ function _build_service_model_outages!( template::ProblemTemplate, sys::PSY.System, ) sc_service_models = _sc_reserve_service_models(template) + template_sc_service_keys = Set{Tuple{DataType, String}}( + (get_component_type(m), get_service_name(m)) for m in sc_service_models + ) + # Run the orphan-attachment check unconditionally so users still get + # feedback when they attached outages to a service whose SC `ServiceModel` + # was never registered (e.g. they forgot the `set_service_model!` call, or + # registered the service with a non-SC formulation). + _warn_outages_attached_to_unmodeled_services(sys, template_sc_service_keys) + isempty(sc_service_models) && return modeled_types = Set{DataType}(get_component_types(template)) - selection = _take_service_outage_selection!(sc_service_models) uncovered_types = Dict{DataType, Set{Base.UUID}}() - for outage in PSY.get_supplemental_attributes(PSY.Outage, sys) - outage_uuid = IS.get_uuid(outage) - if isempty(PSY.get_monitored_components(outage)) - @warn "Outage $(outage_uuid) ($(typeof(outage))) has empty \ - monitored_components; no post-contingency variables or \ - constraints will be created for this outage." _group = + for m in sc_service_models + empty!(m.outages) + D = get_component_type(m) + service_name = get_service_name(m) + service = PSY.get_component(D, sys, service_name) + if service === nothing + @warn "ServiceModel{$D, $(get_formulation(m))} (service_name=\ + $(service_name)) is in the template but no matching \ + service exists in the system; it will not contribute any \ + post-contingency constraints." _group = LOG_GROUP_MODELS_VALIDATION continue end - per_type, uncovered = _monitored_components_by_modeled_type( - outage, outage_uuid, sys, modeled_types, - ) - for comp_type in uncovered - push!(get!(uncovered_types, comp_type, Set{Base.UUID}()), outage_uuid) - end - isempty(per_type) && continue - - attached_types = _attached_component_types(outage, sys) - # SC reserve service models claim outages whose attached component is - # an injector. Skip outages attached only to branches — those belong - # to N-1 branch-outage SC device models. - any(t -> t <: PSY.StaticInjection, attached_types) || continue + for outage in PSY.get_supplemental_attributes(PSY.Outage, service) + outage_uuid = IS.get_uuid(outage) + if isempty(PSY.get_monitored_components(outage)) + @warn "Outage $(outage_uuid) ($(typeof(outage))) attached to \ + service $(service_name) has empty \ + monitored_components; no post-contingency variables \ + or constraints will be created for this outage." _group = + LOG_GROUP_MODELS_VALIDATION + continue + end + _service_skips_outage(outage, m) && continue - covered = _assign_outage_to_sc_service_models!( - sc_service_models, - selection, - outage, - outage_uuid, - per_type, - sys, - ) - if !covered - @warn "Outage $(outage_uuid) is attached to injector(s) of \ - type $(collect(attached_types)), but no ServiceModel with \ - an AbstractSecurityConstrainedReservesFormulation is \ - present in the template; it will not contribute any \ - post-contingency constraints." _group = - LOG_GROUP_MODELS_VALIDATION + per_type, uncovered = _monitored_components_by_modeled_type( + outage, outage_uuid, sys, modeled_types, + ) + for comp_type in uncovered + push!(get!(uncovered_types, comp_type, Set{Base.UUID}()), outage_uuid) + end + isempty(per_type) && continue + m.outages[outage_uuid] = per_type end end _warn_uncovered_monitored_types(uncovered_types) - _warn_unmatched_user_service_outages(sc_service_models, selection) return end @@ -566,85 +567,30 @@ function _sc_reserve_service_models(template::ProblemTemplate) ] end -function _take_service_outage_selection!(sc_service_models::Vector{ServiceModel}) - selection = Dict{Tuple{DataType, String}, Set{Base.UUID}}() - for m in sc_service_models - key = (get_component_type(m), get_service_name(m)) - selection[key] = Set{Base.UUID}(keys(m.outages)) - empty!(m.outages) - end - return selection -end - -function _sc_service_claims_outage( - m::ServiceModel, - outage::PSY.Outage, - outage_uuid::Base.UUID, - sel::Set{Base.UUID}, -) - isempty(sel) || return outage_uuid in sel - if outage isa PSY.PlannedOutage - attr = get_attribute(m, "include_planned_outages") - return attr === true - end - return true -end +# Whether SC service model `m` should skip `outage`. Dispatched on the outage +# subtype to keep `PlannedOutage`/`UnplannedOutage` branching out of the hot +# path. Planned outages are skipped unless the SC service model opts in via +# the `"include_planned_outages"` attribute. +_service_skips_outage(::PSY.Outage, ::ServiceModel) = false +_service_skips_outage(::PSY.PlannedOutage, m::ServiceModel) = + get_attribute(m, "include_planned_outages") !== true -function _assign_outage_to_sc_service_models!( - sc_service_models::Vector{ServiceModel}, - selection::Dict{Tuple{DataType, String}, Set{Base.UUID}}, - outage::PSY.Outage, - outage_uuid::Base.UUID, - per_type::Dict{DataType, Set{String}}, +function _warn_outages_attached_to_unmodeled_services( sys::PSY.System, + template_sc_service_keys::Set{Tuple{DataType, String}}, ) - covered = false - attached = _attached_components(outage, sys) - attached_uuids = Set{Base.UUID}(IS.get_uuid(c) for c in attached) - for m in sc_service_models - key = (get_component_type(m), get_service_name(m)) - sel = selection[key] - if isempty(sel) - # Auto-discovery path: only assign the outage to this service if - # one of the outage's attached injectors is among the service's - # contributing devices. The explicit allow-list path below - # bypasses this filter so users keep full control over which - # outages a service responds to. - service = PSY.get_component( - get_component_type(m), - sys, - get_service_name(m), - ) - service === nothing && continue - contributing_uuids = Set{Base.UUID}( - IS.get_uuid(c) for c in PSY.get_contributing_devices(sys, service) - ) - isempty(intersect(attached_uuids, contributing_uuids)) && continue - end - if _sc_service_claims_outage(m, outage, outage_uuid, sel) - m.outages[outage_uuid] = per_type - covered = true - end - end - return covered -end - -function _warn_unmatched_user_service_outages( - sc_service_models::Vector{ServiceModel}, - selection::Dict{Tuple{DataType, String}, Set{Base.UUID}}, -) - for m in sc_service_models - key = (get_component_type(m), get_service_name(m)) - sel = selection[key] - isempty(sel) && continue - for uuid in sel - haskey(m.outages, uuid) && continue - D = get_component_type(m) - @warn "Outage $(uuid) listed on ServiceModel{$D, \ - $(get_formulation(m))} (service_name=$(get_service_name(m))) \ - was not found among the system's outage supplemental \ - attributes — it will not contribute any post-contingency \ - constraints." _group = LOG_GROUP_MODELS_VALIDATION + for service in PSY.get_components(PSY.Service, sys) + attached_outages = PSY.get_supplemental_attributes(PSY.Outage, service) + isempty(attached_outages) && continue + key = (typeof(service), PSY.get_name(service)) + key in template_sc_service_keys && continue + for outage in attached_outages + @warn "Outage $(IS.get_uuid(outage)) is attached to service \ + $(PSY.get_name(service)) ($(typeof(service))) but the \ + template does not include a security-constrained \ + ServiceModel for it; the outage will not contribute any \ + post-contingency reserve constraints." _group = + LOG_GROUP_MODELS_VALIDATION end end return diff --git a/src/services_models/static_injection_security_constrained_models.jl b/src/services_models/static_injection_security_constrained_models.jl index 43c15b81fd..a408103334 100644 --- a/src/services_models/static_injection_security_constrained_models.jl +++ b/src/services_models/static_injection_security_constrained_models.jl @@ -101,18 +101,18 @@ end """ Resolve the outages claimed by `service_model.outages` to the `PSY.Outage` -supplemental attribute objects attached to generators in `sys`. Returned -vector is sorted by UUID for deterministic axes. +supplemental attribute objects attached to the reserve service in `sys`. +Returned vector is sorted by UUID for deterministic axes. The element type is `PSY.Outage` (rather than `PSY.UnplannedOutage`) so that -the `"include_planned_outages"` opt-in supported by `_assign_outage_to_sc_service_models!` -in `template_validation.jl` can flow through without a `MethodError` when a -`PSY.PlannedOutage` is claimed. +the `"include_planned_outages"` opt-in honored by +`_build_service_model_outages!` in `template_validation.jl` can flow through +without a `MethodError` when a `PSY.PlannedOutage` is claimed. This is the service-side counterpart to iterating `get_outages(device_model)` -on the AC-branch side: outages are attached to the *outaged generator*, not -to the reserve service, so resolution requires a UUID lookup against the -system. Callers use the resolved objects to query +on the AC-branch side: outages are attached to the reserve service (and, +typically, also to the outaged generator), and resolution requires a UUID +lookup against the system. Callers use the resolved objects to query `PSY.get_associated_components(sys, outage; component_type = PSY.Generator)` and pin the outaged generator's deployment variable to zero. """ diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 198053280b..a59e31e640 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -2464,35 +2464,22 @@ end end end -# Regression test for the per-service outage scoping bug in -# `_assign_outage_to_sc_service_models!`. With two SC reserve services -# (Reserve1_1 in Area1, Reserve1_2 in Area2) and a generator outage attached -# to an Area1 contributor only, the outage must be assigned to Reserve1_1's -# ServiceModel and NOT to Reserve1_2's. -@testset "SC reserve outage auto-discovery is scoped per ServiceModel" begin +# Regression test for per-service outage scoping under the +# attachment-as-the-rule contract: a security-constrained reserve service +# responds to exactly the outages attached to it via +# `add_supplemental_attribute!(sys, service, outage)`. Generator attachment +# is required for the post-contingency build (so the outaged generator can +# be pinned to zero deployment), but it is the *service* attachment that +# selects which `ServiceModel` claims the outage. Membership in the +# service's contributing-devices set is irrelevant to the selection. +@testset "SC reserve outage attachment scopes responding services" begin sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) transform_single_time_series!(sys, Hour(24), Hour(1)) - # Restrict each VariableReserve's contributing devices to its own area so - # the per-service scoping has a meaningful intersection to test. reserve1 = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_1") - reserve2 = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_2") - for g in get_components( - g -> get_name(get_area(get_bus(g))) == "Area2", - ThermalStandard, - sys, - ) - PSY.has_service(g, reserve1) && PSY.remove_service!(g, reserve1) - end - for g in get_components( - g -> get_name(get_area(get_bus(g))) == "Area1", - ThermalStandard, - sys, - ) - PSY.has_service(g, reserve2) && PSY.remove_service!(g, reserve2) - end - # Attach an UnplannedOutage to a single Area1 generator (Alta_1). + # Attach an UnplannedOutage to a single Area1 generator and to the + # reserve that should respond. Reserve1_2 is intentionally NOT attached. alta1 = get_component(ThermalStandard, sys, "Alta_1") transition_data = GeometricDistributionForcedOutage(; mean_time_to_recovery = 10, @@ -2500,6 +2487,7 @@ end monitored_components = collect(get_components(ACTransmission, sys)), ) add_supplemental_attribute!(sys, alta1, transition_data) + add_supplemental_attribute!(sys, reserve1, transition_data) outage_uuid = IS.get_uuid(transition_data) template = get_thermal_dispatch_template_network( @@ -2518,6 +2506,7 @@ end "Reserve1_2", )) + # --- Unit-level: attachment scoping populates only the responding ServiceModel --- PSI._build_service_model_outages!(template, sys) services = PSI.get_service_models(template) @@ -2525,12 +2514,41 @@ end sm2 = services[("Reserve1_2", Symbol(VariableReserve{ReserveUp}))] @test haskey(sm1.outages, outage_uuid) @test !haskey(sm2.outages, outage_uuid) + + # --- Build-level: post-contingency constraints fire only on Reserve1_1 --- + ps_model = + DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + container = PSI.get_optimization_container(ps_model) + @test PSI.has_container_key( + container, + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ) + @test !PSI.has_container_key( + container, + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_2", + ) + cons_resp = PSI.get_constraint( + container, + PSI.ConstraintKey( + PostContingencyGenerationBalanceConstraint, + PSY.VariableReserve{ReserveUp}, + "Reserve1_1", + ), + ) + @test size(cons_resp) == (1, 24) end -# Regression test for the single-reserve auto-discovery path: an outage -# attached to a generator that contributes to the only SC reserve service -# must end up in its ServiceModel.outages dict. -@testset "SC reserve outage auto-discovery covers single-reserve case" begin +# Regression test for the single-reserve case: when only one SC reserve +# service is in the template, an outage attached to both the outaged +# generator and the reserve must end up in that ServiceModel.outages dict. +@testset "SC reserve outage attachment covers single-reserve case" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) transition_data = GeometricDistributionForcedOutage(; @@ -2539,7 +2557,9 @@ end monitored_components = collect(get_components(ACTransmission, sys)), ) alta = get_component(ThermalStandard, sys, "Alta") + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") add_supplemental_attribute!(sys, alta, transition_data) + add_supplemental_attribute!(sys, reserve_up, transition_data) outage_uuid = IS.get_uuid(transition_data) template = get_thermal_dispatch_template_network(