From a0d850579b76c0b147d0ae72a44a2259d88d5804 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 15 Sep 2026 11:10:38 -0400 Subject: [PATCH 01/22] refactor services constructor --- .../security_constrained_injectors.jl | 179 ++++++++++++++++++ src/services_models/services_constructor.jl | 79 ++------ 2 files changed, 199 insertions(+), 59 deletions(-) create mode 100644 src/services_models/security_constrained_injectors.jl diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl new file mode 100644 index 00000000..176e6811 --- /dev/null +++ b/src/services_models/security_constrained_injectors.jl @@ -0,0 +1,179 @@ +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + model::ServiceModel{SR, F}, + devices_template::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractActivePowerModel}, +) 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 = _service_requires_requirement_ts(sys, service, model) + if has_requirement_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) + + outage_ids = _service_outage_ids(model) + if isempty(outage_ids) + @warn "Service $(SR)('$name'): `service_model.outages` is empty; the \ + security-constrained formulation $(F) will not add any \ + post-contingency variables or constraints." + return + end + + attribute_device_map = PSY.get_component_supplemental_attribute_pairs( + PSY.Generator, PSY.Outage, sys, + ) + outaged_gens = _outaged_generators_by_outage_id(outage_ids, attribute_device_map) + add_variables!( + container, + PostContingencyActivePowerReserveDeploymentVariable, + service, + model, + contributing_devices, + F(), + outage_ids, + outaged_gens, + ) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, F}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:PM.AbstractDCPModel}, +) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} + service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, + attribute_device_map = _construct_service_model_prologue!(container, sys, model) + isempty(outage_ids) && return + + _construct_service_post_contingency_balance!( + container, service, contributing_devices, model, network_model, outage_ids, + outaged_gens, attribute_device_map, + ) + relevant_buses = _injection_relevant_buses( + get_network_reduction(network_model), contributing_devices, outaged_gens, + ) + add_to_expression!( + container, PostContingencyNodalActivePowerDeployment, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, relevant_buses, service, model, network_model, + outage_ids, outaged_gens, + ) + add_to_expression!( + container, PostContingencyNodalActivePowerDeployment, ActivePowerVariable, + attribute_device_map, service, model, network_model, + ) + resolved_arcs = _resolve_service_monitored_arcs( + model, get_network_reduction(network_model), + ) + add_post_contingency_flow_expressions!( + container, PostContingencyBranchFlow, service, model, network_model, + resolved_arcs, + ) + add_constraints!( + container, PostContingencyFlowRateConstraint, PostContingencyBranchFlow, + service, model, network_model, resolved_arcs, + ) + + _construct_service_deployment_limits!( + container, contributing_devices, service, model, network_model, + has_requirement_ts, outage_ids, outaged_gens, + ) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, F}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:CopperPlatePowerModel}, +) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} + service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, + attribute_device_map = _construct_service_model_prologue!(container, sys, model) + isempty(outage_ids) && return + + _construct_service_post_contingency_balance!( + container, service, contributing_devices, model, network_model, outage_ids, + outaged_gens, attribute_device_map, + ) + + _construct_service_deployment_limits!( + container, contributing_devices, service, model, network_model, + has_requirement_ts, outage_ids, outaged_gens, + ) + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{SR, F}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:AreaBalancePowerModel}, +) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} + service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, + attribute_device_map = _construct_service_model_prologue!(container, sys, model) + isempty(outage_ids) && return + + # The system-wide `PostContingencyActivePowerBalance` expression built by + # `_construct_service_post_contingency_balance!` is redundant here (the + # per-area balance below recovers it on summation), so it is not called; + # `attribute_device_map` came from the shared prologue scan instead. + add_to_expression!( + container, sys, PostContingencyAreaActivePowerDeployment, + PostContingencyActivePowerReserveDeploymentVariable, + contributing_devices, service, model, network_model, outage_ids, outaged_gens, + ) + add_to_expression!( + container, PostContingencyAreaActivePowerDeployment, ActivePowerVariable, + attribute_device_map, service, model, network_model, + ) + add_post_contingency_area_interchange_flow_deviation_variables!( + container, sys, service, model, network_model, outage_ids, + ) + add_constraints!( + container, sys, PostContingencyCopperPlateBalanceConstraint, + PostContingencyAreaActivePowerDeployment, + service, model, network_model, outage_ids, + ) + resolved_area_interchanges = + _resolve_service_monitored_area_interchanges(sys, model) + add_post_contingency_flow_expressions!( + container, sys, PostContingencyAreaInterchangeFlow, + service, model, network_model, resolved_area_interchanges, + ) + add_constraints!( + container, sys, PostContingencyFlowRateConstraint, + PostContingencyAreaInterchangeFlow, + service, model, network_model, resolved_area_interchanges, + ) + + _construct_service_deployment_limits!( + container, contributing_devices, service, model, network_model, + has_requirement_ts, outage_ids, outaged_gens, + ) + return +end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 3f24f5b2..baa8d84b 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -54,33 +54,6 @@ seed_reserve_range_expressions!( ::DevicesModelContainer, ) = nothing -function seed_reserve_range_expressions!( - container::OptimizationContainer, - sys::PSY.System, - model::ServiceModel{S, <:AbstractReservesFormulation}, - devices_template::DevicesModelContainer, -) where {S <: PSY.AbstractReserve} - for by_device_type in values(get_contributing_devices_map(model)), - device_type in keys(by_device_type) - # Template keys are `nameof(D)`; `Symbol(T)` would qualify the name off Main. - device_model = get(devices_template, nameof(device_type), nothing) - isnothing(device_model) && continue - # Formulations carrying offline capability through `OfflineReserveBandConstraint` - # never wire into the range expression. - if _is_offline_reserve(S) && - !offline_reserve_in_range_ub(get_formulation(device_model)) - continue - end - _seed_range_expression!( - container, - sys, - get_expression_type_for_reserve(ActivePowerReserveVariable, device_type, S), - device_model, - ) - end - return -end - # Function barrier: the caller's loop is uninferable, so the container work happens here # where `T`, `D` and `W` are concrete. function _seed_range_expression!( @@ -99,53 +72,40 @@ function _seed_range_expression!( return end -function construct_services!( +function seed_reserve_range_expressions!( container::OptimizationContainer, sys::PSY.System, - stage::ArgumentConstructStage, - services_template::ServicesModelContainer, + model::ServiceModel{S, <:AbstractReservesFormulation}, devices_template::DevicesModelContainer, - network_model::NetworkModel{<:AbstractNetworkModel}, -) - isempty(services_template) && return - incompatible_device_types = get_incompatible_devices(devices_template) - - deferred_groups = Symbol[] - for (key, service_model) in services_template - if _is_deferred_group_formulation(get_formulation(service_model)) - push!(deferred_groups, key) # constructed last + stage::ArgumentConstructStage, +) where {S <: PSY.AbstractReserve} + for by_device_type in values(get_contributing_devices_map(model)), + device_type in keys(by_device_type) + # Template keys are `nameof(D)`; `Symbol(T)` would qualify the name off Main. + device_model = get(devices_template, nameof(device_type), nothing) + isnothing(device_model) && continue + # Formulations carrying offline capability through `OfflineReserveBandConstraint` + # never wire into the range expression. + if _is_offline_reserve(S) && + !offline_reserve_in_range_ub(get_formulation(device_model)) continue end - isempty(get_contributing_devices_map(service_model)) && continue - seed_reserve_range_expressions!(container, sys, service_model, devices_template) - construct_service!( - container, - sys, - stage, - service_model, - devices_template, - incompatible_device_types, - network_model, - ) - end - for key in deferred_groups - construct_service!( + _seed_range_expression!( container, sys, - stage, - services_template[key], - devices_template, - incompatible_device_types, - network_model, + get_expression_type_for_reserve(ActivePowerReserveVariable, device_type, S), + device_model, ) end return end +seed_reserve_range_expressions!(::OptimizationContainer, ::PSY.System, ::ServiceModel, ::DevicesModelContainer, ::ModelConstructStage) = nothing + function construct_services!( container::OptimizationContainer, sys::PSY.System, - stage::ModelConstructStage, + stage::Union{ArgumentConstructStage, ModelConstructStage}, services_template::ServicesModelContainer, devices_template::DevicesModelContainer, network_model::NetworkModel{<:AbstractNetworkModel}, @@ -160,6 +120,7 @@ function construct_services!( continue end isempty(get_contributing_devices_map(service_model)) && continue + seed_reserve_range_expressions!(container, sys, service_model, devices_template, stage) construct_service!( container, sys, From 17f55ffe3bfb6ab4b8a42946c556999604d1f546 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 17 Sep 2026 10:10:30 -0400 Subject: [PATCH 02/22] port DCP constraints; reorganize validation --- src/operation/template_validation.jl | 88 +- .../security_constrained_injectors.jl | 185 +- ...c_injection_security_constrained_models.jl | 2323 +++++++++++++++++ 3 files changed, 2511 insertions(+), 85 deletions(-) create mode 100644 test/test_static_injection_security_constrained_models.jl diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index c131dc4b..0cc778dd 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -220,7 +220,7 @@ function _interface_branch_problems!( end ################################################################################# -# Security-constrained branch validation and outage population +# Security-constrained branch validation ################################################################################# function _any_component_has_branch_rating_ts( @@ -500,45 +500,9 @@ function _check_security_constrained_network( return end -function _assert_transformer_outages( - transformer::T, - branch_models::IOM.BranchModelContainer, -) where {T <: _TRANSFORMERS} - model = get(branch_models, nameof(T), nothing) - _has_unsupported_phase(transformer, model) && throw( - IS.ConflictingInputsError( - "Phase-shifting transformers and transformers with non-zero angle may not be outages.", - ), - ) - return -end - -_assert_transformer_outages(::PSY.Device, ::IOM.BranchModelContainer) = - nothing - -# Monitored components exist; no controlled transformer outages -function _check_monitored_components( - branch_models::IOM.BranchModelContainer, - sys::PSY.System, -) - for branch_model in values(branch_models) - IOM.supports_outages(IOM.get_formulation(branch_model)) || continue - for outage_id in keys(get_outages(branch_model)) - outage = PSY.get_supplemental_attribute(sys, outage_id) - for uuid in PSY.get_monitored_components(outage) - isnothing(IS.get_component(sys, uuid)) && throw( - IS.ConflictingInputsError( - "Monitored component with UUID $uuid on outage $outage_id is not found in the system.", - ), - ) - end - for component in PSY.get_associated_components(sys, outage) - _assert_transformer_outages(component, branch_models) - end - end - end - return -end +################################################################################# +# Voltage control +################################################################################# # Under ACP a VOLTAGE-control device pins the shared network VoltageMagnitude at its # regulated bus via JuMP.fix(force=true); two devices on one bus silently override @@ -655,6 +619,10 @@ function _check_voltage_regulation_conflicts!( return end +################################################################################# +# Outage validation +################################################################################# + """ Populate `device_model.outages` for every security-constrained (SC) branch device model in the template, in a single pass over the system's outage @@ -883,6 +851,46 @@ function _warn_unmatched_user_outages( return end +function _assert_transformer_outages( + transformer::T, + branch_models::IOM.BranchModelContainer, +) where {T <: _TRANSFORMERS} + model = get(branch_models, nameof(T), nothing) + _has_unsupported_phase(transformer, model) && throw( + IS.ConflictingInputsError( + "Phase-shifting transformers and transformers with non-zero angle may not be outages.", + ), + ) + return +end + +_assert_transformer_outages(::PSY.Device, ::IOM.BranchModelContainer) = + nothing + +# Monitored components exist; no controlled transformer outages +function _check_monitored_components( + branch_models::IOM.BranchModelContainer, + sys::PSY.System, +) + for branch_model in values(branch_models) + IOM.supports_outages(IOM.get_formulation(branch_model)) || continue + for outage_id in keys(get_outages(branch_model)) + outage = PSY.get_supplemental_attribute(sys, outage_id) + for uuid in PSY.get_monitored_components(outage) + isnothing(IS.get_component(sys, uuid)) && throw( + IS.ConflictingInputsError( + "Monitored component with UUID $uuid on outage $outage_id is not found in the system.", + ), + ) + end + for component in PSY.get_associated_components(sys, outage) + _assert_transformer_outages(component, branch_models) + end + end + end + return +end + ################################################################################# # Outage-event discovery and validation (time-series outage events; distinct # from the security-constrained `_build_device_model_outages!` above) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 176e6811..fb946c79 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -1,3 +1,120 @@ +function add_variables!( + container::OptimizationContainer, + ::Type{T}, + service::PSY.AbstractReserve + service_model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, + contributing_devices::Vector{V}, + ::Type{F}, + outage_uuids::Vector{String}, + outaged_gens::Dict{String, Set{PSY.Generator}}, +) where { + T <: AbstractContingencyVariableType, + R <: PSY.AbstractReserve, + V <: PSY.StaticInjection, + F <: AbstractSecurityConstrainedReservesFormulation, +} + service_name = PSY.get_name(service) + device_names = [PSY.get_name(d) for d in contributing_devices] + time_steps = get_time_steps(container) + binary = get_variable_binary(T, R, F) + jump_model = get_jump_model(container) + + # TODO: keep service name axis? + variable = lazy_container_addition!(container, T, R, [PSY.get_name(service)], outage_uuids, device_names, time_steps; sparse = true) + + prefix = "$(T)_$(R)_$(service_name)_" + for uuid in outage_uuids, device in contributing_devices + for (i, device) in enumerate(contributing_devices) + device_name = PSY.get_name(device) + for t in time_steps + v = variable[uuid, device_name, t] = JuMP.@variable(jump_model, base_name = "$(prefix){$(uuid), $(device_name), $(t)}", binary = binary) + if device in outaged_gens[uuid] + JuMP.fix(v, 0.0) + continue + end + ub = get_variable_upper_bound(T, service, device, F) + isnothing(ub) || JuMP.set_upper_bound(v, ub) + lb = get_variable_lower_bound(T, service, device, F) + isnothing(lb) || JuMP.set_lower_bound(v, lb) + if get_warm_start(get_settings(container)) + start = get_variable_warm_start_value(T, device, F) + isnothing(start) || JuMP.set_start_value(v, start) + end + end + end + end + return +end + +_generator_power(container::OptimizationContainer, generator::G, t::Int) where {G <: PSY.Generator} = + get_variable(container, ActivePowerVariable, G)[PSY.get_name(generator), t] + +"""Post-contingency reserve deployment must match outaged generator's +pre-contingency generation.""" +function _constrain_post_contingency_balance!( + container::OptimizationContainer, +) + jump_model = get_jump_model(container) + time_steps = get_time_steps(container) + constraint = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, R, outage_uuids, time_steps) + deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, R) + for uuid in outage_uuids, t in time_steps + # Not registering because balance == 0.0 + balance = JuMP.AffExpr(0.0) + for generator in outaged_gens[uuid] + JuMP.add_to_expression!(balance, -_generator_power(container, generator, t)) + end + for device in contributing_devices + JuMP.add_to_expression!(balance, deployment[uuid, PSY.get_name(device), t]) + end + constraint[uuid, t] = JuMP.@constraint(jump_model, balance == 0) + end + return +end + +function fn() + power = get_variable(container, ActivePowerVariable, G) + deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, R) + limit = generator in outaged_gens ? 0.0 : PSY.get_active_power_limits(generator).max + for t in time_steps + container[name, t] = JuMP.@constraint(jump_model, power[name, t] + deployment[name, t] <= limit) + end + return +end + +"""Contributing devices inject and reserve up to their max, or nothing +if they are outaged.""" +function _constrain_post_contingency_generation!() + jump_model = get_jump_model(container) + time_steps = get_time_steps(container) + for uuid in outage_uuids, device in contributing_devices + limits = PSY.get_active_power_limits(device) + for uuid in outage_uuids, d in contributing_devices, t in time_steps + if device in outaged_gens[uuid] + JuMP.@constraint(jump_model, power + reserve == 0) + else + JuMP.@constraint(jump_model, power + reserve <= limits.max) + end + end + return +end + +############################################################################### +############################################################################### +############################################################################### + +_service_outage_uuids(model::ServiceModel) = string.(sort!(collect(keys(get_outages(model))))) + +function _outaged_generators(uuids::Vector{String}, generator_outage_pairs::Vector{Tuple{PSY.Generator, PSY.Outage}}) + outaged_gens = Dict{String, Set{PSY.Generator}}(uuid => Set{PSY.Generator}() for uuid in uuids) + for (generator, outage) in generator_outage_pairs + uuid = string(IS.get_uuid(outage)) + haskey(outaged_gens, uuid) || continue + push!(outaged_gens[uuid], generator) + end + return outaged_gens +end + function construct_service!( container::OptimizationContainer, sys::PSY.System, @@ -7,47 +124,29 @@ function construct_service!( ::Set{<:DataType}, ::NetworkModel{<:AbstractActivePowerModel}, ) 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 = _service_requires_requirement_ts(sys, service, model) - if has_requirement_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) + services = _services_with_contributors(model, sys) + isempty(services) && return + + ts_services = [s for s in _demand_services(model, services) if _has_ts_requirement(model, s)] + isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) - outage_ids = _service_outage_ids(model) - if isempty(outage_ids) + outage_uuids = _service_outage_uuids(model) + if isempty(outage_uuids) @warn "Service $(SR)('$name'): `service_model.outages` is empty; the \ security-constrained formulation $(F) will not add any \ post-contingency variables or constraints." return end + generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) + outaged_gens = _outaged_generators(outage_uuids, generator_outage_pairs) - attribute_device_map = PSY.get_component_supplemental_attribute_pairs( - PSY.Generator, PSY.Outage, sys, - ) - outaged_gens = _outaged_generators_by_outage_id(outage_ids, attribute_device_map) - add_variables!( - container, - PostContingencyActivePowerReserveDeploymentVariable, - service, - model, - contributing_devices, - F(), - outage_ids, - outaged_gens, - ) + for service in services + contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + add_variables!(container, PostContingencyActivePowerReserveDeploymentVariable, service, model, contributing_devices, F, outage_uuids, outaged_gens) + add_service_variables!(container, ActivePowerReserveVariable, service, contributing_devices, F) + add_to_expression!(container, ActivePowerReserveVariable, service, model, devices_template) + add_feedforward_arguments!(container, model, service) + end return end @@ -58,10 +157,10 @@ function construct_service!( model::ServiceModel{SR, F}, ::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, - network_model::NetworkModel{<:PM.AbstractDCPModel}, + network_model::NetworkModel{<:AbstractDCPNetworkModel}, ) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, - attribute_device_map = _construct_service_model_prologue!(container, sys, model) + attribute_device_map = _construct_ervice_model_prologue!(container, sys, model) isempty(outage_ids) && return _construct_service_post_contingency_balance!( @@ -107,16 +206,12 @@ function construct_service!( model::ServiceModel{SR, F}, ::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, - network_model::NetworkModel{<:CopperPlatePowerModel}, + network_model::NetworkModel{<:CopperPlateNetworkModel}, ) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, - attribute_device_map = _construct_service_model_prologue!(container, sys, model) - isempty(outage_ids) && return + services = _services_with_contributors(model, sys) + isempty(services) && return - _construct_service_post_contingency_balance!( - container, service, contributing_devices, model, network_model, outage_ids, - outaged_gens, attribute_device_map, - ) + _constrain_post_contingency_balance!() _construct_service_deployment_limits!( container, contributing_devices, service, model, network_model, @@ -132,7 +227,7 @@ function construct_service!( model::ServiceModel{SR, F}, ::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, - network_model::NetworkModel{<:AreaBalancePowerModel}, + network_model::NetworkModel{<:AreaBalanceNetworkModel}, ) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, attribute_device_map = _construct_service_model_prologue!(container, sys, model) 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 00000000..6b5ddac3 --- /dev/null +++ b/test/test_static_injection_security_constrained_models.jl @@ -0,0 +1,2323 @@ +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_rows = filter(x -> x["name"] == outage_name, required_variables) + 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, outage_rows)[!, "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, + require_positive_outage::Bool = true, +) + variablesdict = read_variables(res) + associated_outages = + collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) + isempty(associated_outages) && + error("service $(PSY.get_name(service)) has no attached outages") + 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)) + # Guard against a vacuous `0 == 0` pass: if the outaged unit never + # dispatches, both sides of the identity are trivially zero and the + # check below verifies nothing. `require_positive_outage = false` + # opts a testset out where zero outaged dispatch is expected. + if require_positive_outage + @test maximum(outage_dict[outage_name]) > tolerance + end + 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 + +function compare_outage_power_and_deployed_reserves( + sys::PSY.System, + res::OptimizationProblemResults, + reserve_names::Vector{String}; + tolerance::Float64 = 1e-3, + require_positive_outage::Bool = true, +) + for reserve_name in reserve_names + reserve = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve; + tolerance = tolerance, + require_positive_outage = require_positive_outage, + ) + end + return nothing +end + +# Shorthand for the `PSI.ConstraintKey(T, PSY.VariableReserve{ReserveUp}, name)` +# pattern that dominates the constraint-key lists below. +function sc_key(constraint_type::DataType, name::String) + return PSI.ConstraintKey(constraint_type, PSY.VariableReserve{ReserveUp}, name) +end + +# Shared tail for the SC reserve deliverability testsets: build, check +# constraint keys, MOI counts, objective function type, solve status/value, +# and (when `reserve_names` is non-empty) that deployed reserves match the +# outaged power. +function run_sc_reserve_case!( + ps_model::DecisionModel, + sys::PSY.System, + constraint_keys::Vector{<:PSI.ConstraintKey}, + moi_counts::Vector{Int}, + has_binaries::Bool, + objfunc::DataType, + obj_value::Float64, + reserve_names::Vector{String}; + obj_tol::Float64 = 10000.0, + require_positive_outage::Bool = true, +) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + psi_constraint_test(ps_model, constraint_keys) + moi_tests(ps_model, moi_counts..., has_binaries) + psi_checkobjfun_test(ps_model, objfunc) + psi_checksolve_test( + ps_model, + [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], + obj_value, + obj_tol, + ) + if isempty(reserve_names) + return nothing + end + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves( + sys, + res, + reserve_names; + require_positive_outage = require_positive_outage, + ) + return nothing +end + +# Shared zone-balance verification for the `AreaBalancePowerModel` testsets: +# the monitored "1_2" tie stays within its flow limits, and each area's +# generation plus load nets to zero against the tie flow. +function verify_zone_balance!( + results::OptimizationProblemResults; + flow_tol::Float64 = 1e-6, +) + interarea_flow = read_variable( + results, + "FlowActivePowerVariable__AreaInterchange"; + table_format = TableFormat.WIDE, + ) + @test all(interarea_flow[!, "1_2"] .<= 150 + flow_tol) + @test all(interarea_flow[!, "1_2"] .>= -150 - flow_tol) + + 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, + ), + ) + return nothing +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] + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + if add_parallel_line + l4 = get_component(Line, sys, "4") + add_equivalent_ac_transmission_with_parallel_circuits!(sys, l4, PSY.Line) + end + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + sc_key(RequirementConstraint, "Reserve1"), + sc_key(RampConstraint, "Reserve1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1", + ), + ] + 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") + component = get_component(ThermalStandard, sys, "Alta") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [360, 0, 600, 432, 48], + false, + GAEVF, + 329000.0, + ["Reserve1"], + ) + 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 + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + monitored_line_names = ["1", "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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + sc_key(RequirementConstraint, "Reserve1"), + sc_key(RampConstraint, "Reserve1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1", + ), + ] + # Counts are smaller than the all-lines baseline `[360, 0, 600, 432, 72]` + # because only the monitored subset contributes + # `PostContingencyFlowRateConstraint` rows per outage step. + 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") + monitored_subset = [get_component(Line, sys, n) for n in monitored_line_names] + component = get_component(ThermalStandard, sys, "Alta") + attach_geometric_outage!( + sys, + component, + [reserve_up]; + monitored_components = monitored_subset, + ) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedRampReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [360, 0, 504, 336, 48], + false, + GAEVF, + 329000.0, + ["Reserve1"], + ) +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] + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + if add_parallel_line + l4 = get_component(Line, sys, "4") + add_equivalent_ac_transmission_with_parallel_circuits!(sys, l4, PSY.Line) + end + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + sc_key(RequirementConstraint, "Reserve1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1", + ), + ] + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + component = get_component(ThermalStandard, sys, "Alta") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [360, 0, 504, 432, 48], + false, + GAEVF, + 329000.0, + ["Reserve1"], + ) + 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 + sys = c_sys5 + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), + sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_ub"), + ] + 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", + ) + component = get_component(ThermalStandard, sys, "Alta") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [240, 0, 504, 408, 72], + false, + GAEVF, + 329000.0, + ["Reserve1"], + ) + 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 + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + l4 = get_component(Line, sys, "4") + add_equivalent_ac_transmission_with_parallel_circuits!( + sys, + l4, + PSY.Line, + PSY.MonitoredLine, + ) + remove_component!(sys, l4) + + component = get_component(ThermalStandard, sys, "Alta") + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(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 +@testset "Test SecurityConstrainedContingencyReserve with different BranchFormulations" begin + for line_formulation in [StaticBranch, StaticBranchUnbounded, StaticBranchBounds] + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + l4 = get_component(Line, sys, "4") + add_equivalent_ac_transmission_with_parallel_circuits!( + sys, + l4, + PSY.Line, + PSY.MonitoredLine, + ) + remove_component!(sys, l4) + + component = get_component(ThermalStandard, sys, "Alta") + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(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 length(flow_rate_cons) == 1 * 5 * 24 + end +end + +@testset "Test if G-n with Contingency reserve deliverability constraints builds when there is a device without set_device_model!()" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + l4 = get_component(Line, sys, "4") + add_equivalent_ac_transmission_with_parallel_circuits!( + sys, + l4, + PSY.Line, + PSY.MonitoredLine, + ) + remove_component!(sys, l4) + + component = get_component(ThermalStandard, sys, "Alta") + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(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 + +@testset "G-n with Ramp reserve deliverability constraints UC allowing 2 reserve products to respond" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve11_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve11_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + sc_key(RequirementConstraint, "Reserve1"), + sc_key(RequirementConstraint, "Reserve11"), + sc_key(RampConstraint, "Reserve1"), + sc_key(RampConstraint, "Reserve11"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve11"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve11", + ), + ] + component = get_component(ThermalStandard, sys, "Alta") + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up2 = get_component(VariableReserve{ReserveUp}, sys, "Reserve11") + attach_geometric_outage!(sys, component, [reserve_up, reserve_up2]) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(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) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [960, 0, 1296, 600, 192], + true, + GAEVF, + 254242.0, + String[], + ) +end + +@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFPowerModel w/wo Reserve Slacks" begin + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key(RampConstraint, "Reserve1_1"), + sc_key(RampConstraint, "Reserve1_2"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + ] + components_outages_names, reserve_names = + (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + for reserve_slack in [false, true] + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + + for (component_name, reserve_name) in + zip(components_outages_names, reserve_names) + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + attach_geometric_outage!(sys, component, [reserve_up]) + 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) + + if reserve_slack + moi_counts = [2040, 0, 1536, 1200, 120] + else + moi_counts = [744, 0, 1536, 1200, 120] + end + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + moi_counts, + false, + GAEVF, + 497000.0, + reserve_names, + ) + 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 + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key(RampConstraint, "Reserve1_1"), + sc_key(RampConstraint, "Reserve1_2"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + ] + # Counts are smaller than the all-lines baseline `[744, 0, 1536, 1200, 120]` + # because each service monitors only two AC lines instead of all 13. + components_outages_names, reserve_names = + (["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)) + + 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] + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + attach_geometric_outage!( + sys, + component, + [reserve_up]; + monitored_components = monitored_subset, + ) + 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) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [744, 0, 1008, 672, 120], + false, + GAEVF, + 497000.0, + reserve_names, + ) +end + +@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, reserve requirement" begin + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + ] + components_outages_names, reserve_names = + (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + for reserve_slack in [false, true] + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + attach_geometric_outage!(sys, component, [reserve_up]) + 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) + + if reserve_slack + moi_counts = [2040, 0, 1344, 1200, 120] + else + moi_counts = [744, 0, 1344, 1200, 120] + end + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + moi_counts, + false, + GAEVF, + 497000.0, + reserve_names, + ) + end +end + +@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, NO reserve requirement" begin + sys = PSB.build_system(PSISystems, "two_area_pjm_DA") + transform_single_time_series!(sys, Hour(24), Hour(1)) + 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"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), + sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_2_ub"), + ] + components_outages_names, reserve_names = + (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) + + 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) + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + attach_geometric_outage!(sys, component, [reserve_up]) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(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) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [504, 0, 1344, 1152, 168], + false, + GAEVF, + 497000.0, + reserve_names, + ) +end + +@testset "G-n with Ramp reserve deliverability constraints with CopperPlatePowerModel" begin + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key(RampConstraint, "Reserve1_1"), + sc_key(RampConstraint, "Reserve1_2"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + ] + 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) + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + attach_geometric_outage!(sys, component, [reserve_up]) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(CopperPlatePowerModel; 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) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [720, 0, 624, 288, 72], + false, + GAEVF, + 497494.48, + reserve_names, + ) +end + +@testset "G-n with Contingency reserve deliverability constraints with CopperPlatePowerModel with Reserve Requirement" begin + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + ] + 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) + component = get_component(ThermalStandard, sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + attach_geometric_outage!(sys, component, [reserve_up]) + end + + template = get_thermal_dispatch_template_network( + NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF(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) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [720, 0, 432, 288, 72], + false, + GAEVF, + 482055.92, + reserve_names, + ) +end + +@testset "G-n with Contingency reserve deliverability constraints with CopperPlatePowerModel with NO Reserve Requirement" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + + constraint_keys = [ + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), + sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_ub")] + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + remove_time_series!( + sys, + Deterministic, + reserve_up, + "requirement", + ) + component = get_component(ThermalStandard, sys, "Alta") + attach_geometric_outage!(sys, component, [reserve_up]) + + template = get_thermal_dispatch_template_network( + NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + + run_sc_reserve_case!( + ps_model, + sys, + constraint_keys, + [240, 0, 216, 120, 72], + false, + GAEVF, + 329000.0, + ["Reserve1"], + ) +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), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key(RampConstraint, "Reserve1_1"), + sc_key(RampConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_2"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + ] + + 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"]) + + monitored_components = vcat( + collect(get_components(ACTransmission, c_sys)), + collect(get_components(AreaInterchange, c_sys)), + ) + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + component = get_component(ThermalStandard, c_sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + attach_geometric_outage!( + c_sys, + component, + [reserve_up]; + monitored_components = monitored_components, + ) + 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, 792, 0, 696, 360, 144, 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.4871638, 1) + + results = OptimizationProblemResults(ps_model) + verify_zone_balance!(results; flow_tol = 1e-6) + + compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) +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), + sc_key(RequirementConstraint, "Reserve1_1"), + sc_key(RequirementConstraint, "Reserve1_2"), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_1", + ), + sc_key( + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + "Reserve1_2", + ), + sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_2"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + ] + + 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"]) + + monitored_components = vcat( + collect(get_components(ACTransmission, c_sys)), + collect(get_components(AreaInterchange, c_sys)), + ) + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + component = get_component(ThermalStandard, c_sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + attach_geometric_outage!( + c_sys, + component, + [reserve_up]; + monitored_components = monitored_components, + ) + 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, 792, 0, 504, 360, 144, 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], 482055.9151334, 1) + + results = OptimizationProblemResults(ps_model) + verify_zone_balance!(results; flow_tol = 1e-6) + + compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) +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), + sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_2_ub"), + sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), + sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_2"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + ] + + 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"]) + + monitored_components = vcat( + collect(get_components(ACTransmission, c_sys)), + collect(get_components(AreaInterchange, c_sys)), + ) + for (component_name, reserve_name) in zip(components_outages_names, reserve_names) + component = get_component(ThermalStandard, c_sys, component_name) + reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + attach_geometric_outage!( + c_sys, + component, + [reserve_up]; + monitored_components = monitored_components, + ) + 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, 552, 0, 504, 312, 192, 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], 482055.7647083302, 1) + + results = OptimizationProblemResults(ps_model) + verify_zone_balance!(results; flow_tol = 0.0) + + compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) +end + +# 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)) + + reserve1 = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_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 = attach_geometric_outage!(sys, alta1, [reserve1]) + 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", + )) + + # --- Unit-level: attachment scoping populates only the responding ServiceModel --- + 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) + + # --- 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, + sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + ) + @test size(cons_resp) == (1, 24) +end + +# 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) + + alta = get_component(ThermalStandard, sys, "Alta") + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + transition_data = attach_geometric_outage!(sys, alta, [reserve_up]) + 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 + +# ---------------------------------------------------------------------------- +# Regression tests for the AreaBalancePowerModel post-contingency +# reformulation (PR #1617 review remediation, task 1): the per-area balance +# no longer references the pre-contingency `ActivePowerBalance` expression, +# and inter-area rebalancing flows through a free +# `PostContingencyAreaInterchangeFlowDeviationVariable` (Δf) on every +# `AreaInterchange`. Prior to the fix, areas without the outaged unit had +# their deployment pinned to zero by construction, so these tests fail on +# unmodified code (either with a missing-variable error, since Δf did not +# exist, or because the below assertions on nonzero cross-area deployment +# do not hold). +# ---------------------------------------------------------------------------- + +# `PostContingencyAreaInterchangeFlowDeviationVariable` is dense over +# (outage_id, area_interchange_name, t), so `read_variable` returns separate +# "name"/"name2" columns (outage, tie) like the reserve deployment variable. +# Filter to one (outage, tie) pair and return the value series sorted by time. +function _sc_flow_deviation_series( + df::DataFrame, + outage_id::String, + monitored_name::String, +) + rows = filter(x -> x["name"] == outage_id && x["name2"] == monitored_name, df) + sorted = sort(rows, :DateTime) + return sorted[!, "value"] +end + +# Sum `PostContingencyActivePowerReserveDeploymentVariable` across +# contributing devices for one outage, sorted by time. Shared by the +# cross-area deployment/Δf identity checks below. +function total_deployment_series(df::DataFrame, outage_id::String) + outage_rows = filter(x -> x["name"] == outage_id, df) + total_by_t = combine(groupby(outage_rows, :DateTime), :value => sum => :dep) + sort!(total_by_t, :DateTime) + return total_by_t.dep +end + +# Adds a second, parallel `AreaInterchange` between Area1 and Area2 of a +# `two_area_pjm_DA`-derived system, named "1_2_b" to distinguish it from the +# original "1_2" tie. Shared by the multi-tie G-1 testsets below, which vary +# only in `available`. +function add_parallel_tie!(sys::PSY.System; available::Bool = true) + area1 = get_component(Area, sys, "Area1") + area2 = get_component(Area, sys, "Area2") + tie = AreaInterchange(; + name = "1_2_b", + available = available, + active_power_flow = 0.0, + from_area = area1, + to_area = area2, + flow_limits = (from_to = 1.5, to_from = 1.5), + ) + add_component!(sys, tie) + return tie +end + +# Shared setup for the AreaBalancePowerModel G-1 testsets below: builds +# `two_area_pjm_DA`, optionally adds the parallel "1_2_b" tie +# (`add_parallel_tie = false` skips it), attaches a `FixedForcedOutage` on +# `outaged_gen_name` to "Reserve1_2" with the caller-supplied monitored +# components (a function of `sys`, since some callers need components created +# inside this setup), and builds the AreaBalancePowerModel template/service +# model/decision model. `set_interchange_model!` registers the +# `AreaInterchange` DeviceModel; callers override it to attach a +# `filter_function` or to omit the DeviceModel entirely. +function _two_area_g1_setup!(; + add_parallel_tie::Bool = false, + tie_available::Bool = true, + monitored::Function, + use_slacks::Bool = false, + outaged_gen_name::String = "Brighton_1", + set_interchange_model!::Function = t -> + set_device_model!(t, AreaInterchange, StaticBranch), +) + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + if add_parallel_tie + add_parallel_tie!(sys; available = tie_available) + end + + outaged_gen = get_component(ThermalStandard, sys, outaged_gen_name) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_2") + attach_fixed_outage!( + sys, + outaged_gen, + [reserve_up]; + monitored_components = monitored(sys), + ) + + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + set_interchange_model!(template) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1_2"; + use_slacks = use_slacks, + )) + + ps_model = + DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) + return ps_model, sys, reserve_up +end + +@testset "G-1 AreaBalancePowerModel: cross-area reserve deployment covers out-of-area outage" begin + # Outage sited in Area1; the only responding service, Reserve1_2, has + # contributing devices exclusively in Area2 (see reserve setup in + # `two_area_pjm_DA`). Deployment must cross the "1_2" tie. + ps_model, sys, reserve_up = _two_area_g1_setup!(; + monitored = s -> vcat( + collect(get_components(ACTransmission, s)), + collect(get_components(AreaInterchange, s)), + ), + ) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + + res = OptimizationProblemResults(ps_model) + variables = read_variables(res) + + # The outaged generator must dispatch normally, not be pinned to zero by + # a stray "areas without the outage get zero deployment" side effect. + gen_power = filter( + x -> x["name"] == "Brighton_1", + variables["ActivePowerVariable__ThermalStandard"], + )[ + !, + "value", + ] + @test maximum(gen_power) > 1.0 + + # Σ Δrsv (Area2 devices) == p_out (Brighton_1), reusing the existing + # outage/deployment comparison helper. + compare_outage_power_and_deployed_reserves(sys, res, reserve_up) + + # The monitored tie's flow deviation carries exactly the deployed amount + # (opposite sign, since Area2 → Area1 support reduces the from(Area1)→ + # to(Area2) flow). + resolved_outages = + collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up)) + @test length(resolved_outages) == 1 + outage_id = string(IS.get_uuid(only(resolved_outages))) + + deployment = + variables["PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__Reserve1_2"] + total_deployment = total_deployment_series(deployment, outage_id) + + flow_dev = + variables["PostContingencyAreaInterchangeFlowDeviationVariable__VariableReserve__ReserveUp__Reserve1_2"] + tie_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") + + @test all(total_deployment .> 0.0) + @test maximum(abs.(total_deployment .+ tie_flow_dev)) < 1e-3 +end + +@testset "G-1 AreaBalancePowerModel: multi-tie area splits response without over-counting" begin + # Add a second, parallel AreaInterchange between Area1 and Area2 so the + # post-contingency response can split across two ties. + ps_model, sys, reserve_up = _two_area_g1_setup!(; + add_parallel_tie = true, + monitored = s -> vcat( + collect(get_components(ACTransmission, s)), + collect(get_components(AreaInterchange, s)), + ), + ) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + + res = OptimizationProblemResults(ps_model) + variables = read_variables(res) + compare_outage_power_and_deployed_reserves(sys, res, reserve_up) + + resolved_outages = + collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up)) + outage_id = string(IS.get_uuid(only(resolved_outages))) + + deployment = + variables["PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__Reserve1_2"] + total_deployment = total_deployment_series(deployment, outage_id) + + flow_dev = + variables["PostContingencyAreaInterchangeFlowDeviationVariable__VariableReserve__ReserveUp__Reserve1_2"] + tie1_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") + tie2_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2_b") + + # Both ties are from(Area1)→to(Area2); the per-area balance requires the + # *sum* of their deviations to carry the response, not each tie + # independently reporting the full amount (the pre-fix over-count). + @test maximum( + abs.(total_deployment .+ tie1_flow_dev .+ tie2_flow_dev), + ) < 1e-3 +end + +@testset "G-1 AreaBalancePowerModel: unmonitored tie has no rate constraint despite dense Δf axis" begin + # Add a second, available parallel tie but monitor only the original + # "1_2" tie. Δf must still span both (it is dense over every tie in the + # AreaInterchange DeviceModel's set), while the rate constraint is + # strictly opt-in and must cover only the monitored one. + ps_model, sys, reserve_up = _two_area_g1_setup!(; + add_parallel_tie = true, + monitored = s -> [get_component(AreaInterchange, s, "1_2")], + ) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + container = PSI.get_optimization_container(ps_model) + flow_dev = PSI.get_variable( + container, + PSI.PostContingencyAreaInterchangeFlowDeviationVariable(), + VariableReserve{ReserveUp}, + "Reserve1_2", + ) + @test Set(axes(flow_dev, 2)) == Set(["1_2", "1_2_b"]) + + for suffix in ("lb", "ub") + # `SparseAxisArray` does not forward `Base.keys` to the container + # axes, so index its `.data` dict directly to get the actual + # `(outage_id, name, t)` tuples that were written. + cons = PSI.get_constraint( + container, + sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_$(suffix)"), + ) + @test Set(k[2] for k in keys(cons.data)) == Set(["1_2"]) + end +end + +@testset "G-1 AreaBalancePowerModel: binding monitored-tie limit uses post-contingency flow slack" begin + ps_model, sys, reserve_up = _two_area_g1_setup!(; + monitored = s -> vcat( + collect(get_components(ACTransmission, s)), + collect(get_components(AreaInterchange, s)), + ), + use_slacks = true, + ) + + # Tighten the "1_2" tie well below the cross-area response so the + # post-contingency flow-rate constraint binds. + tie = get_component(AreaInterchange, sys, "1_2") + set_flow_limits!(tie, (from_to = 0.1, to_from = 0.1)) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + + res = OptimizationProblemResults(ps_model) + variables = read_variables(res) + + # The balance still holds exactly even though the tie's physical limit + # cannot accommodate the full response: Δf is decoupled from the + # flow-rate constraint by the slack. + compare_outage_power_and_deployed_reserves(sys, res, reserve_up) + + slack_lb = + variables["PostContingencyFlowActivePowerSlackLowerBound__VariableReserve__ReserveUp__Reserve1_2"] + @test maximum(slack_lb[!, "value"]) > 1e-3 +end + +@testset "G-1 AreaBalancePowerModel: unavailable AreaInterchange excluded from Δf axis" begin + # Add a second, parallel AreaInterchange between Area1 and Area2, then + # disable it. The original "1_2" tie stays available, so it remains the + # sole path for cross-area response. Monitor only available + # AreaInterchanges: a monitored-but-unavailable tie is a separate error + # path (`_resolve_service_monitored_area_interchanges`), not what this + # test targets. + ps_model, sys, reserve_up = _two_area_g1_setup!(; + add_parallel_tie = true, + tie_available = false, + monitored = s -> vcat( + collect(get_components(ACTransmission, s)), + collect(get_components(PSY.get_available, AreaInterchange, s)), + ), + ) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + container = PSI.get_optimization_container(ps_model) + flow_deviation_var = PSI.get_variable( + container, + PSI.PostContingencyAreaInterchangeFlowDeviationVariable(), + VariableReserve{ReserveUp}, + "Reserve1_2", + ) + interchange_axis = axes(flow_deviation_var, 2) + @test "1_2" in interchange_axis + @test !("1_2_b" in interchange_axis) + + # Matches pre-contingency semantics: the disabled tie is not the sole + # path here (the original "1_2" tie stays available), so the model + # remains solvable and the response still balances exactly. + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves(sys, res, reserve_up) +end + +@testset "G-1 AreaBalancePowerModel: unavailable monitored AreaInterchange rejected at validation" begin + # Monitor every AreaInterchange, including the disabled one: an admissible + # tie must be both available and in the network model's scope. + ps_model, sys, reserve_up = _two_area_g1_setup!(; + add_parallel_tie = true, + tie_available = false, + monitored = s -> collect(get_components(AreaInterchange, s)), + ) + template = PSI.get_template(ps_model) + + # `build!` catches this internally and reports `ModelBuildStatus.FAILED` + # rather than propagating it, so the throw is only observable by calling + # the template-validation step directly (mirrors the `ReserveDown` + # rejection testset below). + @test_throws IS.ConflictingInputsError PSI._build_service_model_outages!( + template, + sys, + ) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.FAILED +end + +# The AreaInterchange `DeviceModel`'s set — not the network model's scope — is +# what Δf must span: a tie excluded by that DeviceModel's `filter_function` +# has no pre-contingency `FlowActivePowerVariable`, so a Δf term on it would +# be an unanchored transfer path in the per-area balance (and the monitored +# post-contingency flow expression would hit a `KeyError`). +# +# Both testsets below build under `AreaBalancePowerModel`, so +# `ignores_branch_filtering(AreaBalancePowerModel) = true` makes +# `_validate_branch_models` log a stray "Branch filtering is ignored for +# network model AreaBalancePowerModel" warning during `build!`. That warning +# is inaccurate for this DeviceModel: filtering IS honored here (Δf's axis +# comes straight from the filtered `FlowActivePowerVariable__AreaInterchange` +# container, asserted below). Do not "fix" these tests by removing +# `filter_function` — the warning is a known false positive in the unrelated +# branch-filtering validation path, not a signal that filtering was ignored. +_exclude_parallel_tie_model!(template::PSI.ProblemTemplate) = set_device_model!( + template, + DeviceModel( + AreaInterchange, + StaticBranch; + attributes = Dict("filter_function" => x -> get_name(x) != "1_2_b"), + ), +) + +@testset "G-1 AreaBalancePowerModel: filter_function-excluded tie gets no Δf term" begin + ps_model, sys, reserve_up = _two_area_g1_setup!(; + add_parallel_tie = true, + monitored = s -> [get_component(AreaInterchange, s, "1_2")], + set_interchange_model! = _exclude_parallel_tie_model!, + ) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + container = PSI.get_optimization_container(ps_model) + flow_var = + PSI.get_variable(container, PSI.FlowActivePowerVariable(), AreaInterchange) + flow_deviation_var = PSI.get_variable( + container, + PSI.PostContingencyAreaInterchangeFlowDeviationVariable(), + VariableReserve{ReserveUp}, + "Reserve1_2", + ) + # Δf spans exactly the pre-contingency flow variable's ties. + @test Set(axes(flow_var, 1)) == Set(["1_2"]) + @test Set(axes(flow_deviation_var, 2)) == Set(["1_2"]) + + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemResults(ps_model) + variables = read_variables(res) + compare_outage_power_and_deployed_reserves(sys, res, reserve_up) + + outage_id = string( + IS.get_uuid( + only(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up))), + ), + ) + total_deployment = total_deployment_series( + variables["PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__Reserve1_2"], + outage_id, + ) + tie_flow_dev = _sc_flow_deviation_series( + variables["PostContingencyAreaInterchangeFlowDeviationVariable__VariableReserve__ReserveUp__Reserve1_2"], + outage_id, + "1_2", + ) + # Per-area balance residual: the single modeled tie carries the whole + # cross-area response, with no free deviation on the excluded tie. + @test all(total_deployment .> 0.0) + @test maximum(abs.(total_deployment .+ tie_flow_dev)) < 1e-3 +end + +@testset "G-1 AreaBalancePowerModel: monitored tie excluded by filter_function rejected at validation" begin + # Both ties are available and in the network model's scope, so only the + # DeviceModel-inclusion check can reject the monitored "1_2_b". + ps_model, sys, reserve_up = _two_area_g1_setup!(; + add_parallel_tie = true, + monitored = s -> collect(get_components(AreaInterchange, s)), + set_interchange_model! = _exclude_parallel_tie_model!, + ) + template = PSI.get_template(ps_model) + + @test_throws IS.ConflictingInputsError PSI._build_service_model_outages!( + template, + sys, + ) + + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.FAILED +end + +@testset "G-1 AreaBalancePowerModel: no AreaInterchange device model warns and covers in-area" begin + # No AreaInterchange DeviceModel means no modeled tie at all: the per-area + # post-contingency balance reduces to in-area coverage, so the outage is + # sited in Area2 where Reserve1_2's contributing devices live. + ps_model, sys, reserve_up = _two_area_g1_setup!(; + outaged_gen_name = "Solitude_2", + monitored = s -> collect(get_components(Line, s)), + set_interchange_model! = t -> nothing, + ) + @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, + PSI.FlowActivePowerVariable, + AreaInterchange, + ) + # No modeled tie, so no Δf container at all: the per-area balance reduces + # to in-area coverage. + @test !PSI.has_container_key( + container, + PSI.PostContingencyAreaInterchangeFlowDeviationVariable, + VariableReserve{ReserveUp}, + "Reserve1_2", + ) + + # `build!` installs its own logger, so the construct-time warning is only + # observable in the model's log file; it must be emitted exactly once. + log_text = read(PSI.get_log_file(ps_model), String) + @test length( + collect(eachmatch(r"no PSY\.AreaInterchange device model in the", log_text)), + ) == 1 + + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemResults(ps_model) + compare_outage_power_and_deployed_reserves(sys, res, reserve_up) +end + +@testset "SecurityConstrainedContingencyReserve rejects Reserve{ReserveDown}" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + template = get_thermal_dispatch_template_network(PTDFPowerModel) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveDown}, + SecurityConstrainedContingencyReserve, + "Reserve2", + )) + + # `build!` catches this internally and reports `ModelBuildStatus.FAILED` + # rather than propagating it (see `build_impl!`), so the throw is only + # observable by calling the template-validation step directly — mirrors + # the sibling AC-transmission test file's convention for internal + # validation checks. + @test_throws IS.ConflictingInputsError PSI._build_service_model_outages!( + template, + sys, + ) +end + +@testset "SC guard rails: fail-fast on unresolved monitored components" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + template = get_thermal_dispatch_template_network(PTDFPowerModel) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + network_model = PSI.get_network_model(PSI.get_template(ps_model)) + net_reduction_data = network_model.network_reduction + uuid = IS.get_uuid(sys) + + # (a) device-side: an ACTransmission-type monitor absent from the + # network-reduction's branch-arc maps (this system has no MonitoredLine + # components) raises, rather than being silently dropped. + device_model = DeviceModel(MonitoredLine, SecurityConstrainedStaticBranch) + device_model.outages[uuid] = + Dict{DataType, Set{String}}(MonitoredLine => Set(["fake_line"])) + @test_throws ErrorException PSI._resolve_monitored_arcs( + device_model, + net_reduction_data, + ) + + # (a) service-side counterpart. + service_model = ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + ) + service_model.outages[uuid] = + Dict{DataType, Set{String}}(MonitoredLine => Set(["fake_line"])) + @test_throws ErrorException PSI._resolve_service_monitored_arcs( + service_model, + net_reduction_data, + ) + + # (b) AreaInterchange monitors have no branch-arc entry under a PTDF + # network model; dropped with a warning naming the component instead of + # being silently skipped. + service_model_ai = ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + ) + service_model_ai.outages[uuid] = + Dict{DataType, Set{String}}(AreaInterchange => Set(["tie1"])) + @test_logs (:warn, r"AreaInterchange monitor.*tie1.*dropped") PSI._resolve_service_monitored_arcs( + service_model_ai, + net_reduction_data, + ) + + # (c) an AreaInterchange monitor naming a component absent from the + # system raises, rather than being silently skipped. + service_model_missing = ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + ) + service_model_missing.outages[uuid] = + Dict{DataType, Set{String}}(AreaInterchange => Set(["not_a_real_tie"])) + @test_throws ErrorException PSI._resolve_service_monitored_area_interchanges( + sys, + service_model_missing, + ) +end + +# Regression for A-1 (PR #1617 review remediation, simplify pass 2): +# `_monitored_components_by_modeled_type` admits `PSY.AreaInterchange` for the +# service consumer only. On the device-side path an AreaInterchange monitor +# must be reported via `_warn_uncovered_monitored_types` and excluded from +# `device_model.outages`, not silently dropped later inside +# `_resolve_monitored_arcs`. +@testset "Device-side AreaInterchange monitor is reported, not silently dropped" begin + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + transform_single_time_series!(sys, Hour(24), Hour(1)) + + line = first(collect(get_components(Line, sys))) + tie = get_component(AreaInterchange, sys, "1_2") + attach_fixed_outage!( + sys, + line, + PSY.Service[]; + monitored_components = [line, tie], + ) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_device_model!(template, Line, SecurityConstrainedStaticBranch) + set_device_model!(template, AreaInterchange, StaticBranch) + + @test_logs ( + :warn, + r"AreaInterchange.*is not a modeled ACTransmission branch type", + ) PSI._build_device_model_outages!(template, sys) + + device_model = only(PSI._sc_branch_models(template)) + outage_uuid = only(keys(device_model.outages)) + per_type = device_model.outages[outage_uuid] + @test haskey(per_type, Line) + @test !haskey(per_type, AreaInterchange) +end + +@testset "PostContingencyBranchFlow applies PTDF orientation sign for series-reduced monitored branch" begin + # Regression for M4: `RADIAL1-RADIAL2-i_1` is a `:ToFrom`-oriented member + # of a degree-two series reduction (orientation_sign == -1.0) — mirrors + # the `case10_radial_series_reductions` fixture pattern used for the + # equivalent device-side check in + # test_ac_transmission_security_constrained_models.jl. + sys_red = PSB.build_system(PSITestSystems, "case10_radial_series_reductions") + + dummy_forecast = Deterministic( + "max_active_power", + Dict(DateTime("2020-01-01T00:00:00") => ones(24)), + Hour(1), + ) + load = first(collect(get_components(StandardLoad, sys_red))) + add_time_series!(sys_red, load, dummy_forecast) + + gens = collect(get_components(ThermalStandard, sys_red)) + add_service!(sys_red, + VariableReserve{ReserveUp}(; + name = "Reserve1", + available = true, + time_frame = 0.0, + requirement = 0.0, + sustained_time = 3600, + max_output_fraction = 1.0, + max_participation_factor = 0.25, + deployed_fraction = 0.0, + ), + gens) + reserve_up = get_component(VariableReserve{ReserveUp}, sys_red, "Reserve1") + + outaged_gen = gens[1] + monitored_name = "RADIAL1-RADIAL2-i_1" + monitored_line = get_component(Line, sys_red, monitored_name) + outage = attach_fixed_outage!( + sys_red, + outaged_gen, + [reserve_up]; + monitored_components = [monitored_line], + ) + + ptdf_red = PTDF(sys_red; network_reductions = NetworkReduction[DegreeTwoReduction()]) + template = ProblemTemplate( + NetworkModel( + PTDFPowerModel; + PTDF_matrix = ptdf_red, + reduce_degree_two_branches = true, + ), + ) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + set_device_model!(template, StandardLoad, StaticPowerLoad) + set_device_model!(template, Line, StaticBranch) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = DecisionModel(template, sys_red; optimizer = HiGHS_optimizer) + @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + PSI.ModelBuildStatus.BUILT + + container = PSI.get_optimization_container(ps_model) + network_model = PSI.get_network_model(PSI.get_template(ps_model)) + net_reduction_data = network_model.network_reduction + orientation_sign = + PSI.get_ptdf_orientation_sign(net_reduction_data, Line, monitored_name) + @test orientation_sign == -1.0 # guard: fixture must exercise the :ToFrom member + + time_steps = PSI.get_time_steps(container) + outage_id = string(IS.get_uuid(outage)) + actual_container = PSI.get_expression( + container, + PSI.PostContingencyBranchFlow(), + VariableReserve{ReserveUp}, + "Reserve1", + ) + pre_flow = PSI.get_expression(container, PSI.PTDFBranchFlow(), Line) + nodal_deployment = PSI.get_expression( + container, + PSI.PostContingencyNodalActivePowerDeployment(), + VariableReserve{ReserveUp}, + "Reserve1", + ) + # `nodal_deployment`'s bus axis is scoped to injection-relevant buses + # (contributing-device + outaged-generator buses), not the full PTDF bus + # axis — buses outside that subset always carry a zero deployment + # expression, so they're skipped here rather than indexed positionally. + name_to_arc_maps = PNM.get_name_to_arc_maps(net_reduction_data) + arc, _ = name_to_arc_maps[Line][monitored_name] + ptdf_col = PSI.get_PTDF_matrix(network_model)[arc, :] + full_bus_axis = PNM.get_bus_axis(PSI.get_PTDF_matrix(network_model)) + relevant_buses = Set(nodal_deployment.axes[2]) + + for t in time_steps + actual = actual_container[outage_id, monitored_name, t] + correctly_signed = copy(pre_flow[monitored_name, t]) + naively_signed = copy(pre_flow[monitored_name, t]) + for b in eachindex(ptdf_col) + coef = ptdf_col[b] + abs(coef) < PSI.PTDF_ZERO_TOL && continue + bus_number = full_bus_axis[b] + bus_number in relevant_buses || continue + JuMP.add_to_expression!( + correctly_signed, orientation_sign * coef, + nodal_deployment[outage_id, bus_number, t], + ) + JuMP.add_to_expression!( + naively_signed, coef, nodal_deployment[outage_id, bus_number, t], + ) + end + @test aff_exprs_approx_equal(actual, correctly_signed; atol = 1e-8) + @test !aff_exprs_approx_equal(actual, naively_signed; atol = 1e-8) + end +end + +@testset "PostContingencyActivePowerGenerationLimitsConstraint has no lower bound (UC, no-requirement path)" begin + # Regression for M5: for an up-reserve formulation, `p + Δ >= limits.min` + # is redundant when the unit is committed (`p >= limits.min * u`, `Δ >= + # 0`) and actively wrong when it is not (`u == 0` forces `p == 0`, so the + # old lower bound demanded a positive `Δ` from an offline unit just to + # reach `limits.min`). Fixing the offline unit's on-status AND its + # deployment to zero must remain feasible: under the dropped lower bound + # this is `0 >= limits.min`, which is false whenever `limits.min > 0` — + # provably infeasible under the pre-fix constraint. + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + remove_time_series!(sys, Deterministic, reserve_up, "requirement") + + outaged_gen = get_component(ThermalStandard, sys, "Alta") + offline_gen = get_component(ThermalStandard, sys, "Park City") + @test PSY.get_active_power_limits(offline_gen).min > 0.0 # guard: fix-to-zero must bind the old lb + + outage = attach_fixed_outage!( + sys, + outaged_gen, + [reserve_up]; + monitored_components = collect(get_components(ACTransmission, sys)), + ) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_device_model!(template, ThermalStandard, ThermalStandardUnitCommitment) + 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 + + container = PSI.get_optimization_container(ps_model) + on_var = PSI.get_variable(container, OnVariable(), ThermalStandard) + deployment_var = PSI.get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable(), + VariableReserve{ReserveUp}, + "Reserve1", + ) + outage_id = string(IS.get_uuid(outage)) + for t in PSI.get_time_steps(container) + JuMP.fix(on_var["Park City", t], 0.0; force = true) + JuMP.fix(deployment_var[outage_id, "Park City", t], 0.0; force = true) + end + + @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "has_requirement_ts is gated on the mapped requirement series, not any time series" begin + # Regression for M12: a service carrying only an unrelated time series + # (not the name mapped to `RequirementTimeSeriesParameter`) must take the + # no-requirement path — no `RequirementConstraint` should be built. + sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) + reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + remove_time_series!(sys, Deterministic, reserve_up, "requirement") + + unrelated_forecast = Deterministic( + "unrelated_series", + Dict(DateTime("2020-01-01T00:00:00") => ones(24)), + Hour(1), + ) + add_time_series!(sys, reserve_up, unrelated_forecast) + + outaged_gen = get_component(ThermalStandard, sys, "Alta") + attach_fixed_outage!( + sys, + outaged_gen, + [reserve_up]; + monitored_components = collect(get_components(ACTransmission, sys)), + ) + + template = get_thermal_dispatch_template_network( + NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + ) + set_service_model!(template, + ServiceModel( + VariableReserve{ReserveUp}, + SecurityConstrainedContingencyReserve, + "Reserve1", + )) + + ps_model = + DecisionModel(template, sys; optimizer = HiGHS_optimizer, interval = Hour(24)) + @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, + RequirementConstraint, + VariableReserve{ReserveUp}, + ) +end From dc10a333cb99696a016b2178c81b7f8e700b8a9b Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 17 Sep 2026 13:31:09 -0400 Subject: [PATCH 03/22] found elegant solution to type stability of outaged devices --- src/operation/template_validation.jl | 20 ++ .../security_constrained_injectors.jl | 216 +++++------------- 2 files changed, 71 insertions(+), 165 deletions(-) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 0cc778dd..82a7f8eb 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -623,6 +623,26 @@ end # Outage validation ################################################################################# +_validate_reserve_direction(::ServiceModel{<:PSY.Reserve{PSY.ReserveUp}}) = nothing +_validate_reserve_direction(::ServiceModel{<:PSY.Reserve}) = throw(IS.ConflictingInputsError("Security-constrained reserves currently only support Reserve{ReserveUp}.")) + +function _build_service_model_outages!(template::IOM.AbstractProblemTemplate, sys::PSY.System) + sc_models = ServiceModel[m for m in values(get_service_models(template)) if m <: AbstractSecurityConstrainedReservesFormulation] + isempty(sc_models) && return + + modeled_types = Set{DataType}(get_component_types(template)) + uncovered_types = Dict{DataType, Set{Int}}() + + for model in sc_models + _validate_reserve_direction(model) + for (service_name, per_type) in get_contributing_devices_map(model) + isempty(per_type) && continue + #service = PSY.get_component( + end + + end +end + """ Populate `device_model.outages` for every security-constrained (SC) branch device model in the template, in a single pass over the system's outage diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index fb946c79..c92a2155 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -13,87 +13,74 @@ function add_variables!( V <: PSY.StaticInjection, F <: AbstractSecurityConstrainedReservesFormulation, } - service_name = PSY.get_name(service) - device_names = [PSY.get_name(d) for d in contributing_devices] - time_steps = get_time_steps(container) + var = add_variable_container!(container, T, V, String[], Int[], Int[]; sparse = true) binary = get_variable_binary(T, R, F) jump_model = get_jump_model(container) - - # TODO: keep service name axis? - variable = lazy_container_addition!(container, T, R, [PSY.get_name(service)], outage_uuids, device_names, time_steps; sparse = true) - - prefix = "$(T)_$(R)_$(service_name)_" - for uuid in outage_uuids, device in contributing_devices - for (i, device) in enumerate(contributing_devices) - device_name = PSY.get_name(device) - for t in time_steps - v = variable[uuid, device_name, t] = JuMP.@variable(jump_model, base_name = "$(prefix){$(uuid), $(device_name), $(t)}", binary = binary) - if device in outaged_gens[uuid] - JuMP.fix(v, 0.0) - continue - end - ub = get_variable_upper_bound(T, service, device, F) - isnothing(ub) || JuMP.set_upper_bound(v, ub) - lb = get_variable_lower_bound(T, service, device, F) - isnothing(lb) || JuMP.set_lower_bound(v, lb) - if get_warm_start(get_settings(container)) - start = get_variable_warm_start_value(T, device, F) - isnothing(start) || JuMP.set_start_value(v, start) - end - end + for uuid in outage_uuids, device in contributing_devices, t in time_steps + device_name = PSY.get_name(device) + v = var[device_name, uuid, t] = JuMP.@variable(jump_model, base_name = "$(T)_$(R)_$(service_name)_{$(uuid), $(device_name), $(t)}", binary = binary) + if device in outaged_gens[uuid] + JuMP.fix(v, 0.0) + continue + end + # TODO Check about this trait... I don't see where it's defined + ub = get_variable_upper_bound(T, service, device, F) + isnothing(ub) || JuMP.set_upper_bound(v, ub) + lb = get_variable_lower_bound(T, service, device, F) + isnothing(lb) || JuMP.set_lower_bound(v, lb) + if get_warm_start(get_settings(container)) + start = get_variable_warm_start_value(T, device, F) + isnothing(start) || JuMP.set_start_value(v, start) end end return end -_generator_power(container::OptimizationContainer, generator::G, t::Int) where {G <: PSY.Generator} = - get_variable(container, ActivePowerVariable, G)[PSY.get_name(generator), t] - """Post-contingency reserve deployment must match outaged generator's pre-contingency generation.""" function _constrain_post_contingency_balance!( container::OptimizationContainer, -) - jump_model = get_jump_model(container) + outage_uuids::Vector{Int}, + outaged_gens::Dict{Int, Set{Tuple{DataType, String}}} + contributing_devices::Vector{D}, +) where {D <: PSY.StaticInjection} time_steps = get_time_steps(container) - constraint = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, R, outage_uuids, time_steps) - deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, R) + cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, D, outage_uuids, time_steps) + deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) + jump_model = get_jump_model(container) for uuid in outage_uuids, t in time_steps - # Not registering because balance == 0.0 balance = JuMP.AffExpr(0.0) - for generator in outaged_gens[uuid] - JuMP.add_to_expression!(balance, -_generator_power(container, generator, t)) + for (gen_type, name) in outaged_gens[uuid] + power = get_variable(container, ActivePowerVariable, gen_type) + JuMP.add_to_expression!(balance, -power[name, t]) end for device in contributing_devices JuMP.add_to_expression!(balance, deployment[uuid, PSY.get_name(device), t]) end - constraint[uuid, t] = JuMP.@constraint(jump_model, balance == 0) - end - return -end - -function fn() - power = get_variable(container, ActivePowerVariable, G) - deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, R) - limit = generator in outaged_gens ? 0.0 : PSY.get_active_power_limits(generator).max - for t in time_steps - container[name, t] = JuMP.@constraint(jump_model, power[name, t] + deployment[name, t] <= limit) + cons[uuid, t] = JuMP.@constraint(jump_model, balance == 0) end return end """Contributing devices inject and reserve up to their max, or nothing if they are outaged.""" -function _constrain_post_contingency_generation!() +function _constrain_post_contingency_generation!( + container::OptimizationContainer, + outage_uuids::Vector{Int}, + outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, + contributing_devices::Vector{D}, +) where {D <: PSY.StaticInjection} jump_model = get_jump_model(container) time_steps = get_time_steps(container) - for uuid in outage_uuids, device in contributing_devices - limits = PSY.get_active_power_limits(device) - for uuid in outage_uuids, d in contributing_devices, t in time_steps - if device in outaged_gens[uuid] - JuMP.@constraint(jump_model, power + reserve == 0) - else - JuMP.@constraint(jump_model, power + reserve <= limits.max) + cons = add_constraints_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, R, time_steps) + for device in contributing_devices, uuid in outage_uuids + name = PSY.get_name(device) + power = get_variable(container, ActivePowerVariable, D) + reserve = get_varaible(container, PostContingencyActivePowerReserveDeploymentVariable, D) + limit = (D, name) in outaged_gens[uuid] ? 0.0 : PSY.get_active_power_limits(device).max + # TODO: max deployment fraction? + for t in time_steps + cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) end end return @@ -105,12 +92,12 @@ end _service_outage_uuids(model::ServiceModel) = string.(sort!(collect(keys(get_outages(model))))) -function _outaged_generators(uuids::Vector{String}, generator_outage_pairs::Vector{Tuple{PSY.Generator, PSY.Outage}}) - outaged_gens = Dict{String, Set{PSY.Generator}}(uuid => Set{PSY.Generator}() for uuid in uuids) +function _outaged_generators(sys::PSY.System, uuids::Vector{Int}) + generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) + outaged_gens = Dict{Int, Set{Tuple{DataType, String}}}(uuid => Set{Tuple{DataType, String}}() for uuid in uuids) for (generator, outage) in generator_outage_pairs - uuid = string(IS.get_uuid(outage)) haskey(outaged_gens, uuid) || continue - push!(outaged_gens[uuid], generator) + push!(outaged_gens[uuid], (typeof(generator), PSY.get_name(generator))) end return outaged_gens end @@ -137,12 +124,13 @@ function construct_service!( post-contingency variables or constraints." return end - generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) - outaged_gens = _outaged_generators(outage_uuids, generator_outage_pairs) + outaged_gens = _outaged_generators(sys, outage_uuids) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) - add_variables!(container, PostContingencyActivePowerReserveDeploymentVariable, service, model, contributing_devices, F, outage_uuids, outaged_gens) + per_type = get_contributing_devices(model, PSY.get_name(service)) + for contributing_devices in values(per_type) + add_variables!(container, PostContingencyActivePowerReserveDeploymentVariable, service, model, contributing_devices, F, outage_uuids, outaged_gens) + end add_service_variables!(container, ActivePowerReserveVariable, service, contributing_devices, F) add_to_expression!(container, ActivePowerReserveVariable, service, model, devices_template) add_feedforward_arguments!(container, model, service) @@ -150,55 +138,6 @@ function construct_service!( return end -function construct_service!( - container::OptimizationContainer, - sys::PSY.System, - ::ModelConstructStage, - model::ServiceModel{SR, F}, - ::Dict{Symbol, DeviceModel}, - ::Set{<:DataType}, - network_model::NetworkModel{<:AbstractDCPNetworkModel}, -) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, - attribute_device_map = _construct_ervice_model_prologue!(container, sys, model) - isempty(outage_ids) && return - - _construct_service_post_contingency_balance!( - container, service, contributing_devices, model, network_model, outage_ids, - outaged_gens, attribute_device_map, - ) - relevant_buses = _injection_relevant_buses( - get_network_reduction(network_model), contributing_devices, outaged_gens, - ) - add_to_expression!( - container, PostContingencyNodalActivePowerDeployment, - PostContingencyActivePowerReserveDeploymentVariable, - contributing_devices, relevant_buses, service, model, network_model, - outage_ids, outaged_gens, - ) - add_to_expression!( - container, PostContingencyNodalActivePowerDeployment, ActivePowerVariable, - attribute_device_map, service, model, network_model, - ) - resolved_arcs = _resolve_service_monitored_arcs( - model, get_network_reduction(network_model), - ) - add_post_contingency_flow_expressions!( - container, PostContingencyBranchFlow, service, model, network_model, - resolved_arcs, - ) - add_constraints!( - container, PostContingencyFlowRateConstraint, PostContingencyBranchFlow, - service, model, network_model, resolved_arcs, - ) - - _construct_service_deployment_limits!( - container, contributing_devices, service, model, network_model, - has_requirement_ts, outage_ids, outaged_gens, - ) - return -end - function construct_service!( container::OptimizationContainer, sys::PSY.System, @@ -219,56 +158,3 @@ function construct_service!( ) return end - -function construct_service!( - container::OptimizationContainer, - sys::PSY.System, - ::ModelConstructStage, - model::ServiceModel{SR, F}, - ::Dict{Symbol, DeviceModel}, - ::Set{<:DataType}, - network_model::NetworkModel{<:AreaBalanceNetworkModel}, -) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - service, contributing_devices, has_requirement_ts, outage_ids, outaged_gens, - attribute_device_map = _construct_service_model_prologue!(container, sys, model) - isempty(outage_ids) && return - - # The system-wide `PostContingencyActivePowerBalance` expression built by - # `_construct_service_post_contingency_balance!` is redundant here (the - # per-area balance below recovers it on summation), so it is not called; - # `attribute_device_map` came from the shared prologue scan instead. - add_to_expression!( - container, sys, PostContingencyAreaActivePowerDeployment, - PostContingencyActivePowerReserveDeploymentVariable, - contributing_devices, service, model, network_model, outage_ids, outaged_gens, - ) - add_to_expression!( - container, PostContingencyAreaActivePowerDeployment, ActivePowerVariable, - attribute_device_map, service, model, network_model, - ) - add_post_contingency_area_interchange_flow_deviation_variables!( - container, sys, service, model, network_model, outage_ids, - ) - add_constraints!( - container, sys, PostContingencyCopperPlateBalanceConstraint, - PostContingencyAreaActivePowerDeployment, - service, model, network_model, outage_ids, - ) - resolved_area_interchanges = - _resolve_service_monitored_area_interchanges(sys, model) - add_post_contingency_flow_expressions!( - container, sys, PostContingencyAreaInterchangeFlow, - service, model, network_model, resolved_area_interchanges, - ) - add_constraints!( - container, sys, PostContingencyFlowRateConstraint, - PostContingencyAreaInterchangeFlow, - service, model, network_model, resolved_area_interchanges, - ) - - _construct_service_deployment_limits!( - container, contributing_devices, service, model, network_model, - has_requirement_ts, outage_ids, outaged_gens, - ) - return -end From 9e8dbdd3934ec12f5e38698b4b9892d8c1dfbd69 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 17 Sep 2026 14:19:45 -0400 Subject: [PATCH 04/22] copperplate done --- .../security_constrained_injectors.jl | 84 +++++++++++++------ 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index c92a2155..887794e7 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -5,7 +5,6 @@ function add_variables!( service_model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, contributing_devices::Vector{V}, ::Type{F}, - outage_uuids::Vector{String}, outaged_gens::Dict{String, Set{PSY.Generator}}, ) where { T <: AbstractContingencyVariableType, @@ -40,7 +39,6 @@ end pre-contingency generation.""" function _constrain_post_contingency_balance!( container::OptimizationContainer, - outage_uuids::Vector{Int}, outaged_gens::Dict{Int, Set{Tuple{DataType, String}}} contributing_devices::Vector{D}, ) where {D <: PSY.StaticInjection} @@ -48,7 +46,7 @@ function _constrain_post_contingency_balance!( cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, D, outage_uuids, time_steps) deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) jump_model = get_jump_model(container) - for uuid in outage_uuids, t in time_steps + for uuid in keys(outaged_gens), t in time_steps balance = JuMP.AffExpr(0.0) for (gen_type, name) in outaged_gens[uuid] power = get_variable(container, ActivePowerVariable, gen_type) @@ -66,19 +64,19 @@ end if they are outaged.""" function _constrain_post_contingency_generation!( container::OptimizationContainer, - outage_uuids::Vector{Int}, outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, contributing_devices::Vector{D}, ) where {D <: PSY.StaticInjection} jump_model = get_jump_model(container) time_steps = get_time_steps(container) - cons = add_constraints_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, R, time_steps) - for device in contributing_devices, uuid in outage_uuids + cons = add_constraints_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, D, time_steps) + for device in contributing_devices, uuid in keys(outaged_gens) name = PSY.get_name(device) + (D, name) in outaged_gens[uuid] && continue power = get_variable(container, ActivePowerVariable, D) - reserve = get_varaible(container, PostContingencyActivePowerReserveDeploymentVariable, D) - limit = (D, name) in outaged_gens[uuid] ? 0.0 : PSY.get_active_power_limits(device).max + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) # TODO: max deployment fraction? + limit = PSY.get_max_active_power(device) for t in time_steps cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) end @@ -86,13 +84,39 @@ function _constrain_post_contingency_generation!( return end +"""Contributing devices with a TS-backed pre-contingency reserve limit maintain +that limit post-contingency.""" +function _constrain_post_contingency_reserve!( + container::OptimizationContainer, + outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, + contributing_devices::Vector{D}, +) where {D <: PSY.StaticInjection} + jump_model = get_jump_model(container) + time_steps = get_time_steps(container) + cons = add_constraints_container!(container, PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, D, time_steps) + for device in contributing_devices, uuid in keys(outaged_gens) + name = PSY.get_name(device) + (D, name) in outaged_gens[uuid] && continue + pre_reserve = get_variable(container, ActivePowerReserveVariable, D) + post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) + for t in time_steps + cons[name, uuid, t] = JuMP.@constraint(jump_model, post_reserve[name, t] <= pre_reserve[name, t]) + end + end +end + ############################################################################### ############################################################################### ############################################################################### -_service_outage_uuids(model::ServiceModel) = string.(sort!(collect(keys(get_outages(model))))) - -function _outaged_generators(sys::PSY.System, uuids::Vector{Int}) +function _outaged_generators(sys::PSY.System, model::ServiceModel{R, F}) where {R <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} + uuids = string.(sort!(collect(keys(get_outages(model))))) + if isempty(uuids) + @warn "Service{$(R),$(F)}($(PSY.get_name(model)): `service_model.outages` is empty; the \ + security-constrained formulation will not add any \ + post-contingency variables or constraints." + return + end generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) outaged_gens = Dict{Int, Set{Tuple{DataType, String}}}(uuid => Set{Tuple{DataType, String}}() for uuid in uuids) for (generator, outage) in generator_outage_pairs @@ -102,6 +126,16 @@ function _outaged_generators(sys::PSY.System, uuids::Vector{Int}) return outaged_gens end +_formulation_needs_requirement_ts(::Type{SecurityConstrainedContingencyReserve}) = false +_formulation_needs_requirement_ts(::Type{SecurityConstrainedRampReserve}) = true + +function _service_needs_requirement_ts(sys::PSY.System, service::S, model::ServiceModel{S, F}) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} + _formulation_needs_requirement_ts(F) && return true + ts_names = get_time_series_names(model) + has(ts_names, RequirementTimeSeriesParameter) || return false + return PSY.has_time_series(service, get_deterministic_time_series_type(sys), ts_name[RequirementTimeSeriesParameter]) +end + function construct_service!( container::OptimizationContainer, sys::PSY.System, @@ -117,15 +151,7 @@ function construct_service!( ts_services = [s for s in _demand_services(model, services) if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) - outage_uuids = _service_outage_uuids(model) - if isempty(outage_uuids) - @warn "Service $(SR)('$name'): `service_model.outages` is empty; the \ - security-constrained formulation $(F) will not add any \ - post-contingency variables or constraints." - return - end - outaged_gens = _outaged_generators(sys, outage_uuids) - + outaged_gens = _outaged_generators(sys, model) for service in services per_type = get_contributing_devices(model, PSY.get_name(service)) for contributing_devices in values(per_type) @@ -149,12 +175,18 @@ function construct_service!( ) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} services = _services_with_contributors(model, sys) isempty(services) && return + outaged_gens = _outaged_generators(sys, outage_uuids) - _constrain_post_contingency_balance!() - - _construct_service_deployment_limits!( - container, contributing_devices, service, model, network_model, - has_requirement_ts, outage_ids, outaged_gens, - ) + for service in services + per_type = get_contributing_devices(model, PSY.get_name(service)) + has_requirement = _service_needs_requirement_ts(sys, service, model) + for contributing_devices in values(per_type) + _constrain_post_contingency_balance!(container, outaged_gens, contributing_devices) + _constrain_post_contingency_generation!(container, outaged_gens, contributing_devices) + if has_requirement + _constrain_post_contingency_reserve!(container, outaged_gens, contributing_devices) + end + end + end return end From ff8844c05f7182d392eacc5173663046c7703b4e Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Thu, 17 Sep 2026 17:32:30 -0400 Subject: [PATCH 05/22] partially done ptdf need to rebase --- .../security_constrained_injectors.jl | 98 ++++++++++++++++--- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 887794e7..e9a8a6e5 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -70,14 +70,14 @@ function _constrain_post_contingency_generation!( jump_model = get_jump_model(container) time_steps = get_time_steps(container) cons = add_constraints_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, D, time_steps) - for device in contributing_devices, uuid in keys(outaged_gens) + power = get_variable(container, ActivePowerVariable, D) + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) + for device in contributing_devices name = PSY.get_name(device) (D, name) in outaged_gens[uuid] && continue - power = get_variable(container, ActivePowerVariable, D) - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) # TODO: max deployment fraction? limit = PSY.get_max_active_power(device) - for t in time_steps + for t in time_steps, uuid in keys(outaged_gens) cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) end end @@ -94,17 +94,55 @@ function _constrain_post_contingency_reserve!( jump_model = get_jump_model(container) time_steps = get_time_steps(container) cons = add_constraints_container!(container, PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, D, time_steps) - for device in contributing_devices, uuid in keys(outaged_gens) + pre_reserve = get_variable(container, ActivePowerReserveVariable, D) + post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) + for device in contributing_devices name = PSY.get_name(device) (D, name) in outaged_gens[uuid] && continue - pre_reserve = get_variable(container, ActivePowerReserveVariable, D) - post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for t in time_steps + for uuid in keys(outaged_gens), t in time_steps cons[name, uuid, t] = JuMP.@constraint(jump_model, post_reserve[name, t] <= pre_reserve[name, t]) end end end +function _build_post_contingency_nodal_power!( + container::OptimizationContainer, + outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, + contributing_devices::Vector{D}, +) where {D <: PSY.StaticInjection} + expr = add_expression_container!(container, PostContingencyNodalActivePowerDeployment, D, Int[], Int[], Int[]; sparse = true) + power = get_variable(container, ActivePowerVariable, D) + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) + for device in contributing_devices + name = PSY.get_name(device) + (D, name) in contributing_devices && continue + bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) + for uuid in values(outaged_gens), t in time_steps + ex = expr[uuid, bus, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, power[name, t]) + JuMP.add_to_expression!(ex, reserve[name, t]) + end + end + return +end + +# Go through monitored lines, find what arc they reduced to, build flow on that arc. +# Flow on arc will consider pre-conting. flow and post-conting. nodal power. +function _build_post_contingency_branch_flow!( + container::OptimizationContainer, +) + nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, D) + catalog = get_branch_catalog(network_model) + reduction_name = get_component_to_reduction_name_map(catalog, T)[name] + get_name_to_arc_map(catalog, T)[reduction_name] + + for uuid in values(outaged_gens) + per_type = get_outages(model)[uuid] + + arc = PNM.get_name_to_arc_maps(catalog)[T][name] + end +end + ############################################################################### ############################################################################### ############################################################################### @@ -140,18 +178,20 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ArgumentConstructStage, - model::ServiceModel{SR, F}, + model::ServiceModel{S, F}, devices_template::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, ::NetworkModel{<:AbstractActivePowerModel}, -) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} +) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} services = _services_with_contributors(model, sys) isempty(services) && return + outaged_gens = _outaged_generators(sys, model) + isempty(outaged_gens) && return + ts_services = [s for s in _demand_services(model, services) if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) - outaged_gens = _outaged_generators(sys, model) for service in services per_type = get_contributing_devices(model, PSY.get_name(service)) for contributing_devices in values(per_type) @@ -168,18 +208,48 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ModelConstructStage, - model::ServiceModel{SR, F}, + model::ServiceModel{S, F}, ::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, network_model::NetworkModel{<:CopperPlateNetworkModel}, -) where {SR <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} +) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} services = _services_with_contributors(model, sys) isempty(services) && return - outaged_gens = _outaged_generators(sys, outage_uuids) + outaged_gens = _outaged_generators(sys, model) + isempty(outaged_gens) && return for service in services per_type = get_contributing_devices(model, PSY.get_name(service)) has_requirement = _service_needs_requirement_ts(sys, service, model) + for contributing_devices in values(per_type) + _constrain_post_contingency_balance!(container, outaged_gens, contributing_devices) + _constrain_post_contingency_generation!(container, outaged_gens, contributing_devices) + if has_requirement + # TODO: Both are needed? Cause what if pre-conting. is too loose? + # TODO: Don't I have to enforce the pre-conting. level too? + _constrain_post_contingency_reserve!(container, outaged_gens, contributing_devices) + end + end + end + return +end + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + model::ServiceModel{S, F}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, +) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} + services = _services_with_contributors(model, sys) + isempty(services) && return + outaged_gens = _outaged_generators(sys, model) + isempty(services) && return + + for service in services + per_type = get_contributing_devices(model, PSY.get_name(service)) for contributing_devices in values(per_type) _constrain_post_contingency_balance!(container, outaged_gens, contributing_devices) _constrain_post_contingency_generation!(container, outaged_gens, contributing_devices) From 66ab512ee957dc852c76f4e85db516efbc06e777 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 18 Sep 2026 14:33:04 -0400 Subject: [PATCH 06/22] ptdf flow tentativley implemented --- .../security_constrained_injectors.jl | 80 ++++++++++++++----- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index e9a8a6e5..ea89bd5f 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -46,9 +46,9 @@ function _constrain_post_contingency_balance!( cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, D, outage_uuids, time_steps) deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) jump_model = get_jump_model(container) - for uuid in keys(outaged_gens), t in time_steps + for (uuid, gens) in outaged_gens, t in time_steps balance = JuMP.AffExpr(0.0) - for (gen_type, name) in outaged_gens[uuid] + for (gen_type, name) in gens power = get_variable(container, ActivePowerVariable, gen_type) JuMP.add_to_expression!(balance, -power[name, t]) end @@ -72,12 +72,12 @@ function _constrain_post_contingency_generation!( cons = add_constraints_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, D, time_steps) power = get_variable(container, ActivePowerVariable, D) reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for device in contributing_devices + for device in contributing_devices, (uuid, gens) in outaged_gens name = PSY.get_name(device) - (D, name) in outaged_gens[uuid] && continue + (D, name) in gens && continue # TODO: max deployment fraction? limit = PSY.get_max_active_power(device) - for t in time_steps, uuid in keys(outaged_gens) + for t in time_steps cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) end end @@ -96,26 +96,27 @@ function _constrain_post_contingency_reserve!( cons = add_constraints_container!(container, PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, D, time_steps) pre_reserve = get_variable(container, ActivePowerReserveVariable, D) post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for device in contributing_devices + for device in contributing_devices, (uuid, gens) in outaged_gens name = PSY.get_name(device) - (D, name) in outaged_gens[uuid] && continue - for uuid in keys(outaged_gens), t in time_steps + (D, name) in gens && continue + for t in time_steps cons[name, uuid, t] = JuMP.@constraint(jump_model, post_reserve[name, t] <= pre_reserve[name, t]) end end end +# TODO: What about outaged gens? Subtract their power on the node? function _build_post_contingency_nodal_power!( container::OptimizationContainer, outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, contributing_devices::Vector{D}, -) where {D <: PSY.StaticInjection} - expr = add_expression_container!(container, PostContingencyNodalActivePowerDeployment, D, Int[], Int[], Int[]; sparse = true) +) where {R <: PSY.AbstractReserve, D <: PSY.StaticInjection} + expr = lazy_container_addition!(container, PostContingencyNodalActivePowerDeployment, PSY.Outage, Int[], Int[], Int[]; sparse = true) power = get_variable(container, ActivePowerVariable, D) reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for device in contributing_devices + for device in contributing_devices, (uuid, gens) in outaged_gens name = PSY.get_name(device) - (D, name) in contributing_devices && continue + (D, name) in gens && continue bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) for uuid in values(outaged_gens), t in time_steps ex = expr[uuid, bus, t] = JuMP.AffExpr(0.0) @@ -128,19 +129,48 @@ end # Go through monitored lines, find what arc they reduced to, build flow on that arc. # Flow on arc will consider pre-conting. flow and post-conting. nodal power. -function _build_post_contingency_branch_flow!( +function _build_post_contingency_flow!( container::OptimizationContainer, -) - nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, D) + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, + monitored_lines::Dict{M, Vector{Int}}, +) where {R <: PSY.AbstractReserve, M <: PSY.ACTransmission} catalog = get_branch_catalog(network_model) - reduction_name = get_component_to_reduction_name_map(catalog, T)[name] - get_name_to_arc_map(catalog, T)[reduction_name] + reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, M) + name_to_arc_map = PNM.get_name_to_arc_map(catalog, M) + + expr = add_expression_container!(container, PostContingencyBranchFlow, M, String[], Int[], Int[]; sparse = true) + nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.Outage) + # TODO is pre_flow needed when nodal already considers active power? + pre_flow = get_expression(container, PTDFBranchFlow, M) + + ptdf = get_PTDF_matrix(network_model) + bus_axis = PNM.get_bus_axis(ptdf) - for uuid in values(outaged_gens) - per_type = get_outages(model)[uuid] + dfs = Dict{String, Vector{Float64}}() + for line in monitored_lines + name = PSY.get_name(line) + arc = name_to_arc_map[reduction_name_map[name]] + ptdf_col = ptdf[arc, :] + sign = get_ptdf_orientation_sign(catalog, M, name) + for bus in buses + # TODO: am I indexing the bus correctly? + df = sign * ptdf_col[bus_axis[bus]] + abs(df) < PTDF_ZERO_TOL && continue + push!(dfs[name], df) + end + end - arc = PNM.get_name_to_arc_maps(catalog)[T][name] + for (line, uuids) in monitored_lines + name = PSY.get_name(line) + for uuid in uuids, t in time_steps + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, pre_flow) + for bus in buses + JuMP.add_to_expression!(ex, dfs[name][bus], nodal[uuid, bus, t]) + end + end end + return end ############################################################################### @@ -248,6 +278,14 @@ function construct_service!( outaged_gens = _outaged_generators(sys, model) isempty(services) && return + # Build PC nodal power before building and constraining PC flows + for service in services + per_type = get_contributing_devices(model, PSY.get_name(services)) + for contributing_devices in values(per_type) + _build_post_contingency_nodal_power(model, outaged_gens, service, contributing_devices) + end + end + for service in services per_type = get_contributing_devices(model, PSY.get_name(service)) for contributing_devices in values(per_type) @@ -256,6 +294,8 @@ function construct_service!( if has_requirement _constrain_post_contingency_reserve!(container, outaged_gens, contributing_devices) end + + _constrain_post_contingency_flow!(container, outaged_gens, contributing_devices) end end return From b54cd310807bd70c2e32c9c050c86eebb7f36d23 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Fri, 18 Sep 2026 18:35:25 -0400 Subject: [PATCH 07/22] much of this needs to happen aggregated across service models --- .../security_constrained_injectors.jl | 173 ++++++++++-------- src/services_models/services_constructor.jl | 73 ++++++-- 2 files changed, 156 insertions(+), 90 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index ea89bd5f..0c97fc4a 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -1,108 +1,125 @@ -function add_variables!( +const _PER_TYPE = Dict{DataType, Set{String}} +const _OUTAGE_MAP = Dict{Int, _PER_TYPE} + +# One outage may be tied to several reserves with overlapping contributing devices. So aggregate +# those devices across all modeled reserves +# TODO: Need to do only security constrained ones +function _post_contingency_devices(sys::PSY.System, services_template::ServicesModelContainer) + uuids = Set{Int}() + contributing_devices = _OUTAGE_MAP() + for model in values(services_template) + for uuid in keys(get_outages(model)), device in get_contributing_devices(model) + per_type = get!(contributing_devices, uuid, _PER_TYPE()) + c = get!(per_type, typeof(device), Set{String}()) + push!(c, PSY.get_name(device)) + push!(uuids, uuid) + end + end + outaged_generators = _OUTAGE_MAP(uuid => Dict{DataType, Set{String}}() for uuid in uuids) + for (generator, outage) in PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) + uuid = IS.get_uuid(outage) + haskey(outaged_generators, uuid) || continue + c = get!(outaged_generators[uuid], typeof(generator), Set{String}()) + push!(c, PSY.get_name(generator)) + end + return contributing_devices, outaged_generators +end + +function _create_post_contingency_reserve_variables!( container::OptimizationContainer, - ::Type{T}, - service::PSY.AbstractReserve - service_model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, - contributing_devices::Vector{V}, - ::Type{F}, - outaged_gens::Dict{String, Set{PSY.Generator}}, -) where { - T <: AbstractContingencyVariableType, - R <: PSY.AbstractReserve, - V <: PSY.StaticInjection, - F <: AbstractSecurityConstrainedReservesFormulation, -} - var = add_variable_container!(container, T, V, String[], Int[], Int[]; sparse = true) - binary = get_variable_binary(T, R, F) + contributing_devices::_OUTAGE_MAP, + outaged_gens::_OUTAGE_MAP, +) jump_model = get_jump_model(container) - for uuid in outage_uuids, device in contributing_devices, t in time_steps - device_name = PSY.get_name(device) - v = var[device_name, uuid, t] = JuMP.@variable(jump_model, base_name = "$(T)_$(R)_$(service_name)_{$(uuid), $(device_name), $(t)}", binary = binary) - if device in outaged_gens[uuid] - JuMP.fix(v, 0.0) - continue - end - # TODO Check about this trait... I don't see where it's defined - ub = get_variable_upper_bound(T, service, device, F) - isnothing(ub) || JuMP.set_upper_bound(v, ub) - lb = get_variable_lower_bound(T, service, device, F) - isnothing(lb) || JuMP.set_lower_bound(v, lb) - if get_warm_start(get_settings(container)) - start = get_variable_warm_start_value(T, device, F) - isnothing(start) || JuMP.set_start_value(v, start) + for (uuid, per_type) in contributing_devices + for (device_type, names) in per_device + var = lazy_add_container!(container, PostContingencyActivePowerReserveDeploymentVariable, device_type, String[], Int[], Int[]; sparse = true) + for name in names + (device_type, name) in outaged_generators[uuid] && continue + for t in time_steps + v = var[name, uuid, t] = JuMP.@variable(jump_model, base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(device_type)_{$(name), $(uuid), $(t)}") + # TODO bounds? start? + end + end end end return end -"""Post-contingency reserve deployment must match outaged generator's -pre-contingency generation.""" function _constrain_post_contingency_balance!( container::OptimizationContainer, - outaged_gens::Dict{Int, Set{Tuple{DataType, String}}} - contributing_devices::Vector{D}, -) where {D <: PSY.StaticInjection} - time_steps = get_time_steps(container) - cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, D, outage_uuids, time_steps) - deployment = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - jump_model = get_jump_model(container) - for (uuid, gens) in outaged_gens, t in time_steps + contributing_devices::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, +) + cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, PSY.System, uuids, time_steps) + + for uuid in keys(contributing_devices), t in time_steps balance = JuMP.AffExpr(0.0) - for (gen_type, name) in gens - power = get_variable(container, ActivePowerVariable, gen_type) - JuMP.add_to_expression!(balance, -power[name, t]) + for (generator_type, names) in outaged_generators[uuid] + power = get_variable(container, ActivePowerVariable, generator_type) + for name in names + JuMP.add_to_expression!(balance, -power[name, t]) + end end - for device in contributing_devices - JuMP.add_to_expression!(balance, deployment[uuid, PSY.get_name(device), t]) + for (device_type, names) in contributing_devices[uuid] + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + for name in names + JuMP.add_to_expression!(balance, reserve[name, t]) + end end - cons[uuid, t] = JuMP.@constraint(jump_model, balance == 0) + + cons[uuid, t] = JuMP.@constraint(jump_model, balance == 0.0) end return end -"""Contributing devices inject and reserve up to their max, or nothing -if they are outaged.""" function _constrain_post_contingency_generation!( container::OptimizationContainer, - outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, - contributing_devices::Vector{D}, -) where {D <: PSY.StaticInjection} + sys::PSY.System, + outaged_gens::_OUTAGE_MAP, + contributing_devices::_OUTAGE_MAP, +) jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - cons = add_constraints_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, D, time_steps) - power = get_variable(container, ActivePowerVariable, D) - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for device in contributing_devices, (uuid, gens) in outaged_gens - name = PSY.get_name(device) - (D, name) in gens && continue - # TODO: max deployment fraction? - limit = PSY.get_max_active_power(device) - for t in time_steps - cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) + for (uuid, per_type) in contributing_devices + for (device_type, names) in per_type + cons = lazy_add_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, device_type, String[], Int[], Int[]; sparse = true) + power = get_variable(container, ActivePowerVariable, device_type) + reserve = get_variable(contianer, PostContingencyActivePowerReserveDeploymentVariable, device_type) + for name in names + (device_type, name) in outaged_gens[uuid] && continue + limit = PSY.get_max_active_power(PSY.get_component(device_type, sys, name)) + for t in time_steps + cons[uuid, name, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) + end + end end end return end -"""Contributing devices with a TS-backed pre-contingency reserve limit maintain -that limit post-contingency.""" +# Contributing devices with a TS-backed pre-contingency reserve limit maintain that limit +# post-contingency. +# TODO: That's not quite what this is doing... function _constrain_post_contingency_reserve!( container::OptimizationContainer, - outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, - contributing_devices::Vector{D}, -) where {D <: PSY.StaticInjection} + contributing_devices::_OUTAGE_MAP, + outaged_gens::_OUTAGE_MAP, +) jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - cons = add_constraints_container!(container, PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, D, time_steps) - pre_reserve = get_variable(container, ActivePowerReserveVariable, D) - post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for device in contributing_devices, (uuid, gens) in outaged_gens - name = PSY.get_name(device) - (D, name) in gens && continue - for t in time_steps - cons[name, uuid, t] = JuMP.@constraint(jump_model, post_reserve[name, t] <= pre_reserve[name, t]) + for (uuid, per_type) in contributing_devices + for (device_type, names) in per_type + cons = lazy_add_container!(container, PostContingencyActivePowerReserveDeploymentVariable, device_type, String[], Int[], Int[]; sparse = true) + pre_reserve = get_variable(container, ActivePowerReserveVariable, device_type) + post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + for name in names + (device_type, name) in outaged_gens[uuid] && continue + for t in time_steps + cons[name, uuid, t] = JuMP.@constraint(jump_model, post_reserve[name, t] <= pre_reserve[name, t]) + end + end end end + return end # TODO: What about outaged gens? Subtract their power on the node? @@ -178,7 +195,7 @@ end ############################################################################### function _outaged_generators(sys::PSY.System, model::ServiceModel{R, F}) where {R <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - uuids = string.(sort!(collect(keys(get_outages(model))))) + uuids = sort!(collect(keys(get_outages(model)))) if isempty(uuids) @warn "Service{$(R),$(F)}($(PSY.get_name(model)): `service_model.outages` is empty; the \ security-constrained formulation will not add any \ @@ -187,9 +204,11 @@ function _outaged_generators(sys::PSY.System, model::ServiceModel{R, F}) where { end generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) outaged_gens = Dict{Int, Set{Tuple{DataType, String}}}(uuid => Set{Tuple{DataType, String}}() for uuid in uuids) + outaged_gens = Dict{Int, Dict{DataType, Set{String}}}(uuid => Dict{DataType, Set{String}}() for uuid in uuids) for (generator, outage) in generator_outage_pairs haskey(outaged_gens, uuid) || continue push!(outaged_gens[uuid], (typeof(generator), PSY.get_name(generator))) + push!(outaged_gens[uuid][typeof(generator)],) end return outaged_gens end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index baa8d84b..4a788aa7 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -47,15 +47,7 @@ device model's full available component set. Runs once per service model, before any service wires awards in, so services of the same type with contributor sets that do not nest all index an axis that holds their devices. """ -seed_reserve_range_expressions!( - ::OptimizationContainer, - ::PSY.System, - ::ServiceModel, - ::DevicesModelContainer, -) = nothing - -# Function barrier: the caller's loop is uninferable, so the container work happens here -# where `T`, `D` and `W` are concrete. +# Function barrier function _seed_range_expression!( container::OptimizationContainer, sys::PSY.System, @@ -72,12 +64,18 @@ function _seed_range_expression!( return end +seed_reserve_range_expressions!( + ::OptimizationContainer, + ::PSY.System, + ::ServiceModel, + ::DevicesModelContainer, +) = nothing + function seed_reserve_range_expressions!( container::OptimizationContainer, sys::PSY.System, model::ServiceModel{S, <:AbstractReservesFormulation}, devices_template::DevicesModelContainer, - stage::ArgumentConstructStage, ) where {S <: PSY.AbstractReserve} for by_device_type in values(get_contributing_devices_map(model)), device_type in keys(by_device_type) @@ -100,12 +98,56 @@ function seed_reserve_range_expressions!( return end -seed_reserve_range_expressions!(::OptimizationContainer, ::PSY.System, ::ServiceModel, ::DevicesModelContainer, ::ModelConstructStage) = nothing +function construct_services!( + container::OptimizationContainer, + sys::PSY.System, + stage::ArgumentConstructStage, + services_template::ServicesModelContainer, + devices_template::DevicesModelContainer, + network_model::NetworkModel{<:AbstractNetworkModel}, +) + isempty(services_template) && return + incompatible_device_types = get_incompatible_devices(devices_template) + + deferred_groups = Symbol[] + for (key, service_model) in services_template + if _is_deferred_group_formulation(get_formulation(service_model)) + push!(deferred_groups, key) # constructed last + continue + end + isempty(get_contributing_devices_map(service_model)) && continue + seed_reserve_range_expressions!(container, sys, service_model, devices_template) + construct_service!( + container, + sys, + stage, + service_model, + devices_template, + incompatible_device_types, + network_model, + ) + end + for key in deferred_groups + construct_service!( + container, + sys, + stage, + services_template[key], + devices_template, + incompatible_device_types, + network_model, + ) + end + + contributing_devices, outaged_generators = _post_contingency_devices(sys, services_template) + _create_post_contingency_reserve_variables(container, contributing_devices, outaged_generators) + return +end function construct_services!( container::OptimizationContainer, sys::PSY.System, - stage::Union{ArgumentConstructStage, ModelConstructStage}, + stage::ArgumentConstructStage, services_template::ServicesModelContainer, devices_template::DevicesModelContainer, network_model::NetworkModel{<:AbstractNetworkModel}, @@ -120,7 +162,6 @@ function construct_services!( continue end isempty(get_contributing_devices_map(service_model)) && continue - seed_reserve_range_expressions!(container, sys, service_model, devices_template, stage) construct_service!( container, sys, @@ -142,6 +183,12 @@ function construct_services!( network_model, ) end + + + + contributing_devices, outaged_generators = _post_contingency_devices(sys, services_template) + _constrain_post_contingency_balance!(container, contributing_devices, outaged_generators) + _constrain_post_contingency_generation!(container, contributing_devices, outaged_generators) return end From a02a27d15c07f0cb5c0f757efb9e10adcada7301 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 21 Sep 2026 10:53:07 -0400 Subject: [PATCH 08/22] restored ptdf --- .../security_constrained_injectors.jl | 106 +++++++++--------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 0c97fc4a..881d52db 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -4,6 +4,8 @@ const _OUTAGE_MAP = Dict{Int, _PER_TYPE} # One outage may be tied to several reserves with overlapping contributing devices. So aggregate # those devices across all modeled reserves # TODO: Need to do only security constrained ones +# TODO: separate? we aggregate across services for contributing devices because services across different templates may have different contributing devices. as for outaged generators and monitored lines which are just per-outage and keep no info about the services... I think it makes sense to group them and do them at the construct_serviceS level (plural) because the constraints e.g. on the monitored lines need to only be added once per outage, but if we do it at the construct_service (singular) level, then we are setting ourselves up to do it once per service per outage +# TODO: monitored lines? function _post_contingency_devices(sys::PSY.System, services_template::ServicesModelContainer) uuids = Set{Int}() contributing_devices = _OUTAGE_MAP() @@ -18,7 +20,7 @@ function _post_contingency_devices(sys::PSY.System, services_template::ServicesM outaged_generators = _OUTAGE_MAP(uuid => Dict{DataType, Set{String}}() for uuid in uuids) for (generator, outage) in PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) uuid = IS.get_uuid(outage) - haskey(outaged_generators, uuid) || continue + uuid in uuids || continue c = get!(outaged_generators[uuid], typeof(generator), Set{String}()) push!(c, PSY.get_name(generator)) end @@ -84,12 +86,12 @@ function _constrain_post_contingency_generation!( for (device_type, names) in per_type cons = lazy_add_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, device_type, String[], Int[], Int[]; sparse = true) power = get_variable(container, ActivePowerVariable, device_type) - reserve = get_variable(contianer, PostContingencyActivePowerReserveDeploymentVariable, device_type) + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) for name in names (device_type, name) in outaged_gens[uuid] && continue limit = PSY.get_max_active_power(PSY.get_component(device_type, sys, name)) for t in time_steps - cons[uuid, name, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) + cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) end end end @@ -125,65 +127,67 @@ end # TODO: What about outaged gens? Subtract their power on the node? function _build_post_contingency_nodal_power!( container::OptimizationContainer, - outaged_gens::Dict{Int, Set{Tuple{DataType, String}}}, - contributing_devices::Vector{D}, -) where {R <: PSY.AbstractReserve, D <: PSY.StaticInjection} - expr = lazy_container_addition!(container, PostContingencyNodalActivePowerDeployment, PSY.Outage, Int[], Int[], Int[]; sparse = true) - power = get_variable(container, ActivePowerVariable, D) - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, D) - for device in contributing_devices, (uuid, gens) in outaged_gens - name = PSY.get_name(device) - (D, name) in gens && continue - bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(device)) - for uuid in values(outaged_gens), t in time_steps - ex = expr[uuid, bus, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, power[name, t]) - JuMP.add_to_expression!(ex, reserve[name, t]) + sys::PSY.System, + contributing_devices::_OUTAGE_MAP, + outaged_gens::_OUTAGE_MAP, +) + jump_model = get_jump_model(container) + for (uuid, per_type) fin contributing_devices + for (device_type, names) in per_type + expr = lazy_add_container(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus, String[], Int[], Int[]; sparse = true) + power = get_variable(container, ActivePowerReserveVariable, device_type) + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + for name in names + (device_type, name) in outaged_gens[uuid] && continue + bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(PSY.get_component(device_type, sys, name))) + for t in time_steps + ex = expr[bus, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, power[name, t]) + JuMP.add_to_expression!(ex, reserve[name, t]) + end + end end end return end -# Go through monitored lines, find what arc they reduced to, build flow on that arc. -# Flow on arc will consider pre-conting. flow and post-conting. nodal power. -function _build_post_contingency_flow!( - container::OptimizationContainer, - network_model::NetworkModel{<:AbstractPTDFNetworkModel}, - monitored_lines::Dict{M, Vector{Int}}, -) where {R <: PSY.AbstractReserve, M <: PSY.ACTransmission} +function _build_post_contingency_flow!() catalog = get_branch_catalog(network_model) reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, M) name_to_arc_map = PNM.get_name_to_arc_map(catalog, M) - expr = add_expression_container!(container, PostContingencyBranchFlow, M, String[], Int[], Int[]; sparse = true) - nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.Outage) - # TODO is pre_flow needed when nodal already considers active power? - pre_flow = get_expression(container, PTDFBranchFlow, M) - ptdf = get_PTDF_matrix(network_model) bus_axis = PNM.get_bus_axis(ptdf) + nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus) + dfs = Dict{String, Vector{Float64}}() - for line in monitored_lines - name = PSY.get_name(line) - arc = name_to_arc_map[reduction_name_map[name]] - ptdf_col = ptdf[arc, :] - sign = get_ptdf_orientation_sign(catalog, M, name) - for bus in buses - # TODO: am I indexing the bus correctly? - df = sign * ptdf_col[bus_axis[bus]] - abs(df) < PTDF_ZERO_TOL && continue - push!(dfs[name], df) - end - end + for (uuid, per_type) in monitored_lines + for (line_type, names) in per_type + expr = lazy_add_container!(container, PostContingencyBranchFlow, line_type, String[], Int[], Int[]; sparse = true) + # TODO is pre_flow needed when nodal already considers active power? + pre_flow = get_expression(container, PTDFBranchFlow, line_type) + + # Build distribution factor cache (if needed) + for name in names + (line_type, name) in dfs && continue + arc = name_to_arc_map[reduction_name_map[name]] + ptdf_col = ptdf[arc, :] + sign = get_ptdf_orientation_sign(catalog, line_type, name) + for bus in buses + # TODO: am I indexing the bus correctly? + df = sign * ptdf_col[bus_axis[bus]] + abs(df) < PTDF_ZERO_TOL && continue + push!(dfs[name], df) + end + end - for (line, uuids) in monitored_lines - name = PSY.get_name(line) - for uuid in uuids, t in time_steps - ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, pre_flow) - for bus in buses - JuMP.add_to_expression!(ex, dfs[name][bus], nodal[uuid, bus, t]) + for name in names, t in time_steps + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, pre_flow) + for bus in buses + JuMP.add_to_expression!(ex, dfs[name][bus], nodal[bus, uuid, t]) + end end end end @@ -203,12 +207,10 @@ function _outaged_generators(sys::PSY.System, model::ServiceModel{R, F}) where { return end generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) - outaged_gens = Dict{Int, Set{Tuple{DataType, String}}}(uuid => Set{Tuple{DataType, String}}() for uuid in uuids) - outaged_gens = Dict{Int, Dict{DataType, Set{String}}}(uuid => Dict{DataType, Set{String}}() for uuid in uuids) + outaged_gens = _OUTAGE_MAP(uuid => PER_TYPE() for uuid in uuids) for (generator, outage) in generator_outage_pairs haskey(outaged_gens, uuid) || continue - push!(outaged_gens[uuid], (typeof(generator), PSY.get_name(generator))) - push!(outaged_gens[uuid][typeof(generator)],) + push!(outaged_gens[uuid][typeof(generator)], PSY,get_name(generator)) end return outaged_gens end From 5bd5c4257279490b93ade33698406869319009c7 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 21 Sep 2026 14:26:16 -0400 Subject: [PATCH 09/22] added areas and validation-sorta --- .../security_constrained_injectors.jl | 273 ++++++++++++++---- src/services_models/services_constructor.jl | 3 + 2 files changed, 212 insertions(+), 64 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 881d52db..31271903 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -4,12 +4,19 @@ const _OUTAGE_MAP = Dict{Int, _PER_TYPE} # One outage may be tied to several reserves with overlapping contributing devices. So aggregate # those devices across all modeled reserves # TODO: Need to do only security constrained ones -# TODO: separate? we aggregate across services for contributing devices because services across different templates may have different contributing devices. as for outaged generators and monitored lines which are just per-outage and keep no info about the services... I think it makes sense to group them and do them at the construct_serviceS level (plural) because the constraints e.g. on the monitored lines need to only be added once per outage, but if we do it at the construct_service (singular) level, then we are setting ourselves up to do it once per service per outage +# TODO: separate? we aggregate across services for contributing devices because services across different templates may have different contributing devices. as for outaged generators and monitored lines which are just per-outage and keep no info about the services... I think it makes sense to group them and do them at the construct_serviceS level (plural) because the constraints e.g. on the monitored lines need to only be added once per outage, but if we do it at the construct_service (singular) level, then we are setting ourselves up to do it once per service per outage... it's a convenience thing # TODO: monitored lines? -function _post_contingency_devices(sys::PSY.System, services_template::ServicesModelContainer) + +""" +Collect all devices contributing to security-constrained reserves. +We perform this aggregation because an outage may be tied to reserves with different contributing +devices and different service models. +""" +function _security_constrained_contributing_devices(sys::PSY.System, services_template::ServicesModelContainer) uuids = Set{Int}() contributing_devices = _OUTAGE_MAP() for model in values(services_template) + get_formulation(model) <: AbstractSecurityConstrainedReservesFormulation || continue for uuid in keys(get_outages(model)), device in get_contributing_devices(model) per_type = get!(contributing_devices, uuid, _PER_TYPE()) c = get!(per_type, typeof(device), Set{String}()) @@ -17,6 +24,10 @@ function _post_contingency_devices(sys::PSY.System, services_template::ServicesM push!(uuids, uuid) end end + return contributing_devices +end + +function _outaged_generators(sys::PSY.System, uuids::Set{Int}) outaged_generators = _OUTAGE_MAP(uuid => Dict{DataType, Set{String}}() for uuid in uuids) for (generator, outage) in PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) uuid = IS.get_uuid(outage) @@ -24,7 +35,34 @@ function _post_contingency_devices(sys::PSY.System, services_template::ServicesM c = get!(outaged_generators[uuid], typeof(generator), Set{String}()) push!(c, PSY.get_name(generator)) end - return contributing_devices, outaged_generators + return outaged_generators +end + +_validate_reserve_formulation(::ServiceModel{<:PSY.AbstractReserve{PSY.ReserveUp}, AbstractSecurityConstrainedReservesFormulation}) = true +_validate_reserve_formulation(::ServiceModel) = false +_validate_reserve_formulation(::ServiceModel{<:PSY.AbstractReserve, AbstractSecurityConstrainedReservesFormulation}) = throw(IS.ConflictingInputsError("Security-constrained formulations currently only support ReserveUp.") + +# TODO move to template_validation or keep here? +# TODO: split between ptdf and area? +function _monitored_components(sys::PSY.System, services_template::ServicesModelContainer) + components = _OUTAGE_MAP() + for model in service_models + _validate_reserve_formulation(model) || continue + # TODO: validate that its nonempty? + for service in get_available_components(sys, model) + for outage in PSY.get_supplemental_attributes(PSY.Outage, service) + outage_uuid = IS.get_uuid(outage) + per_type = PER_TYPE() + for component_uuid in PSY.get_monitored_components(outage) + component = IS.get_component(sys, component_uuid) + names = get!(per_type, typeof(component), Set{String}) + push!(names, PSY.get_name(component)) + end + components[outage_uuid] = per_type + end + end + end + return components end function _create_post_contingency_reserve_variables!( @@ -48,10 +86,139 @@ function _create_post_contingency_reserve_variables!( return end +function _modeled_interchange_names(container::OptimizationContainer) + if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) + @warn "An AreaBalancePowerModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 + String[] + else + var = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) + collect(axes(var, 1)) + end +end + +function _create_post_contingency_interchange_variables!( + container::OptimizationContainer, + uuids::Vector{Int}, + ::NetworkModel{AreaBalancePowerModel}, +) + names = _modeled_interchange_names(container) + isempty(names) && return + + time_steps = get_time_steps(container) + var = add_variable_container!(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange, names, uuids, time_steps) + for name in names, uuid in uuids, t in time_steps + var[name, uuid, t] = JuMP.@variable(jump_model, base_name = "PostContignencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", start = 0.0) + end +end + +_create_post_contingency_interchange_variables(::OptimizationContainer, ::Vector, ::NetworkModel) = nothing + +_deployment_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment +_deployment_expression(::NetworkModel{AreaBalancePowerModel}) = PostContingencyAreaActivePowerDeployment + +_location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string(PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(component))) +# TODO are there reductions on area models? +_location_key(component, ::NetworkModel{AreaBalancePowerModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) + +# TODO: What about outaged gens? Subtract their power on the node? +function _build_post_contingency_locational_power!( + container::OptimizationContainer, + sys::PSY.System, + contributing_devices::_OUTAGE_MAP, + outaged_gens::_OUTAGE_MAP, + network_model::NetworkModel +) + jump_model = get_jump_model(container) + for (uuid, per_type) in contributing_devices + for (device_type, names) in per_type + expr = lazy_add_container(container, _deployment_expression(network_model), PSY.ACBus, String[], Int[], Int[]; sparse = true) + power = get_variable(container, ActivePowerReserveVariable, device_type) + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + for name in names + (device_type, name) in outaged_gens[uuid] && continue + bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(PSY.get_component(device_type, sys, name))) + key = _location_key(PSY.get_component(device_type, sys, name), network_model) + for t in time_steps + ex = expr[key, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, power[name, t]) + JuMP.add_to_expression!(ex, reserve[name, t]) + end + end + end + end + return +end + +function _build_post_contingency_flow!( + container::OptimizationContainer, + monitored_lines::_OUTAGE_MAP, + ::NetworkModel{<:AbstractPTDFNetworkModel} +) + catalog = get_branch_catalog(network_model) + reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, M) + name_to_arc_map = PNM.get_name_to_arc_map(catalog, M) + + ptdf = get_PTDF_matrix(network_model) + bus_axis = PNM.get_bus_axis(ptdf) + + nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus) + + dfs = Dict{String, Vector{Float64}}() + for (uuid, per_type) in monitored_lines + for (line_type, names) in per_type + expr = lazy_add_container!(container, PostContingencyBranchFlow, line_type, String[], Int[], Int[]; sparse = true) + # TODO is pre_flow needed when nodal already considers active power? + pre_flow = get_expression(container, PTDFBranchFlow, line_type) + + # Build distribution factor cache (if needed) + for name in names + (line_type, name) in dfs && continue + arc = name_to_arc_map[reduction_name_map[name]] + ptdf_col = ptdf[arc, :] + sign = get_ptdf_orientation_sign(catalog, line_type, name) + for bus in buses + # TODO: am I indexing the bus correctly? + df = sign * ptdf_col[bus_axis[bus]] + abs(df) < PTDF_ZERO_TOL && continue + push!(dfs[name], df) + end + end + + for name in names, t in time_steps + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, pre_flow) + for bus in buses + JuMP.add_to_expression!(ex, dfs[name][bus], nodal[bus, uuid, t]) + end + end + end + end + return +end + +function _build_post_contingency_flow!( + container::OptimizationContainer, + monitored_interchanges::_OUTAGE_MAP, + ::NetworkModel{AreaBalancePowerModel}, +) + expr = add_expression_container!(container, PostContingencyAreaInterchangeFlow, PSY.AreaInterchange, String[], Int[], Int[]; sparse = true) + flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) + deviation = get_variable(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange) + for (uuid, per_type) in monitored_interchanges + for name in per_type[PSY.AreaInterchange], t in time_steps + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, flow[name, t]) + JuMP.add_to_expression!(ex, deviation[name, uuid, t]) + end + end + return +end + function _constrain_post_contingency_balance!( container::OptimizationContainer, contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, + ::NetworkModel, ) cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, PSY.System, uuids, time_steps) @@ -75,6 +242,31 @@ function _constrain_post_contingency_balance!( return end +# TODO Why does each area listen to every outage? but not every bus? +function _constrain_post_contingency_balance!( + container::OptimizationContainer, + contributing_devices::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, + ::NetworkModel{AreaBalancePowerModel}, +) + interchanges = Dict{String, Vector{Tuple{Float64, String}}}() + for interchange in PSY.get_components(PSY.AreaInterchange, sys) + name = PYS.get_name(interchange) + name in modeled_interchange_areas || continue + push!(get!(interchanges, PSY.get_name(PSY.get_from_area(interchange)), Vector{Tuple{Float64, String}}()), (-1.0, name)) + push!(get!(interchanges, PSY.get_name(PSY.get_to_area(interchange)), Vector{Tuple{Float64, String}}()), (1.0, name)) + end + deployment = get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) + + for area_name in names + exchange = JuMP.AffExpr(0.0) + for (sign, interchange_name) in interchanges[area_name] + JuMP.add_to_expression!(exchange, sign, deviation[interchange_name, uuid, t]) + end + JuMP.@constraint(jump_model, deployment[area_name, uuid, t] + exchange == 0.0) + end +end + function _constrain_post_contingency_generation!( container::OptimizationContainer, sys::PSY.System, @@ -124,76 +316,29 @@ function _constrain_post_contingency_reserve!( return end -# TODO: What about outaged gens? Subtract their power on the node? -function _build_post_contingency_nodal_power!( +# TODO Why do we support area interchanges here but not on N-1? +# TODO refactor with add_constraints!(PostContingencyFlowRate, ACTransmission) from N-1? +# TODO I think we don't need to check for shared sources because we're creating at the create_services level +function _constrain_post_contingency_flow!( container::OptimizationContainer, - sys::PSY.System, - contributing_devices::_OUTAGE_MAP, - outaged_gens::_OUTAGE_MAP, + monitored_components::_OUTAGE_MAP, + network_model::NetworkModel{Union{AbstractPTDFNetworkModel, AreaBalancePowerModel}}, ) - jump_model = get_jump_model(container) - for (uuid, per_type) fin contributing_devices - for (device_type, names) in per_type - expr = lazy_add_container(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus, String[], Int[], Int[]; sparse = true) - power = get_variable(container, ActivePowerReserveVariable, device_type) - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) - for name in names - (device_type, name) in outaged_gens[uuid] && continue - bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(PSY.get_component(device_type, sys, name))) - for t in time_steps - ex = expr[bus, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, power[name, t]) - JuMP.add_to_expression!(ex, reserve[name, t]) - end - end - end - end - return -end - -function _build_post_contingency_flow!() - catalog = get_branch_catalog(network_model) - reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, M) - name_to_arc_map = PNM.get_name_to_arc_map(catalog, M) - - ptdf = get_PTDF_matrix(network_model) - bus_axis = PNM.get_bus_axis(ptdf) - - nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus) - - dfs = Dict{String, Vector{Float64}}() - for (uuid, per_type) in monitored_lines - for (line_type, names) in per_type - expr = lazy_add_container!(container, PostContingencyBranchFlow, line_type, String[], Int[], Int[]; sparse = true) - # TODO is pre_flow needed when nodal already considers active power? - pre_flow = get_expression(container, PTDFBranchFlow, line_type) - - # Build distribution factor cache (if needed) - for name in names - (line_type, name) in dfs && continue - arc = name_to_arc_map[reduction_name_map[name]] - ptdf_col = ptdf[arc, :] - sign = get_ptdf_orientation_sign(catalog, line_type, name) - for bus in buses - # TODO: am I indexing the bus correctly? - df = sign * ptdf_col[bus_axis[bus]] - abs(df) < PTDF_ZERO_TOL && continue - push!(dfs[name], df) - end - end - + for (uuid, per_type) in monitored_components + for (component_type, names) in per_type + limits = _flow_limits(network_model) + # TODO: slacks? for name in names, t in time_steps - ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, pre_flow) - for bus in buses - JuMP.add_to_expression!(ex, dfs[name][bus], nodal[bus, uuid, t]) - end + cons_ub[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] <= limits.max) + cons_lb[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] >= limits.min) end end end return end +_constrain_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkModel) = nothing + ############################################################################### ############################################################################### ############################################################################### diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 4a788aa7..b2b49a0c 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -141,6 +141,7 @@ function construct_services!( contributing_devices, outaged_generators = _post_contingency_devices(sys, services_template) _create_post_contingency_reserve_variables(container, contributing_devices, outaged_generators) + _create_post_contingency_interchange_variables(container, uuids, network_model) return end @@ -187,8 +188,10 @@ function construct_services!( contributing_devices, outaged_generators = _post_contingency_devices(sys, services_template) + monitored_components = _monitored_components(sys, service_template, network_model) _constrain_post_contingency_balance!(container, contributing_devices, outaged_generators) _constrain_post_contingency_generation!(container, contributing_devices, outaged_generators) + _constrain_post_contingency_flow!(container, monitored_components, network_model) return end From ff61f270e46a2b58b0dc567425652f66c19a047a Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 21 Sep 2026 15:54:02 -0400 Subject: [PATCH 10/22] test file updated; code compiles --- src/PowerOperationsModels.jl | 16 +- src/core/constraints.jl | 21 + src/core/expressions.jl | 19 + src/core/formulations.jl | 32 + src/core/variables.jl | 14 + src/services_models/reserves.jl | 9 + .../security_constrained_injectors.jl | 3 +- src/services_models/services_constructor.jl | 12 +- ...c_injection_security_constrained_models.jl | 721 +++++++++--------- 9 files changed, 495 insertions(+), 352 deletions(-) diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 9089fbab..cfbac3cd 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -677,6 +677,10 @@ export HVDCReactivePowerToVariable export ShiftUpActivePowerVariable export ShiftDownActivePowerVariable +# G-1 Variables +export PostContingencyActivePowerReserveDeploymentVariable +export PostContignencyAreaInterchangeFlowDeviationVariable + ######## Hydro Formulations ######## export HydroDispatchRunOfRiver export HydroCommitmentRunOfRiver @@ -867,7 +871,9 @@ export ShiftUpActivePowerVariableLimitsConstraint export ShiftDownActivePowerVariableLimitsConstraint export RealizedShiftedLoadMinimumBoundConstraint export NonAnticipativityConstraint -export HVDCDCControlConstraint +export PostContingencyGenerationBalanceConstraint +export PostContingencyActivePowerGenerationLimitsConstraint +export PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint ################################################################################# # Exports - Expression Types (defined in core/expressions.jl) @@ -900,6 +906,10 @@ export ComponentReserveDownBalanceExpression export InterfaceTotalFlow export PTDFBranchFlow export BThetaBranchFlow +export PostContingencyNodalActivePowerDeployment +export PostContingencyAreaActivePowerDeployment +export PostContingencyBranchFlow +export PostContingencyAreaInterchangeFlow ################################################################################# # Exports - Parameter Types (defined in core/parameters.jl) @@ -1007,6 +1017,10 @@ export VariableMaxInterfaceFlow export ReserveLimitedRegulation export DeviceLimitedRegulation +# G-1 Formulations +export SecurityConstrainedContingency +export SecurityConstrainedRampReserve + ################################################################################# # Exports - Network Formulation Types (defined in core/network_formulations.jl) ################################################################################# diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 49350d10..1f4951f7 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -1274,3 +1274,24 @@ Single award variable per (device, service): the device's merged offer curve pri provision states (documented approximation). """ struct OfflineReserveBandConstraint <: ConstraintType end + +################################################################################# +# G-1 constraints +################################################################################# + +""" +Deployed reserves from devices contributing to security-constrained reserves must match outaged +power. +""" +struct PostContingencyGenerationBalanceConstraint <: ConstraintType end + +""" +Devices contributing to security-constrained reserves cannot reserve more than their personal +limit. +""" +struct PostContingencyActivePowerGenerationLimitsConstraint <: ConstraintType end + +""" +TODO: complete +""" +struct PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint <: ConstraintType end diff --git a/src/core/expressions.jl b/src/core/expressions.jl index bf4c9b37..e546daad 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -124,6 +124,25 @@ Aggregation of reserve variables allocated to the storage subcomponent of a hybr struct StorageReserveBalanceExpression{D, S, Sd} <: ReserveAggregationExpression{D, S, Sd} end +################################################################################# +# G-1 expressions +################################################################################# + +""" +Per-area power considering post-contingency reserve deployment. +""" +struct PostContingencyAreaActivePowerDeployment <: ExpressionType end + +""" +Per-branch flow considering post-contingency reserve deployment. +""" +struct PostContingencyBranchFlow <: ExpressionType end + +""" +Per-interchange flow considering post-contingency reserve deployment. +""" +struct PostContingencyAreaInterchangeFlow <: ExpressionType end + # Method extensions for output writing should_write_resulting_value(::Type{InterfaceTotalFlow}) = true should_write_resulting_value(::Type{PTDFBranchFlow}) = true diff --git a/src/core/formulations.jl b/src/core/formulations.jl index b32b677f..1db1a425 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -821,3 +821,35 @@ unit and a standalone copy produce identical objective coefficients). When regularization slacks. """ struct HybridDispatchWithReserves <: AbstractHybridFormulationWithReserves end + +############################### G-1 Formulations ##################################### + +abstract type AbstractSecurityConstrainedReservesFormulation <: AbstractReservesFormulation end + +""" +Security-constrained contingency reserve formulation: deploys reserves +under each G-1 outage scoped to the reserve `PSY.Service`. The set of +contingencies a service responds to is the `PSY.Outage` supplemental +attributes attached to that service via +`add_supplemental_attribute!(sys, service, outage)`; template validation +mirrors those attachments into `service_model.outages`. Post-contingency +branch-flow constraints are added only for the monitored components +listed on each outage's `monitored_components`. + +A `RequirementTimeSeriesParameter` is optional: if no requirement time +series is configured on the service, the formulation falls back to the +per-(outage, generator) post-contingency active power expression. + +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 diff --git a/src/core/variables.jl b/src/core/variables.jl index cc6104fd..659e285f 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -841,6 +841,20 @@ Reserve allocated to one side of a hybrid system's storage subcomponent. Paramet struct HybridStorageSubcomponentReserveVariable{Sd <: ReserveSide} <: AbstractHybridReserveVariableType end +################################################################################# +# G-1 Variables +################################################################################# + +""" +Reserve deployed by a contributing device on a security-constrained reserve. +""" +struct PostContingencyActivePowerReserveDeploymentVariable <: VariableType end + +""" +Post-contingency power exchange over an area interchange from security-constrained reserves. +""" +struct PostContignencyAreaInterchangeFlowDeviationVariable <: VariableType end + const MULTI_START_VARIABLES = (HotStartVariable, WarmStartVariable, ColdStartVariable) should_write_resulting_value(::Type{PiecewiseLinearCostVariable}) = false diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index 4b74a58f..b65e4ffc 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -148,6 +148,15 @@ function get_default_time_series_names( return Dict{Type{<:TimeSeriesParameter}, String}() end +function get_default_time_series_names( + ::Type{<:PSY.AbstractReserve}, + ::Type{<:AbstractSecurityConstrainedReservesFormulation}, +) + return Dict{Type{<:TimeSeriesParameter}, String}( + RequirementTimeSeriesParameter => "requirement", + ) +end + function get_default_attributes( ::Type{<:PSY.AbstractReserve}, ::Type{<:AbstractReservesFormulation}, diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 31271903..a499b3a4 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -302,7 +302,8 @@ function _constrain_post_contingency_reserve!( jump_model = get_jump_model(container) for (uuid, per_type) in contributing_devices for (device_type, names) in per_type - cons = lazy_add_container!(container, PostContingencyActivePowerReserveDeploymentVariable, device_type, String[], Int[], Int[]; sparse = true) + # TODO: shorter name + cons = lazy_add_container!(container, PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, device_type, String[], Int[], Int[]; sparse = true) pre_reserve = get_variable(container, ActivePowerReserveVariable, device_type) post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) for name in names diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index b2b49a0c..c79efd7a 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -148,7 +148,7 @@ end function construct_services!( container::OptimizationContainer, sys::PSY.System, - stage::ArgumentConstructStage, + stage::ModelConstructStage, services_template::ServicesModelContainer, devices_template::DevicesModelContainer, network_model::NetworkModel{<:AbstractNetworkModel}, @@ -601,11 +601,11 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ArgumentConstructStage, - model::ServiceModel{SR, RampReserve}, + model::ServiceModel{SR, F}, devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {SR <: PSY.Reserve} +) where {SR <: PSY.Reserve, F <: Union{RampReserve, SecurityConstrainedRampReserve}} services = _services_with_contributors(model, sys) isempty(services) && return # Only services carrying a requirement series get the parameter (a curve-only ORDC of the @@ -638,7 +638,7 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ModelConstructStage, - model::ServiceModel{SR, RampReserve}, + model::ServiceModel{SR, Union{RampReserve, SecurityConstrainedRampReserve}}, devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, @@ -1104,3 +1104,7 @@ function construct_service!( add_constraint_dual!(container, sys, model) return end + +# These models are constructed entirely in construct_services! +construct_service!(::OptimizationContainer, ::PSY.System, ::ArgumentConstructStage, ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) = nothing +construct_service!(::OptimizationContainer, ::PSY.System, ::ModelConstructStage, ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) = nothing diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 6b5ddac3..cb861ad9 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -1,3 +1,73 @@ +# Shared skeleton for the SC reserve deliverability test cases: attach a +# `GeometricDistributionForcedOutage` to `device` and every service in +# `services` in one shot, instead of repeating the construct-and-attach block +# per testset. Device-side (line/transformer) callers pass `PSY.Service[]`. +function attach_geometric_outage!( + sys::PSY.System, + device::PSY.Device, + services::Vector{<:PSY.Service}; + monitored_components::Vector{<:PSY.Component} = collect( + PSY.get_components(PSY.ACTransmission, sys), + ), + mean_time_to_recovery::Float64 = 10.0, + outage_transition_probability::Float64 = 0.9999, +) + transition_data = GeometricDistributionForcedOutage(; + mean_time_to_recovery = mean_time_to_recovery, + outage_transition_probability = outage_transition_probability, + monitored_components = monitored_components, + ) + add_supplemental_attribute!(sys, device, transition_data) + for service in services + add_supplemental_attribute!(sys, service, transition_data) + end + return transition_data +end + +# Same as `attach_geometric_outage!` but for the `PSY.FixedForcedOutage` +# fixture used by the deterministic (non-probabilistic) regression tests. +function attach_fixed_outage!( + sys::PSY.System, + device::PSY.Device, + services::Vector{<:PSY.Service}; + monitored_components::Vector{<:PSY.Component}, +) + outage = PSY.FixedForcedOutage(; + outage_status = 1.0, + monitored_components = monitored_components, + ) + add_supplemental_attribute!(sys, device, outage) + for service in services + add_supplemental_attribute!(sys, service, outage) + end + return outage +end + +# Callers compare against a SECOND, independent VirtualMODF/VirtualPTDF. Those +# columns are solved through multithreaded BLAS, whose reduction order is not deterministic, +# so two instances agree only to machine precision (~1e-16), never bit-for-bit. Exact +# `JuMP.isequal_canonical` therefore fails or passes depending on the BLAS thread count of +# the machine. Compare coefficients to `PTDF_ZERO_TOL` instead: a variable absent from one +# side counts as a zero coefficient, so a term sitting on the sparsity threshold cannot flip +# the result, while a genuinely wrong term (sign flip, wrong arc) differs by orders of +# magnitude more and still fails. +function aff_exprs_approx_equal( + actual::JuMP.AffExpr, + expected::JuMP.AffExpr; + atol::Float64 = IOM.PTDF_ZERO_TOL, +) + if !isapprox(actual.constant, expected.constant; atol = atol) + return false + end + for v in union(keys(actual.terms), keys(expected.terms)) + a = get(actual.terms, v, 0.0) + e = get(expected.terms, v, 0.0) + if !isapprox(a, e; atol = atol) + return false + end + end + return true +end function get_outage_total_power_by_step_dict( sys::PSY.System, variables::Dict{String, DataFrame}, @@ -71,8 +141,8 @@ end function compare_outage_power_and_deployed_reserves( sys::PSY.System, - res::OptimizationProblemResults, - service::PSY.VariableReserve; + res::OptimizationProblemOutputs, + service::PSY.AbstractReserve; tolerance::Float64 = 1e-3, require_positive_outage::Bool = true, ) @@ -92,7 +162,7 @@ function compare_outage_power_and_deployed_reserves( service_name = PSY.get_name(service) reserve_dict = get_reserve_total_power_by_step_dict( variablesdict, - "PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__" * + "PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__" * service_name, associated_outages, contributing_devices; @@ -118,13 +188,13 @@ end function compare_outage_power_and_deployed_reserves( sys::PSY.System, - res::OptimizationProblemResults, + res::OptimizationProblemOutputs, reserve_names::Vector{String}; tolerance::Float64 = 1e-3, require_positive_outage::Bool = true, ) for reserve_name in reserve_names - reserve = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) compare_outage_power_and_deployed_reserves( sys, res, @@ -136,10 +206,10 @@ function compare_outage_power_and_deployed_reserves( return nothing end -# Shorthand for the `PSI.ConstraintKey(T, PSY.VariableReserve{ReserveUp}, name)` +# Shorthand for the `IOM.ConstraintKey(T, PSY.OnlineReserve{ReserveUp}, name)` # pattern that dominates the constraint-key lists below. function sc_key(constraint_type::DataType, name::String) - return PSI.ConstraintKey(constraint_type, PSY.VariableReserve{ReserveUp}, name) + return IOM.ConstraintKey(constraint_type, PSY.OnlineReserve{ReserveUp}, name) end # Shared tail for the SC reserve deliverability testsets: build, check @@ -149,7 +219,7 @@ end function run_sc_reserve_case!( ps_model::DecisionModel, sys::PSY.System, - constraint_keys::Vector{<:PSI.ConstraintKey}, + constraint_keys::Vector{<:IOM.ConstraintKey}, moi_counts::Vector{Int}, has_binaries::Bool, objfunc::DataType, @@ -159,7 +229,7 @@ function run_sc_reserve_case!( require_positive_outage::Bool = true, ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT psi_constraint_test(ps_model, constraint_keys) moi_tests(ps_model, moi_counts..., has_binaries) psi_checkobjfun_test(ps_model, objfunc) @@ -172,7 +242,7 @@ function run_sc_reserve_case!( if isempty(reserve_names) return nothing end - res = OptimizationProblemResults(ps_model) + res = OptimizationProblemOutputs(ps_model) compare_outage_power_and_deployed_reserves( sys, res, @@ -186,7 +256,7 @@ end # the monitored "1_2" tie stays within its flow limits, and each area's # generation plus load nets to zero against the tie flow. function verify_zone_balance!( - results::OptimizationProblemResults; + results::OptimizationProblemOutputs; flow_tol::Float64 = 1e-6, ) interarea_flow = read_variable( @@ -252,22 +322,22 @@ end add_equivalent_ac_transmission_with_parallel_circuits!(sys, l4, PSY.Line) end constraint_keys = [ - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb", ), - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub", ), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), sc_key(RequirementConstraint, "Reserve1"), sc_key(RampConstraint, "Reserve1"), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), @@ -277,19 +347,18 @@ end ), ] 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") + set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") component = get_component(ThermalStandard, sys, "Alta") attach_geometric_outage!(sys, component, [reserve_up]) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -309,30 +378,30 @@ end # Exercises the per-service line-scoping path in # `_monitored_components_by_modeled_type` and the downstream -# `PostContingencyFlowRateConstraint` build for `PTDFPowerModel` when the +# `PostContingencyFlowRateConstraint` build for `PTDFNetworkModel` 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 +@testset "G-n with Ramp reserve deliverability constraints PTDFNetworkModel with monitored line subset" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) monitored_line_names = ["1", "2"] constraint_keys = [ - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb", ), - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub", ), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), sc_key(RequirementConstraint, "Reserve1"), sc_key(RampConstraint, "Reserve1"), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), @@ -345,8 +414,8 @@ end # because only the monitored subset contributes # `PostContingencyFlowRateConstraint` rows per outage step. 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") + set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) #Increase ramp limits to make the problem feasible + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") monitored_subset = [get_component(Line, sys, n) for n in monitored_line_names] component = get_component(ThermalStandard, sys, "Alta") attach_geometric_outage!( @@ -357,13 +426,12 @@ end ) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -389,22 +457,22 @@ end add_equivalent_ac_transmission_with_parallel_circuits!(sys, l4, PSY.Line) end constraint_keys = [ - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb", ), - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub", ), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), sc_key(RequirementConstraint, "Reserve1"), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), sc_key( @@ -412,18 +480,17 @@ end "Reserve1", ), ] - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") component = get_component(ThermalStandard, sys, "Alta") attach_geometric_outage!(sys, component, [reserve_up]) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -451,28 +518,28 @@ end end sys = c_sys5 constraint_keys = [ - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb", ), - PSI.ConstraintKey( + IOM.ConstraintKey( ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub", ), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - PSI.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_ub"), ] 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") + set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) #Increase ramp limits to make the problem feasible + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") remove_time_series!( sys, Deterministic, @@ -483,13 +550,12 @@ end attach_geometric_outage!(sys, component, [reserve_up]) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -521,10 +587,10 @@ end remove_component!(sys, l4) component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") attach_geometric_outage!(sys, component, [reserve_up]) - template = ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys))) + template = PowerOperationsProblemTemplate(NetworkModel(PTDFNetworkModel)) set_device_model!(template, ThermalStandard, ThermalBasicDispatch) set_device_model!(template, PowerLoad, StaticPowerLoad) #set_device_model!(template, MonitoredLine, StaticBranchBounds) @@ -535,15 +601,14 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT end @testset "Test SecurityConstrainedContingencyReserve with different BranchFormulations" begin for line_formulation in [StaticBranch, StaticBranchUnbounded, StaticBranchBounds] @@ -558,10 +623,10 @@ end remove_component!(sys, l4) component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") attach_geometric_outage!(sys, component, [reserve_up]) - template = ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys))) + template = PowerOperationsProblemTemplate(NetworkModel(PTDFNetworkModel)) set_device_model!(template, ThermalStandard, ThermalBasicDispatch) set_device_model!(template, PowerLoad, StaticPowerLoad) #set_device_model!(template, MonitoredLine, StaticBranchBounds) @@ -572,19 +637,18 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT constraints = ps_model.internal.container.constraints - flow_rate_cons = constraints[PSI.ConstraintKey{ + flow_rate_cons = constraints[IOM.ConstraintKey{ PostContingencyFlowRateConstraint, - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, }( "Reserve1_lb", )] @@ -605,10 +669,10 @@ end remove_component!(sys, l4) component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") attach_geometric_outage!(sys, component, [reserve_up]) - template = ProblemTemplate(NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys))) + template = PowerOperationsProblemTemplate(NetworkModel(PTDFNetworkModel)) set_device_model!(template, ThermalStandard, ThermalBasicDispatch) set_device_model!(template, PowerLoad, StaticPowerLoad) #set_device_model!(template, MonitoredLine, StaticBranchBounds) @@ -619,30 +683,29 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT end @testset "G-n with Ramp reserve deliverability constraints UC allowing 2 reserve products to respond" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) 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"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve11_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve11_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), sc_key(RequirementConstraint, "Reserve1"), sc_key(RequirementConstraint, "Reserve11"), sc_key(RampConstraint, "Reserve1"), @@ -659,12 +722,12 @@ end ), ] component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") - reserve_up2 = get_component(VariableReserve{ReserveUp}, sys, "Reserve11") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") + reserve_up2 = get_component(OnlineReserve{ReserveUp}, sys, "Reserve11") attach_geometric_outage!(sys, component, [reserve_up, reserve_up2]) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_device_model!( @@ -675,16 +738,14 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve11", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -701,17 +762,17 @@ end ) end -@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFPowerModel w/wo Reserve Slacks" begin +@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFNetworkModel w/wo Reserve Slacks" begin 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"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key(RampConstraint, "Reserve1_1"), @@ -737,25 +798,23 @@ end for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) attach_geometric_outage!(sys, component, [reserve_up]) end template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(AreaPTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, - SecurityConstrainedRampReserve, - "Reserve1_1"; + OnlineReserve{ReserveUp}, + SecurityConstrainedRampReserve; use_slacks = reserve_slack, )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, - SecurityConstrainedRampReserve, - "Reserve1_2"; + OnlineReserve{ReserveUp}, + SecurityConstrainedRampReserve; use_slacks = reserve_slack, )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -779,23 +838,23 @@ end end # Exercises the per-service line-scoping path in -# `_monitored_components_by_modeled_type` for the `AreaPTDFPowerModel` +# `_monitored_components_by_modeled_type` for the `AreaPTDFNetworkModel` # 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 +@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFNetworkModel and monitored line subset" begin 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"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key(RampConstraint, "Reserve1_1"), @@ -826,7 +885,7 @@ end ) monitored_subset = [get_component(Line, sys, n) for n in monitored_names] component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) attach_geometric_outage!( sys, component, @@ -836,19 +895,17 @@ end end template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(AreaPTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_2", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -864,17 +921,17 @@ end ) end -@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, reserve requirement" begin +@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFNetworkModel, reserves only up, reserve requirement" begin 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"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), @@ -896,25 +953,23 @@ end transform_single_time_series!(sys, Hour(24), Hour(1)) for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) attach_geometric_outage!(sys, component, [reserve_up]) end template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(AreaPTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - "Reserve1_1"; + OnlineReserve{ReserveUp}, + SecurityConstrainedContingencyReserve; use_slacks = reserve_slack, )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - "Reserve1_2"; + OnlineReserve{ReserveUp}, + SecurityConstrainedContingencyReserve; use_slacks = reserve_slack, )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -937,20 +992,20 @@ end end end -@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFPowerModel, reserves only up, NO reserve requirement" begin +@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFNetworkModel, reserves only up, NO reserve requirement" begin sys = PSB.build_system(PSISystems, "two_area_pjm_DA") transform_single_time_series!(sys, Hour(24), Hour(1)) 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"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), + IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), - PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - #PSI.ConstraintKey(NetworkFlowConstraint, PSY.Line), + IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), + #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_1_ub"), @@ -984,24 +1039,22 @@ end for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) attach_geometric_outage!(sys, component, [reserve_up]) end template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(AreaPTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1_2", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -1021,8 +1074,8 @@ end sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) transform_single_time_series!(sys, Hour(24), Hour(1)) constraint_keys = [ - PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key(RampConstraint, "Reserve1_1"), @@ -1043,24 +1096,22 @@ end for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) attach_geometric_outage!(sys, component, [reserve_up]) end template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(CopperPlatePowerModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_2", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -1080,8 +1131,8 @@ end sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) transform_single_time_series!(sys, Hour(24), Hour(1)) constraint_keys = [ - PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), @@ -1100,24 +1151,22 @@ end for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) attach_geometric_outage!(sys, component, [reserve_up]) end template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(CopperPlatePowerModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1_2", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -1137,11 +1186,11 @@ end sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) constraint_keys = [ - PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - PSI.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), PSI.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_ub")] - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") remove_time_series!( sys, Deterministic, @@ -1152,13 +1201,12 @@ end attach_geometric_outage!(sys, component, [reserve_up]) template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlatePowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(CopperPlatePowerModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @@ -1177,8 +1225,8 @@ 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), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key(RampConstraint, "Reserve1_1"), @@ -1210,7 +1258,7 @@ end ) for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, c_sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, reserve_name) attach_geometric_outage!( c_sys, component, @@ -1224,37 +1272,35 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{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 + IOM.ModelBuildStatus.BUILT psi_constraint_test(ps_model, constraint_keys) - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED moi_tests(ps_model, 792, 0, 696, 360, 144, false) - opt_container = PSI.get_optimization_container(ps_model) + opt_container = IOM.get_optimization_container(ps_model) copper_plate_constraints = - PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) + IOM.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) psi_checksolve_test(ps_model, [MOI.OPTIMAL], 497494.4871638, 1) - results = OptimizationProblemResults(ps_model) + results = OptimizationProblemOutputs(ps_model) verify_zone_balance!(results; flow_tol = 1e-6) compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) @@ -1262,8 +1308,8 @@ 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), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), sc_key(RequirementConstraint, "Reserve1_1"), sc_key(RequirementConstraint, "Reserve1_2"), sc_key( @@ -1293,7 +1339,7 @@ end ) for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, c_sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, reserve_name) attach_geometric_outage!( c_sys, component, @@ -1307,37 +1353,35 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{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 + IOM.ModelBuildStatus.BUILT psi_constraint_test(ps_model, constraint_keys) - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED moi_tests(ps_model, 792, 0, 504, 360, 144, false) - opt_container = PSI.get_optimization_container(ps_model) + opt_container = IOM.get_optimization_container(ps_model) copper_plate_constraints = - PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) + IOM.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) psi_checksolve_test(ps_model, [MOI.OPTIMAL], 482055.9151334, 1) - results = OptimizationProblemResults(ps_model) + results = OptimizationProblemOutputs(ps_model) verify_zone_balance!(results; flow_tol = 1e-6) compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) @@ -1345,8 +1389,8 @@ 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), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), + IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_1_ub"), sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_2_ub"), sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), @@ -1359,14 +1403,14 @@ end c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, "Reserve1_1") + reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, "Reserve1_1") remove_time_series!( c_sys, SingleTimeSeries, reserve_up, "requirement", ) - reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, "Reserve1_2") + reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, "Reserve1_2") remove_time_series!( c_sys, SingleTimeSeries, @@ -1384,7 +1428,7 @@ end ) for (component_name, reserve_name) in zip(components_outages_names, reserve_names) component = get_component(ThermalStandard, c_sys, component_name) - reserve_up = get_component(VariableReserve{ReserveUp}, c_sys, reserve_name) + reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, reserve_name) attach_geometric_outage!( c_sys, component, @@ -1398,37 +1442,35 @@ end set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{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 + IOM.ModelBuildStatus.BUILT psi_constraint_test(ps_model, constraint_keys) - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED moi_tests(ps_model, 552, 0, 504, 312, 192, false) - opt_container = PSI.get_optimization_container(ps_model) + opt_container = IOM.get_optimization_container(ps_model) copper_plate_constraints = - PSI.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) + IOM.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) @test size(copper_plate_constraints) == (2, 24) psi_checksolve_test(ps_model, [MOI.OPTIMAL], 482055.7647083302, 1) - results = OptimizationProblemResults(ps_model) + results = OptimizationProblemOutputs(ps_model) verify_zone_balance!(results; flow_tol = 0.0) compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) @@ -1446,7 +1488,7 @@ end sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) transform_single_time_series!(sys, Hour(24), Hour(1)) - reserve1 = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_1") + reserve1 = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1_1") # Attach an UnplannedOutage to a single Area1 generator and to the # reserve that should respond. Reserve1_2 is intentionally NOT attached. @@ -1455,27 +1497,25 @@ end outage_uuid = IS.get_uuid(transition_data) template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(AreaPTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_1", )) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1_2", )) # --- Unit-level: attachment scoping populates only the responding ServiceModel --- - PSI._build_service_model_outages!(template, sys) + IOM._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}))] + services = IOM.get_service_models(template) + sm1 = services[("Reserve1_1", Symbol(OnlineReserve{ReserveUp}))] + sm2 = services[("Reserve1_2", Symbol(OnlineReserve{ReserveUp}))] @test haskey(sm1.outages, outage_uuid) @test !haskey(sm2.outages, outage_uuid) @@ -1483,22 +1523,22 @@ end ps_model = DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - @test PSI.has_container_key( + container = IOM.get_optimization_container(ps_model) + @test IOM.has_container_key( container, PostContingencyGenerationBalanceConstraint, - PSY.VariableReserve{ReserveUp}, + PSY.OnlineReserve{ReserveUp}, "Reserve1_1", ) - @test !PSI.has_container_key( + @test !IOM.has_container_key( container, PostContingencyGenerationBalanceConstraint, - PSY.VariableReserve{ReserveUp}, + PSY.OnlineReserve{ReserveUp}, "Reserve1_2", ) - cons_resp = PSI.get_constraint( + cons_resp = IOM.get_constraint( container, sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), ) @@ -1512,24 +1552,23 @@ end sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) alta = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") transition_data = attach_geometric_outage!(sys, alta, [reserve_up]) outage_uuid = IS.get_uuid(transition_data) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, - "Reserve1", )) - PSI._build_service_model_outages!(template, sys) + IOM._build_service_model_outages!(template, sys) - services = PSI.get_service_models(template) - sm = services[("Reserve1", Symbol(VariableReserve{ReserveUp}))] + services = IOM.get_service_models(template) + sm = services[("Reserve1", Symbol(OnlineReserve{ReserveUp}))] @test haskey(sm.outages, outage_uuid) end @@ -1614,7 +1653,7 @@ function _two_area_g1_setup!(; end outaged_gen = get_component(ThermalStandard, sys, outaged_gen_name) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1_2") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1_2") attach_fixed_outage!( sys, outaged_gen, @@ -1626,9 +1665,8 @@ function _two_area_g1_setup!(; set_interchange_model!(template) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - "Reserve1_2"; + OnlineReserve{ReserveUp}, + SecurityConstrainedContingencyReserve; use_slacks = use_slacks, )) @@ -1648,10 +1686,10 @@ end ), ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + IOM.ModelBuildStatus.BUILT + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemResults(ps_model) + res = OptimizationProblemOutputs(ps_model) variables = read_variables(res) # The outaged generator must dispatch normally, not be pinned to zero by @@ -1678,11 +1716,11 @@ end outage_id = string(IS.get_uuid(only(resolved_outages))) deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__Reserve1_2"] total_deployment = total_deployment_series(deployment, outage_id) flow_dev = - variables["PostContingencyAreaInterchangeFlowDeviationVariable__VariableReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyAreaInterchangeFlowDeviationVariable__OnlineReserve__ReserveUp__Reserve1_2"] tie_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") @test all(total_deployment .> 0.0) @@ -1700,10 +1738,10 @@ end ), ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + IOM.ModelBuildStatus.BUILT + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemResults(ps_model) + res = OptimizationProblemOutputs(ps_model) variables = read_variables(res) compare_outage_power_and_deployed_reserves(sys, res, reserve_up) @@ -1712,11 +1750,11 @@ end outage_id = string(IS.get_uuid(only(resolved_outages))) deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__Reserve1_2"] total_deployment = total_deployment_series(deployment, outage_id) flow_dev = - variables["PostContingencyAreaInterchangeFlowDeviationVariable__VariableReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyAreaInterchangeFlowDeviationVariable__OnlineReserve__ReserveUp__Reserve1_2"] tie1_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") tie2_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2_b") @@ -1738,13 +1776,13 @@ end monitored = s -> [get_component(AreaInterchange, s, "1_2")], ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - flow_dev = PSI.get_variable( + container = IOM.get_optimization_container(ps_model) + flow_dev = IOM.get_variable( container, - PSI.PostContingencyAreaInterchangeFlowDeviationVariable(), - VariableReserve{ReserveUp}, + IOM.PostContingencyAreaInterchangeFlowDeviationVariable(), + OnlineReserve{ReserveUp}, "Reserve1_2", ) @test Set(axes(flow_dev, 2)) == Set(["1_2", "1_2_b"]) @@ -1753,7 +1791,7 @@ end # `SparseAxisArray` does not forward `Base.keys` to the container # axes, so index its `.data` dict directly to get the actual # `(outage_id, name, t)` tuples that were written. - cons = PSI.get_constraint( + cons = IOM.get_constraint( container, sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_$(suffix)"), ) @@ -1776,10 +1814,10 @@ end set_flow_limits!(tie, (from_to = 0.1, to_from = 0.1)) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + IOM.ModelBuildStatus.BUILT + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemResults(ps_model) + res = OptimizationProblemOutputs(ps_model) variables = read_variables(res) # The balance still holds exactly even though the tie's physical limit @@ -1788,7 +1826,7 @@ end compare_outage_power_and_deployed_reserves(sys, res, reserve_up) slack_lb = - variables["PostContingencyFlowActivePowerSlackLowerBound__VariableReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyFlowActivePowerSlackLowerBound__OnlineReserve__ReserveUp__Reserve1_2"] @test maximum(slack_lb[!, "value"]) > 1e-3 end @@ -1808,13 +1846,13 @@ end ), ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - flow_deviation_var = PSI.get_variable( + container = IOM.get_optimization_container(ps_model) + flow_deviation_var = IOM.get_variable( container, - PSI.PostContingencyAreaInterchangeFlowDeviationVariable(), - VariableReserve{ReserveUp}, + IOM.PostContingencyAreaInterchangeFlowDeviationVariable(), + OnlineReserve{ReserveUp}, "Reserve1_2", ) interchange_axis = axes(flow_deviation_var, 2) @@ -1824,8 +1862,8 @@ end # Matches pre-contingency semantics: the disabled tie is not the sole # path here (the original "1_2" tie stays available), so the model # remains solvable and the response still balances exactly. - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemResults(ps_model) + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemOutputs(ps_model) compare_outage_power_and_deployed_reserves(sys, res, reserve_up) end @@ -1837,19 +1875,19 @@ end tie_available = false, monitored = s -> collect(get_components(AreaInterchange, s)), ) - template = PSI.get_template(ps_model) + template = IOM.get_template(ps_model) # `build!` catches this internally and reports `ModelBuildStatus.FAILED` # rather than propagating it, so the throw is only observable by calling # the template-validation step directly (mirrors the `ReserveDown` # rejection testset below). - @test_throws IS.ConflictingInputsError PSI._build_service_model_outages!( + @test_throws IS.ConflictingInputsError IOM._build_service_model_outages!( template, sys, ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.FAILED + IOM.ModelBuildStatus.FAILED end # The AreaInterchange `DeviceModel`'s set — not the network model's scope — is @@ -1867,7 +1905,7 @@ end # container, asserted below). Do not "fix" these tests by removing # `filter_function` — the warning is a known false positive in the unrelated # branch-filtering validation path, not a signal that filtering was ignored. -_exclude_parallel_tie_model!(template::PSI.ProblemTemplate) = set_device_model!( +_exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_device_model!( template, DeviceModel( AreaInterchange, @@ -1883,23 +1921,23 @@ _exclude_parallel_tie_model!(template::PSI.ProblemTemplate) = set_device_model!( set_interchange_model! = _exclude_parallel_tie_model!, ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) + container = IOM.get_optimization_container(ps_model) flow_var = - PSI.get_variable(container, PSI.FlowActivePowerVariable(), AreaInterchange) - flow_deviation_var = PSI.get_variable( + IOM.get_variable(container, IOM.FlowActivePowerVariable(), AreaInterchange) + flow_deviation_var = IOM.get_variable( container, - PSI.PostContingencyAreaInterchangeFlowDeviationVariable(), - VariableReserve{ReserveUp}, + IOM.PostContingencyAreaInterchangeFlowDeviationVariable(), + OnlineReserve{ReserveUp}, "Reserve1_2", ) # Δf spans exactly the pre-contingency flow variable's ties. @test Set(axes(flow_var, 1)) == Set(["1_2"]) @test Set(axes(flow_deviation_var, 2)) == Set(["1_2"]) - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemResults(ps_model) + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemOutputs(ps_model) variables = read_variables(res) compare_outage_power_and_deployed_reserves(sys, res, reserve_up) @@ -1909,11 +1947,11 @@ _exclude_parallel_tie_model!(template::PSI.ProblemTemplate) = set_device_model!( ), ) total_deployment = total_deployment_series( - variables["PostContingencyActivePowerReserveDeploymentVariable__VariableReserve__ReserveUp__Reserve1_2"], + variables["PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__Reserve1_2"], outage_id, ) tie_flow_dev = _sc_flow_deviation_series( - variables["PostContingencyAreaInterchangeFlowDeviationVariable__VariableReserve__ReserveUp__Reserve1_2"], + variables["PostContingencyAreaInterchangeFlowDeviationVariable__OnlineReserve__ReserveUp__Reserve1_2"], outage_id, "1_2", ) @@ -1931,15 +1969,15 @@ end monitored = s -> collect(get_components(AreaInterchange, s)), set_interchange_model! = _exclude_parallel_tie_model!, ) - template = PSI.get_template(ps_model) + template = IOM.get_template(ps_model) - @test_throws IS.ConflictingInputsError PSI._build_service_model_outages!( + @test_throws IS.ConflictingInputsError IOM._build_service_model_outages!( template, sys, ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.FAILED + IOM.ModelBuildStatus.FAILED end @testset "G-1 AreaBalancePowerModel: no AreaInterchange device model warns and covers in-area" begin @@ -1952,43 +1990,42 @@ end set_interchange_model! = t -> nothing, ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - @test !PSI.has_container_key( + container = IOM.get_optimization_container(ps_model) + @test !IOM.has_container_key( container, - PSI.FlowActivePowerVariable, + IOM.FlowActivePowerVariable, AreaInterchange, ) # No modeled tie, so no Δf container at all: the per-area balance reduces # to in-area coverage. - @test !PSI.has_container_key( + @test !IOM.has_container_key( container, - PSI.PostContingencyAreaInterchangeFlowDeviationVariable, - VariableReserve{ReserveUp}, + IOM.PostContingencyAreaInterchangeFlowDeviationVariable, + OnlineReserve{ReserveUp}, "Reserve1_2", ) # `build!` installs its own logger, so the construct-time warning is only # observable in the model's log file; it must be emitted exactly once. - log_text = read(PSI.get_log_file(ps_model), String) + log_text = read(IOM.get_log_file(ps_model), String) @test length( collect(eachmatch(r"no PSY\.AreaInterchange device model in the", log_text)), ) == 1 - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemResults(ps_model) + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemOutputs(ps_model) compare_outage_power_and_deployed_reserves(sys, res, reserve_up) end @testset "SecurityConstrainedContingencyReserve rejects Reserve{ReserveDown}" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - template = get_thermal_dispatch_template_network(PTDFPowerModel) + template = get_thermal_dispatch_template_network(PTDFNetworkModel) set_service_model!(template, ServiceModel( - VariableReserve{ReserveDown}, + OnlineReserve{ReserveDown}, SecurityConstrainedContingencyReserve, - "Reserve2", )) # `build!` catches this internally and reports `ModelBuildStatus.FAILED` @@ -1996,7 +2033,7 @@ end # observable by calling the template-validation step directly — mirrors # the sibling AC-transmission test file's convention for internal # validation checks. - @test_throws IS.ConflictingInputsError PSI._build_service_model_outages!( + @test_throws IS.ConflictingInputsError IOM._build_service_model_outages!( template, sys, ) @@ -2004,12 +2041,12 @@ end @testset "SC guard rails: fail-fast on unresolved monitored components" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - template = get_thermal_dispatch_template_network(PTDFPowerModel) + template = get_thermal_dispatch_template_network(PTDFNetworkModel) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT - network_model = PSI.get_network_model(PSI.get_template(ps_model)) - net_reduction_data = network_model.network_reduction + IOM.ModelBuildStatus.BUILT + network_model = IOM.get_network_model(IOM.get_template(ps_model)) + net_reduction_data = IOM.get_network_reduction(network_model) uuid = IS.get_uuid(sys) # (a) device-side: an ACTransmission-type monitor absent from the @@ -2018,20 +2055,19 @@ end device_model = DeviceModel(MonitoredLine, SecurityConstrainedStaticBranch) device_model.outages[uuid] = Dict{DataType, Set{String}}(MonitoredLine => Set(["fake_line"])) - @test_throws ErrorException PSI._resolve_monitored_arcs( + @test_throws ErrorException IOM._resolve_monitored_arcs( device_model, net_reduction_data, ) # (a) service-side counterpart. service_model = ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", ) service_model.outages[uuid] = Dict{DataType, Set{String}}(MonitoredLine => Set(["fake_line"])) - @test_throws ErrorException PSI._resolve_service_monitored_arcs( + @test_throws ErrorException IOM._resolve_service_monitored_arcs( service_model, net_reduction_data, ) @@ -2040,13 +2076,12 @@ end # network model; dropped with a warning naming the component instead of # being silently skipped. service_model_ai = ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", ) service_model_ai.outages[uuid] = Dict{DataType, Set{String}}(AreaInterchange => Set(["tie1"])) - @test_logs (:warn, r"AreaInterchange monitor.*tie1.*dropped") PSI._resolve_service_monitored_arcs( + @test_logs (:warn, r"AreaInterchange monitor.*tie1.*dropped") IOM._resolve_service_monitored_arcs( service_model_ai, net_reduction_data, ) @@ -2054,13 +2089,12 @@ end # (c) an AreaInterchange monitor naming a component absent from the # system raises, rather than being silently skipped. service_model_missing = ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", ) service_model_missing.outages[uuid] = Dict{DataType, Set{String}}(AreaInterchange => Set(["not_a_real_tie"])) - @test_throws ErrorException PSI._resolve_service_monitored_area_interchanges( + @test_throws ErrorException IOM._resolve_service_monitored_area_interchanges( sys, service_model_missing, ) @@ -2086,7 +2120,7 @@ end ) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_device_model!(template, Line, SecurityConstrainedStaticBranch) set_device_model!(template, AreaInterchange, StaticBranch) @@ -2094,9 +2128,9 @@ end @test_logs ( :warn, r"AreaInterchange.*is not a modeled ACTransmission branch type", - ) PSI._build_device_model_outages!(template, sys) + ) IOM._build_device_model_outages!(template, sys) - device_model = only(PSI._sc_branch_models(template)) + device_model = only(IOM._sc_branch_models(template)) outage_uuid = only(keys(device_model.outages)) per_type = device_model.outages[outage_uuid] @test haskey(per_type, Line) @@ -2121,7 +2155,7 @@ end gens = collect(get_components(ThermalStandard, sys_red)) add_service!(sys_red, - VariableReserve{ReserveUp}(; + OnlineReserve{ReserveUp}(; name = "Reserve1", available = true, time_frame = 0.0, @@ -2132,7 +2166,7 @@ end deployed_fraction = 0.0, ), gens) - reserve_up = get_component(VariableReserve{ReserveUp}, sys_red, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys_red, "Reserve1") outaged_gen = gens[1] monitored_name = "RADIAL1-RADIAL2-i_1" @@ -2144,12 +2178,10 @@ end monitored_components = [monitored_line], ) - ptdf_red = PTDF(sys_red; network_reductions = NetworkReduction[DegreeTwoReduction()]) - template = ProblemTemplate( + template = PowerOperationsProblemTemplate( NetworkModel( - PTDFPowerModel; - PTDF_matrix = ptdf_red, - reduce_degree_two_branches = true, + PTDFNetworkModel; + network_source = SystemNetworkSource(DegreeTwoReduction()), ), ) set_device_model!(template, ThermalStandard, ThermalBasicDispatch) @@ -2157,35 +2189,34 @@ end set_device_model!(template, Line, StaticBranch) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys_red; optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - network_model = PSI.get_network_model(PSI.get_template(ps_model)) - net_reduction_data = network_model.network_reduction + container = IOM.get_optimization_container(ps_model) + network_model = IOM.get_network_model(IOM.get_template(ps_model)) + net_reduction_data = IOM.get_network_reduction(network_model) orientation_sign = - PSI.get_ptdf_orientation_sign(net_reduction_data, Line, monitored_name) + IOM.get_ptdf_orientation_sign(net_reduction_data, Line, monitored_name) @test orientation_sign == -1.0 # guard: fixture must exercise the :ToFrom member - time_steps = PSI.get_time_steps(container) + time_steps = IOM.get_time_steps(container) outage_id = string(IS.get_uuid(outage)) - actual_container = PSI.get_expression( + actual_container = IOM.get_expression( container, - PSI.PostContingencyBranchFlow(), - VariableReserve{ReserveUp}, + IOM.PostContingencyBranchFlow(), + OnlineReserve{ReserveUp}, "Reserve1", ) - pre_flow = PSI.get_expression(container, PSI.PTDFBranchFlow(), Line) - nodal_deployment = PSI.get_expression( + pre_flow = IOM.get_expression(container, IOM.PTDFBranchFlow(), Line) + nodal_deployment = IOM.get_expression( container, - PSI.PostContingencyNodalActivePowerDeployment(), - VariableReserve{ReserveUp}, + IOM.PostContingencyNodalActivePowerDeployment(), + OnlineReserve{ReserveUp}, "Reserve1", ) # `nodal_deployment`'s bus axis is scoped to injection-relevant buses @@ -2194,8 +2225,8 @@ end # expression, so they're skipped here rather than indexed positionally. name_to_arc_maps = PNM.get_name_to_arc_maps(net_reduction_data) arc, _ = name_to_arc_maps[Line][monitored_name] - ptdf_col = PSI.get_PTDF_matrix(network_model)[arc, :] - full_bus_axis = PNM.get_bus_axis(PSI.get_PTDF_matrix(network_model)) + ptdf_col = IOM.get_network_matrix(network_model)[arc, :] + full_bus_axis = PNM.get_bus_axis(IOM.get_network_matrix(network_model)) relevant_buses = Set(nodal_deployment.axes[2]) for t in time_steps @@ -2204,7 +2235,7 @@ end naively_signed = copy(pre_flow[monitored_name, t]) for b in eachindex(ptdf_col) coef = ptdf_col[b] - abs(coef) < PSI.PTDF_ZERO_TOL && continue + abs(coef) < IOM.PTDF_ZERO_TOL && continue bus_number = full_bus_axis[b] bus_number in relevant_buses || continue JuMP.add_to_expression!( @@ -2230,7 +2261,7 @@ end # this is `0 >= limits.min`, which is false whenever `limits.min > 0` — # provably infeasible under the pre-fix constraint. sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") remove_time_series!(sys, Deterministic, reserve_up, "requirement") outaged_gen = get_component(ThermalStandard, sys, "Alta") @@ -2245,35 +2276,34 @@ end ) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_device_model!(template, ThermalStandard, ThermalStandardUnitCommitment) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - on_var = PSI.get_variable(container, OnVariable(), ThermalStandard) - deployment_var = PSI.get_variable( + container = IOM.get_optimization_container(ps_model) + on_var = IOM.get_variable(container, OnVariable(), ThermalStandard) + deployment_var = IOM.get_variable( container, PostContingencyActivePowerReserveDeploymentVariable(), - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, "Reserve1", ) outage_id = string(IS.get_uuid(outage)) - for t in PSI.get_time_steps(container) + for t in IOM.get_time_steps(container) JuMP.fix(on_var["Park City", t], 0.0; force = true) JuMP.fix(deployment_var[outage_id, "Park City", t], 0.0; force = true) end - @test solve!(ps_model) == PSI.RunStatus.SUCCESSFULLY_FINALIZED + @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED end @testset "has_requirement_ts is gated on the mapped requirement series, not any time series" begin @@ -2281,7 +2311,7 @@ end # (not the name mapped to `RequirementTimeSeriesParameter`) must take the # no-requirement path — no `RequirementConstraint` should be built. sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - reserve_up = get_component(VariableReserve{ReserveUp}, sys, "Reserve1") + reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") remove_time_series!(sys, Deterministic, reserve_up, "requirement") unrelated_forecast = Deterministic( @@ -2300,24 +2330,23 @@ end ) template = get_thermal_dispatch_template_network( - NetworkModel(PTDFPowerModel; PTDF_matrix = PTDF(sys)), + NetworkModel(PTDFNetworkModel), ) set_service_model!(template, ServiceModel( - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve, - "Reserve1", )) ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer, interval = Hour(24)) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - PSI.ModelBuildStatus.BUILT + IOM.ModelBuildStatus.BUILT - container = PSI.get_optimization_container(ps_model) - @test !PSI.has_container_key( + container = IOM.get_optimization_container(ps_model) + @test !IOM.has_container_key( container, RequirementConstraint, - VariableReserve{ReserveUp}, + OnlineReserve{ReserveUp}, ) end From 93f5e3bfcd129db1e7b02e3064384a2b0588d68b Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 21 Sep 2026 17:19:59 -0400 Subject: [PATCH 11/22] more bug fixes --- src/PowerOperationsModels.jl | 1 + .../security_constrained_injectors.jl | 170 +++--------------- src/services_models/services_constructor.jl | 25 +-- ...c_injection_security_constrained_models.jl | 6 +- 4 files changed, 42 insertions(+), 160 deletions(-) diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index cfbac3cd..8947ec39 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -346,6 +346,7 @@ include("services_models/reserve_group.jl") # include("services_models/agc.jl") # TODO: needs _get_ace_error include("services_models/transmission_interface.jl") include("services_models/services_constructor.jl") +include("services_models/security_constrained_injectors.jl") # Hybrid System Models (after services_models since they share reserve infrastructure) include("hybrid_system_models/hybrid_systems.jl") diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index a499b3a4..5a3c7058 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -13,7 +13,6 @@ We perform this aggregation because an outage may be tied to reserves with diffe devices and different service models. """ function _security_constrained_contributing_devices(sys::PSY.System, services_template::ServicesModelContainer) - uuids = Set{Int}() contributing_devices = _OUTAGE_MAP() for model in values(services_template) get_formulation(model) <: AbstractSecurityConstrainedReservesFormulation || continue @@ -21,40 +20,44 @@ function _security_constrained_contributing_devices(sys::PSY.System, services_te per_type = get!(contributing_devices, uuid, _PER_TYPE()) c = get!(per_type, typeof(device), Set{String}()) push!(c, PSY.get_name(device)) - push!(uuids, uuid) end end return contributing_devices end -function _outaged_generators(sys::PSY.System, uuids::Set{Int}) +function _outaged_generators(sys::PSY.System, uuids::Vector{Int}) outaged_generators = _OUTAGE_MAP(uuid => Dict{DataType, Set{String}}() for uuid in uuids) for (generator, outage) in PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) - uuid = IS.get_uuid(outage) - uuid in uuids || continue + uuid = IS.get_id(outage) + haskey(outaged_generators, uuid) || continue c = get!(outaged_generators[uuid], typeof(generator), Set{String}()) push!(c, PSY.get_name(generator)) end return outaged_generators end -_validate_reserve_formulation(::ServiceModel{<:PSY.AbstractReserve{PSY.ReserveUp}, AbstractSecurityConstrainedReservesFormulation}) = true +_validate_reserve_formulation(::ServiceModel{Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve}, AbstractSecurityConstrainedReservesFormulation}) = true _validate_reserve_formulation(::ServiceModel) = false -_validate_reserve_formulation(::ServiceModel{<:PSY.AbstractReserve, AbstractSecurityConstrainedReservesFormulation}) = throw(IS.ConflictingInputsError("Security-constrained formulations currently only support ReserveUp.") +_validate_reserve_formulation(::ServiceModel{<:PSY.AbstractReserve, AbstractSecurityConstrainedReservesFormulation}) = throw(IS.ConflictingInputsError("Security-constrained formulations currently only support ReserveUp.")) + +_valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetworkModel}) = true +_valid_component_type(::PSY.AreaInterchange, ::NetworkModel{AreaBalanceNetworkModel}) = true +_valid_component_type(::PSY.Component, ::NetworkModel) = false # TODO move to template_validation or keep here? # TODO: split between ptdf and area? -function _monitored_components(sys::PSY.System, services_template::ServicesModelContainer) +function _monitored_components(sys::PSY.System, services_template::ServicesModelContainer, network_model::NetworkModel) components = _OUTAGE_MAP() - for model in service_models + for model in values(services_template) _validate_reserve_formulation(model) || continue # TODO: validate that its nonempty? for service in get_available_components(sys, model) for outage in PSY.get_supplemental_attributes(PSY.Outage, service) - outage_uuid = IS.get_uuid(outage) + outage_uuid = IS.get_id(outage) per_type = PER_TYPE() for component_uuid in PSY.get_monitored_components(outage) component = IS.get_component(sys, component_uuid) + _valid_component_type(component, network_model) || continue names = get!(per_type, typeof(component), Set{String}) push!(names, PSY.get_name(component)) end @@ -88,7 +91,7 @@ end function _modeled_interchange_names(container::OptimizationContainer) if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) - @warn "An AreaBalancePowerModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 + @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 String[] else var = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) @@ -99,7 +102,7 @@ end function _create_post_contingency_interchange_variables!( container::OptimizationContainer, uuids::Vector{Int}, - ::NetworkModel{AreaBalancePowerModel}, + ::NetworkModel{AreaBalanceNetworkModel}, ) names = _modeled_interchange_names(container) isempty(names) && return @@ -111,14 +114,14 @@ function _create_post_contingency_interchange_variables!( end end -_create_post_contingency_interchange_variables(::OptimizationContainer, ::Vector, ::NetworkModel) = nothing +_create_post_contingency_interchange_variables!(::OptimizationContainer, ::Vector, ::NetworkModel) = nothing _deployment_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment -_deployment_expression(::NetworkModel{AreaBalancePowerModel}) = PostContingencyAreaActivePowerDeployment +_deployment_expression(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaActivePowerDeployment _location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string(PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(component))) # TODO are there reductions on area models? -_location_key(component, ::NetworkModel{AreaBalancePowerModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) +_location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) # TODO: What about outaged gens? Subtract their power on the node? function _build_post_contingency_locational_power!( @@ -199,7 +202,7 @@ end function _build_post_contingency_flow!( container::OptimizationContainer, monitored_interchanges::_OUTAGE_MAP, - ::NetworkModel{AreaBalancePowerModel}, + ::NetworkModel{AreaBalanceNetworkModel}, ) expr = add_expression_container!(container, PostContingencyAreaInterchangeFlow, PSY.AreaInterchange, String[], Int[], Int[]; sparse = true) flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) @@ -218,8 +221,10 @@ function _constrain_post_contingency_balance!( container::OptimizationContainer, contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, - ::NetworkModel, + ::NetworkModel{<:AbstractPTDFNetworkModel}, ) + uuids = collect(keys(contributing_devices)) + time_steps = get_time_steps(container) cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, PSY.System, uuids, time_steps) for uuid in keys(contributing_devices), t in time_steps @@ -247,7 +252,7 @@ function _constrain_post_contingency_balance!( container::OptimizationContainer, contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, - ::NetworkModel{AreaBalancePowerModel}, + ::NetworkModel{AreaBalanceNetworkModel}, ) interchanges = Dict{String, Vector{Tuple{Float64, String}}}() for interchange in PSY.get_components(PSY.AreaInterchange, sys) @@ -323,7 +328,7 @@ end function _constrain_post_contingency_flow!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, - network_model::NetworkModel{Union{AbstractPTDFNetworkModel, AreaBalancePowerModel}}, + network_model::NetworkModel{Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) for (uuid, per_type) in monitored_components for (component_type, names) in per_type @@ -340,130 +345,3 @@ end _constrain_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkModel) = nothing -############################################################################### -############################################################################### -############################################################################### - -function _outaged_generators(sys::PSY.System, model::ServiceModel{R, F}) where {R <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - uuids = sort!(collect(keys(get_outages(model)))) - if isempty(uuids) - @warn "Service{$(R),$(F)}($(PSY.get_name(model)): `service_model.outages` is empty; the \ - security-constrained formulation will not add any \ - post-contingency variables or constraints." - return - end - generator_outage_pairs = PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) - outaged_gens = _OUTAGE_MAP(uuid => PER_TYPE() for uuid in uuids) - for (generator, outage) in generator_outage_pairs - haskey(outaged_gens, uuid) || continue - push!(outaged_gens[uuid][typeof(generator)], PSY,get_name(generator)) - end - return outaged_gens -end - -_formulation_needs_requirement_ts(::Type{SecurityConstrainedContingencyReserve}) = false -_formulation_needs_requirement_ts(::Type{SecurityConstrainedRampReserve}) = true - -function _service_needs_requirement_ts(sys::PSY.System, service::S, model::ServiceModel{S, F}) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - _formulation_needs_requirement_ts(F) && return true - ts_names = get_time_series_names(model) - has(ts_names, RequirementTimeSeriesParameter) || return false - return PSY.has_time_series(service, get_deterministic_time_series_type(sys), ts_name[RequirementTimeSeriesParameter]) -end - -function construct_service!( - container::OptimizationContainer, - sys::PSY.System, - ::ArgumentConstructStage, - model::ServiceModel{S, F}, - devices_template::Dict{Symbol, DeviceModel}, - ::Set{<:DataType}, - ::NetworkModel{<:AbstractActivePowerModel}, -) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - services = _services_with_contributors(model, sys) - isempty(services) && return - - outaged_gens = _outaged_generators(sys, model) - isempty(outaged_gens) && return - - ts_services = [s for s in _demand_services(model, services) if _has_ts_requirement(model, s)] - isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) - - for service in services - per_type = get_contributing_devices(model, PSY.get_name(service)) - for contributing_devices in values(per_type) - add_variables!(container, PostContingencyActivePowerReserveDeploymentVariable, service, model, contributing_devices, F, outage_uuids, outaged_gens) - end - add_service_variables!(container, ActivePowerReserveVariable, service, contributing_devices, F) - add_to_expression!(container, ActivePowerReserveVariable, service, model, devices_template) - add_feedforward_arguments!(container, model, service) - end - return -end - -function construct_service!( - container::OptimizationContainer, - sys::PSY.System, - ::ModelConstructStage, - model::ServiceModel{S, F}, - ::Dict{Symbol, DeviceModel}, - ::Set{<:DataType}, - network_model::NetworkModel{<:CopperPlateNetworkModel}, -) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - services = _services_with_contributors(model, sys) - isempty(services) && return - outaged_gens = _outaged_generators(sys, model) - isempty(outaged_gens) && return - - for service in services - per_type = get_contributing_devices(model, PSY.get_name(service)) - has_requirement = _service_needs_requirement_ts(sys, service, model) - for contributing_devices in values(per_type) - _constrain_post_contingency_balance!(container, outaged_gens, contributing_devices) - _constrain_post_contingency_generation!(container, outaged_gens, contributing_devices) - if has_requirement - # TODO: Both are needed? Cause what if pre-conting. is too loose? - # TODO: Don't I have to enforce the pre-conting. level too? - _constrain_post_contingency_reserve!(container, outaged_gens, contributing_devices) - end - end - end - return -end - -function construct_service!( - container::OptimizationContainer, - sys::PSY.System, - ::ModelConstructStage, - model::ServiceModel{S, F}, - ::Dict{Symbol, DeviceModel}, - ::Set{<:DataType}, - network_model::NetworkModel{<:AbstractPTDFNetworkModel}, -) where {S <: PSY.AbstractReserve, F <: AbstractSecurityConstrainedReservesFormulation} - services = _services_with_contributors(model, sys) - isempty(services) && return - outaged_gens = _outaged_generators(sys, model) - isempty(services) && return - - # Build PC nodal power before building and constraining PC flows - for service in services - per_type = get_contributing_devices(model, PSY.get_name(services)) - for contributing_devices in values(per_type) - _build_post_contingency_nodal_power(model, outaged_gens, service, contributing_devices) - end - end - - for service in services - per_type = get_contributing_devices(model, PSY.get_name(service)) - for contributing_devices in values(per_type) - _constrain_post_contingency_balance!(container, outaged_gens, contributing_devices) - _constrain_post_contingency_generation!(container, outaged_gens, contributing_devices) - if has_requirement - _constrain_post_contingency_reserve!(container, outaged_gens, contributing_devices) - end - - _constrain_post_contingency_flow!(container, outaged_gens, contributing_devices) - end - end - return -end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index c79efd7a..536c6b7a 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -139,9 +139,11 @@ function construct_services!( ) end - contributing_devices, outaged_generators = _post_contingency_devices(sys, services_template) - _create_post_contingency_reserve_variables(container, contributing_devices, outaged_generators) - _create_post_contingency_interchange_variables(container, uuids, network_model) + contributing_devices = _security_constrained_contributing_devices(sys, services_template) + uuids = collect(keys(contributing_devices)) + outaged_generators = _outaged_generators(sys, uuids) + _create_post_contingency_reserve_variables!(container, contributing_devices, outaged_generators) + _create_post_contingency_interchange_variables!(container, uuids, network_model) return end @@ -185,12 +187,13 @@ function construct_services!( ) end - - - contributing_devices, outaged_generators = _post_contingency_devices(sys, services_template) - monitored_components = _monitored_components(sys, service_template, network_model) - _constrain_post_contingency_balance!(container, contributing_devices, outaged_generators) - _constrain_post_contingency_generation!(container, contributing_devices, outaged_generators) + contributing_devices = _security_constrained_contributing_devices(sys, services_template) + # TODO: combine into prev function + uuids = collect(keys(contributing_devices)) + monitored_components = _monitored_components(sys, services_template, network_model) + outaged_generators = _outaged_generators(sys, uuids) + _constrain_post_contingency_balance!(container, contributing_devices, outaged_generators, network_model) + _constrain_post_contingency_generation!(container, sys, contributing_devices, outaged_generators) _constrain_post_contingency_flow!(container, monitored_components, network_model) return end @@ -638,11 +641,11 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ModelConstructStage, - model::ServiceModel{SR, Union{RampReserve, SecurityConstrainedRampReserve}}, + model::ServiceModel{SR, F}, devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {SR <: PSY.Reserve} +) where {SR <: PSY.Reserve, F <: Union{RampReserve, SecurityConstrainedRampReserve}} services = _services_with_contributors(model, sys) isempty(services) && return service_names = PSY.get_name.(services) diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index cb861ad9..6bbbfffb 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -78,7 +78,7 @@ function get_outage_total_power_by_step_dict( 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_name = string(IS.get_id(outage)) outage_power_v = Vector{Float64}() devices = PSY.get_associated_components( sys, @@ -160,10 +160,10 @@ function compare_outage_power_and_deployed_reserves( ) contributing_devices = PSY.get_contributing_devices(sys, service) service_name = PSY.get_name(service) + @show variablesdict reserve_dict = get_reserve_total_power_by_step_dict( variablesdict, - "PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__" * - service_name, + "PostContingencyActivePowerReserveDeploymentVariable_", associated_outages, contributing_devices; col_name = "name2", From f8e0a78f6aebe46212e7d841520841397a7b507f Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 21 Sep 2026 18:15:16 -0400 Subject: [PATCH 12/22] claude bug fixes and test rewrites --- .claude/pom_port_plan.md | 183 ----- src/PowerOperationsModels.jl | 5 +- src/core/expressions.jl | 5 - src/core/variables.jl | 2 +- src/operation/template_validation.jl | 24 - .../security_constrained_injectors.jl | 467 ++++++++--- src/services_models/services_constructor.jl | 21 +- ...c_injection_security_constrained_models.jl | 726 +++++++----------- 8 files changed, 626 insertions(+), 807 deletions(-) delete mode 100644 .claude/pom_port_plan.md diff --git a/.claude/pom_port_plan.md b/.claude/pom_port_plan.md deleted file mode 100644 index 7fccf0ed..00000000 --- a/.claude/pom_port_plan.md +++ /dev/null @@ -1,183 +0,0 @@ -# POM port plan — PSI → PowerOperationsModels.jl - -Scope: bring POM (`PowerOperationsModels.jl`, the **formulation** library) up to date with PSI -(`PowerSimulations.jl`), covering (1) missing formulation **tests**, (2) post-fork PSI **code** -changes that belong in POM, and (3) the **in-progress G-1** generator-contingency feature. - -Companion file: `iom_port_plan.md` (generic optimization-core changes → InfrastructureOptimizationModels). - -## Topology / ground rules -- Sienna porting is two-headed: **formulation specifics → POM**, **generic optimization core - (optimization_container, dual_processing, settings, serialization, parameters, lifecycle, - objective-function machinery, model-export) → IOM**. Check the right repo before porting. -- POM is a **restructured** port (e.g. PF logic lives in `ext/PowerFlowsExt/`; type-based dispatch - `::Type{F}` vs PSI's instance-based). Verify by **symbol/behavior**, not file path; adapt, don't - copy verbatim. -- Fork baseline ≈ PSI #1503 (POM/IOM forked 2026-01-03). Latest PSI PR swept = **#1640**. -- "Already ported" / "code present" claims below were symbol-verified against the local POM clone. - ---- - -## Workstream G1 — in-progress generator contingency (PSI **open PR #1617**) — TIME-SENSITIVE -PR #1617 (`sm/g-1_monitored_c`, OPEN, not draft, `mergeable_state=blocked` on review; author -SebastianManriqueM) adds the **reserve/service-side** security-constrained contingency layer. -POM already carries the **branch-side** foundation (`src/ac_transmission_models/security_constrained_branch.jl`, -outage population in `src/operation/template_validation.jl`, `SecurityConstrainedStaticBranch`, -`PostContingencyBranchFlow/FlowRateConstraint`, slack vars). So the POM port ≈ the #1617 diff -re-applied onto POM's layout. The three older `g-1` branches are a stale lineage already merged to -PSI `main` (and into POM) — **do not** port from them. - -Port from PSI branch `origin/sm/g-1_monitored_c`: -- **Formulations** (`core/formulations.jl`): `AbstractSecurityConstrainedReservesFormulation`, - `SecurityConstrainedContingencyReserve`, `SecurityConstrainedRampReserve`. -- **Variables** (`core/variables.jl`): `AbstractContingencySlackVariableType`, - `PostContingencyActivePowerReserveDeploymentVariable`; re-parent - `PostContingencyFlowActivePowerSlackUpper/LowerBound` under the slack supertype. -- **Expressions** (`core/expressions.jl`): `PostContingencyActivePowerGeneration`, - `PostContingencyAreaInterchangeFlow`, `PostContingencyAreaActivePowerDeployment` - (+ `should_write_resulting_value`/`convert_result_to_natural_units`). -- **Constraints** (`core/constraints.jl`): `PostContingencyActivePowerGenerationLimitsConstraint`, - `PostContingencyCopperPlateBalanceConstraint`, `PostContingencyGenerationBalanceConstraint`, - `PostContingencyRampConstraint`. -- **Definitions**: `POST_CONTINGENCY_CONSTRAINT_VIOLATION_SLACK_COST = 1e5`. -- **ServiceModel** (`core/service_model.jl`): `outages::Dict{UUID,Dict{DataType,Set{String}}}` field - + `get_outages` (outages attached as `PSY.Outage` supplemental attributes on the `PSY.Service`). -- **Template validation** (`operation/template_validation.jl`): `_build_service_model_outages!`, - `_sc_reserve_service_models`, `_service_skips_outage` (dispatch on `PSY.PlannedOutage`/`PSY.Outage`), - `_warn_outages_attached_to_unmodeled_services`; **widen `_monitored_components_by_modeled_type` - to admit `PSY.AreaInterchange`** (POM's copy currently admits only `PSY.ACTransmission`). -- **Reserves** (`services_models/reserves.jl`): SC `get_default_time_series_names(::Reserve, ::AbstractSecurityConstrainedReservesFormulation)`. -- **New file**: `services_models/static_injection_security_constrained_models.jl` (sparse - post-contingency containers keyed by `(outage_id, monitored_name, t)`; `construct_service!` - paths for CopperPlate / AreaBalance / PTDF / AreaPTDF). Register in the POM module + exports. -- **New test**: `test/test_static_injection_security_constrained_models.jl` (~2,580 lines; both - formulations × CopperPlate/AreaBalance/AreaPTDF/PTDF; slack on/off; parallel-circuit reduction; - mixed branch formulations). - -> Track #1617 to merge; if it changes in review, re-diff before porting. - ---- - -## Workstream M — merged-PR code gaps in POM (symbol-verified ABSENT) -Bugfixes and small features that landed in PSI after the fork and are not yet in POM. Each line: PR — what — where in POM. - -**Bugfixes (do first; small, low-risk):** -- **#1519** — TwoTerminalHVDC `AreaBalancePowerModel` `add_to_expression!` bug: uses - `get_expression(container, T, PSY.ACBus)` (should be `PSY.Area`), undefined `network_reduction`/`W`. - → `src/common_models/add_to_expression.jl:954`. -- **#1527** — `round(Float64, time_limits.up*steps_per_hour, RoundUp)` should drop the `Float64` - positional arg (align with `RoundingMode`). → `src/static_injector_models/thermal_generation.jl:1364`. -- **#1535** — VOM cost missing resolution scaling (issue #1531): add - `dt = Dates.value(resolution)/MILLISECONDS_IN_HOUR` to `_add_vom_cost_to_objective_helper!`. - → `src/common_models/market_bid_plumbing.jl:555`. (Unblocks the VOM-normalization MBC tests.) -- **#1587** — improve parallel-branch interface error message (list offending branch names). - → `src/common_models/add_to_expression.jl:2256` (minor). -- **#1508** — network-reduction/AreaInterchange fix: `modeled_branch_types`→`modeled_ac_branch_types` - and the `device_names_with_branches` undefined-constraint bug (`device_names = PSY.get_name.(devices)`). - → `src/area_interchange.jl` + reduction path. Adapt to POM's `instantiate_network_model.jl`. - -**Small features:** -- **#1549** — Source default TS names: `get_default_time_series_names(::Type{<:PSY.Source}, …)` - returns `Dict()` in POM; should map `ActivePowerOut/InTimeSeriesParameter => "max_active_power_out/in"`. - → `src/static_injector_models/source.jl`. -- **#1573** — Source `FixedOutput` constructor (all POM Source constructors are `ImportExportSourceModel`). - → `src/static_injector_models/source_constructor.jl`. (Unblocks "FixedOutput Source w/ PTDF+TS" test.) -- **#1538** — feedforward arguments for renewables: add `add_feedforward_arguments!` and - `get_parameter_multiplier(::VariableValueParameter, ::PSY.RenewableGen, ::AbstractRenewableFormulation)`. - → `src/static_injector_models/renewablegeneration_constructor.jl`, `renewable_generation.jl`. -- **#1614** — renewable curtailment incentive: flip `objective_function_multiplier(::AbstractRenewableDispatchFormulation)` - from `OBJECTIVE_FUNCTION_NEGATIVE` to `POSITIVE` and feed `curtailment_cost` into the dispatch - objective (not just the reporting `CurtailmentCostExpression`). → `src/static_injector_models/renewable_generation.jl:24`. - (Unblocks "curtailment_cost incentive affects dispatch" test.) -- **#1605** — AreaPTDF + `InterconnectingConverter`: replace - `error("AreaPTDFPowerModel doesn't support InterconnectingConverter")` with the area/DC-bus - injection `add_to_expression!`. → `src/mt_hvdc_models/HVDCsystems.jl:284`. -- **#1566** — headroom-proportional slack **recompute from results**: `_update_headroom_participation_factors!` - / `_accumulate_headroom!` / `get_active_power_limits_for_power_flow` absent. → `ext/PowerFlowsExt/` + network_models. -- **#1612 (POM part)** — split In/Out headroom-proportional slack: data-map is ported, but - `_accumulate_in_out_headroom!` / `_find_paired_out` / `_pf_in_out_discharge_max` absent (old - `# Skip storage devices for now` TODO still present). → `ext/PowerFlowsExt/pf_headroom.jl`. -- **#1622** — route reactive-power TS to AC PF on active-power-only network models: add - `_add_pf_only_time_series_parameters!` + `PF_ONLY_TS_PARAMS_BY_CATEGORY`, thread `template` into - `add_power_flow_data!` (currently `add_power_flow_data!(container, transmission_model, sys)` at - `src/operation/build_problem.jl:157`). → `ext/PowerFlowsExt/pf_input_mapping.jl`. (Unblocks "reactive power on PTDFPowerModel" test.) - -**Large feature — Dynamic Line Ratings (DLR), SPLIT POM+IOM:** -- **#1559 + #1561** — DLR entirely absent in both clones (distinct from the static - branch-rating-time-series feature POM already has). POM part: `DynamicBranchRatingTimeSeriesParameter`, - default `"dynamic_line_ratings"`, DLR network-reduction handling, `get_dynamic_branch_rating_min_max_limits`, - `get_equivalent_dynamic_branch_rating`. IOM part: generic param-type registration (see iom plan). - Substantial; scope as its own effort. - -**Verify (not symbol-pinned — diff-review before deciding):** -- **#1509** (remove old N-1/G-1 code — arch diverged), **#1613** (network bugfixes), **#1619** (pnm/pf - logic; mostly version bumps = PSI-only). Confirm whether anything material is missing. - ---- - -## Workstream T — formulation TEST ports (code present in POM) -These are test-only ports against existing POM code (biggest coverage win, low risk). Land one PR -per file. Annotated where a test is **code-blocked** by a Workstream M/C item. - -- **`test_services_constructor.jl`** (~23 testsets): RangeReserve (thermal dispatch/UC, renewable), - RampReserve, StepwiseCostReserve, reserves+slacks, participation-factor limits, service-level - feedforwards, GroupReserve + errors, ConstantReserve, AGC; Transmission Interface (Constant/Variable - MaxFlow, TS limits, feedforwards, validation-under-reductions, double-circuit, on AreaInterchange, - with AreaBalance, with AreaPTDF). Code all present (incl. AGC, more complete than PSI). -- **`test_network_constructors.jl`** (~23 testsets): PowerModels iteration (All-PowerModels, - CopperPlate, PTDF, NFA, ACR/ACT, DCPLL, Unsupported guard); Area networks (AreaBalance ±slacks - ±TimeSeries, AreaPTDF ±DoubleCircuit ±TimeSeries); HVDC subnetworks; reductions (Ward, radial, - PTDF StaticBranch/StaticBranchBounds, subnetwork, branch-filter edge cases, parallel/series bounds, - full PowerModels×reduction matrix ±slacks). Code present. -- **`test_power_flow_in_the_loop.jl`** Source paths (~6): in/out variables, Source FixedOutput - (⚠ needs #1573), reactive-on-PTDF (⚠ needs #1622), in/out headroom (⚠ needs #1612), LCC HVDC. -- **`test_market_bid_cost.jl`** equivalence subset: no-TS vs constant-TS (Thermal/Renewable/PowerLoad), - RenewableDispatch MBC, Renewable-vs-Thermal compare, time-varying slopes/breakpoints/min-gen/everything - (⚠ tranche-count + concavity need Workstream C), VOM normalization (⚠ needs #1535), 3d results, - heterogeneous TS names, single TS. -- **Small device drops**: MonitoredLine asymmetric flow limits (#1604 test path exists — confirm/port), - renewable curtailment incentive (⚠ needs #1614), FixedOutput Source+PTDF+TS (⚠ needs #1573). - ---- - -## Workstream C — Tier-0 code blockers in POM (port code, then test) -- **Event framework — ported, including the runtime interface.** `src/event_models/` - (`event_model.jl`, `event_traits.jl`, `event_arguments.jl`, `event_constraints.jl`, - `event_runtime.jl`) provides the `EventModel`/`AbstractEventCondition` family, template-level - `set_event_model!` attachment, build-time discovery and time-series validation, event - parameters/constraints for thermal, renewable, load, hydro, pump-turbine, and storage devices - (hydro and storage go beyond the original PSI-parity scope), and the pure runtime functions a - simulation calls between solves (`outage_occurred`, `time_to_recover`, `event_step_values`, - `countdown_trajectory`, `event_parameter_keys`, `required_inputs`/`is_triggered`). PSI #1664 - consumes that interface; PSI keeps the state arrays, clock, and RNG. `test/test_events.jl` and - `test/test_event_runtime.jl` cover construction, traits, attachment, discovery/validation - errors, exclusion from the initialization problem, per-device constraint coefficients, the - runtime arithmetic, and E2E build/solve including forced-zero output under a - `FixedForcedOutage` event. Two IOM-side changes landed for it: the `SupplementalAttribute` - type bound (IOM #162) and the `share_template_references!` template-copy hook (IOM #164). -- **SSS `EnergyTargetFeedforward` — ported** as the storage twin of - `ReservoirTargetFeedforward`; the two storage feedforward testsets are live. -- **MBC variable-tranche-count** and **MBC concavity/convexity validation** — absent; small code adds - that unblock the remaining time-varying-tranche and validation MBC tests. - ---- - -## Out of scope for POM (PSI-only — do NOT port) -Simulation orchestration (`test_simulation_*`, simulation_state/partitions/store/results-IO), -`test_model_emulation`, `test_recorder_events`, `test_print`, `test_jump_utils`, docs/tutorials, -CI, version bumps, org renames. PSI template-library helpers (`_copy_template_for_build`, -"provided templates", namespace-stable keys) are architecture-specific to PSI; `modeled_ac_branch_types` -is tracked in POM as the `branches_modeled` trait (already present). - ---- - -## Suggested execution order -1. **Workstream T (1a services, 1b network)** — pure test ports, existing code, biggest coverage win. -2. **Workstream M bugfixes** (#1519, #1527, #1535, #1587, #1508) — small, unblock MBC/network tests. -3. **Workstream M small features** (#1549, #1573, #1538, #1614, #1605, #1622, #1566, #1612) — then - port the PF-Source / MBC / curtailment tests they unblock. -4. **Workstream G1** (#1617) — track upstream merge; port reserve/service SC layer + its test file. -5. **Workstream C** — event framework, runtime interface, and their tests: **done**; IOM #162 and - #164 are merged and every IOM pin is back on `main`. MBC tranche/concavity → remaining MBC - tests still open. -6. **DLR (#1559/#1561)** and the **verify** items — scope separately. diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 8947ec39..bf31a7ab 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -680,7 +680,7 @@ export ShiftDownActivePowerVariable # G-1 Variables export PostContingencyActivePowerReserveDeploymentVariable -export PostContignencyAreaInterchangeFlowDeviationVariable +export PostContingencyAreaInterchangeFlowDeviationVariable ######## Hydro Formulations ######## export HydroDispatchRunOfRiver @@ -909,7 +909,6 @@ export PTDFBranchFlow export BThetaBranchFlow export PostContingencyNodalActivePowerDeployment export PostContingencyAreaActivePowerDeployment -export PostContingencyBranchFlow export PostContingencyAreaInterchangeFlow ################################################################################# @@ -1019,7 +1018,7 @@ export ReserveLimitedRegulation export DeviceLimitedRegulation # G-1 Formulations -export SecurityConstrainedContingency +export SecurityConstrainedContingencyReserve export SecurityConstrainedRampReserve ################################################################################# diff --git a/src/core/expressions.jl b/src/core/expressions.jl index e546daad..69e326c1 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -133,11 +133,6 @@ Per-area power considering post-contingency reserve deployment. """ struct PostContingencyAreaActivePowerDeployment <: ExpressionType end -""" -Per-branch flow considering post-contingency reserve deployment. -""" -struct PostContingencyBranchFlow <: ExpressionType end - """ Per-interchange flow considering post-contingency reserve deployment. """ diff --git a/src/core/variables.jl b/src/core/variables.jl index 659e285f..0e08e35f 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -853,7 +853,7 @@ struct PostContingencyActivePowerReserveDeploymentVariable <: VariableType end """ Post-contingency power exchange over an area interchange from security-constrained reserves. """ -struct PostContignencyAreaInterchangeFlowDeviationVariable <: VariableType end +struct PostContingencyAreaInterchangeFlowDeviationVariable <: VariableType end const MULTI_START_VARIABLES = (HotStartVariable, WarmStartVariable, ColdStartVariable) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 82a7f8eb..92f5db98 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -619,30 +619,6 @@ function _check_voltage_regulation_conflicts!( return end -################################################################################# -# Outage validation -################################################################################# - -_validate_reserve_direction(::ServiceModel{<:PSY.Reserve{PSY.ReserveUp}}) = nothing -_validate_reserve_direction(::ServiceModel{<:PSY.Reserve}) = throw(IS.ConflictingInputsError("Security-constrained reserves currently only support Reserve{ReserveUp}.")) - -function _build_service_model_outages!(template::IOM.AbstractProblemTemplate, sys::PSY.System) - sc_models = ServiceModel[m for m in values(get_service_models(template)) if m <: AbstractSecurityConstrainedReservesFormulation] - isempty(sc_models) && return - - modeled_types = Set{DataType}(get_component_types(template)) - uncovered_types = Dict{DataType, Set{Int}}() - - for model in sc_models - _validate_reserve_direction(model) - for (service_name, per_type) in get_contributing_devices_map(model) - isempty(per_type) && continue - #service = PSY.get_component( - end - - end -end - """ Populate `device_model.outages` for every security-constrained (SC) branch device model in the template, in a single pass over the system's outage diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 5a3c7058..33e040e6 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -7,38 +7,21 @@ const _OUTAGE_MAP = Dict{Int, _PER_TYPE} # TODO: separate? we aggregate across services for contributing devices because services across different templates may have different contributing devices. as for outaged generators and monitored lines which are just per-outage and keep no info about the services... I think it makes sense to group them and do them at the construct_serviceS level (plural) because the constraints e.g. on the monitored lines need to only be added once per outage, but if we do it at the construct_service (singular) level, then we are setting ourselves up to do it once per service per outage... it's a convenience thing # TODO: monitored lines? -""" -Collect all devices contributing to security-constrained reserves. -We perform this aggregation because an outage may be tied to reserves with different contributing -devices and different service models. -""" -function _security_constrained_contributing_devices(sys::PSY.System, services_template::ServicesModelContainer) - contributing_devices = _OUTAGE_MAP() - for model in values(services_template) - get_formulation(model) <: AbstractSecurityConstrainedReservesFormulation || continue - for uuid in keys(get_outages(model)), device in get_contributing_devices(model) - per_type = get!(contributing_devices, uuid, _PER_TYPE()) - c = get!(per_type, typeof(device), Set{String}()) - push!(c, PSY.get_name(device)) - end - end - return contributing_devices -end - -function _outaged_generators(sys::PSY.System, uuids::Vector{Int}) - outaged_generators = _OUTAGE_MAP(uuid => Dict{DataType, Set{String}}() for uuid in uuids) - for (generator, outage) in PSY.get_component_supplemental_attribute_pairs(PSY.Generator, PSY.Outage, sys) - uuid = IS.get_id(outage) - haskey(outaged_generators, uuid) || continue - c = get!(outaged_generators[uuid], typeof(generator), Set{String}()) - push!(c, PSY.get_name(generator)) - end - return outaged_generators -end +# Keeps G-1 post-contingency flow containers apart from the N-1 ones keyed on the same +# branch type. +const _G1_META = "G1" -_validate_reserve_formulation(::ServiceModel{Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve}, AbstractSecurityConstrainedReservesFormulation}) = true _validate_reserve_formulation(::ServiceModel) = false -_validate_reserve_formulation(::ServiceModel{<:PSY.AbstractReserve, AbstractSecurityConstrainedReservesFormulation}) = throw(IS.ConflictingInputsError("Security-constrained formulations currently only support ReserveUp.")) +_validate_reserve_formulation( + ::ServiceModel{<:PSY.Reserve{PSY.ReserveUp}, <:AbstractSecurityConstrainedReservesFormulation}, +) = true +_validate_reserve_formulation( + ::ServiceModel{<:PSY.AbstractReserve, <:AbstractSecurityConstrainedReservesFormulation}, +) = throw( + IS.ConflictingInputsError( + "Security-constrained formulations currently only support Reserve{ReserveUp}.", + ), +) _valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetworkModel}) = true _valid_component_type(::PSY.AreaInterchange, ::NetworkModel{AreaBalanceNetworkModel}) = true @@ -46,42 +29,91 @@ _valid_component_type(::PSY.Component, ::NetworkModel) = false # TODO move to template_validation or keep here? # TODO: split between ptdf and area? -function _monitored_components(sys::PSY.System, services_template::ServicesModelContainer, network_model::NetworkModel) - components = _OUTAGE_MAP() +""" +Per outage attached to a security-constrained reserve: the devices contributing to any +reserve responding to it, the outaged generators, and +the available, modeled monitored components. + +Contributing devices are aggregated across services because one outage may be tied to +reserves with different contributing devices and different service models. +""" +function _security_constrained_outages( + sys::PSY.System, + services_template::ServicesModelContainer, + network_model::NetworkModel, +) + contributing_devices = _OUTAGE_MAP() + outaged_generators = _OUTAGE_MAP() + monitored_components = _OUTAGE_MAP() + modeled_types = network_model.modeled_branch_types for model in values(services_template) _validate_reserve_formulation(model) || continue + service_type = get_component_type(model) # TODO: validate that its nonempty? - for service in get_available_components(sys, model) + for (service_name, per_type) in get_contributing_devices_map(model) + service = PSY.get_component(service_type, sys, service_name) for outage in PSY.get_supplemental_attributes(PSY.Outage, service) - outage_uuid = IS.get_id(outage) - per_type = PER_TYPE() + uuid = IS.get_id(outage) + devices = get!(_PER_TYPE, contributing_devices, uuid) + for (device_type, ds) in per_type + union!(get!(Set{String}, devices, device_type), PSY.get_name.(ds)) + end + + haskey(outaged_generators, uuid) && continue + outaged = outaged_generators[uuid] = _PER_TYPE() + for generator in PSY.get_associated_components( + sys, + outage; + component_type = PSY.Generator, + ) + push!(get!(Set{String}, outaged, typeof(generator)), PSY.get_name(generator)) + end + + monitored = monitored_components[uuid] = _PER_TYPE() for component_uuid in PSY.get_monitored_components(outage) component = IS.get_component(sys, component_uuid) _valid_component_type(component, network_model) || continue - names = get!(per_type, typeof(component), Set{String}) - push!(names, PSY.get_name(component)) + PSY.get_available(component) || continue + typeof(component) in modeled_types || continue + push!( + get!(Set{String}, monitored, typeof(component)), + PSY.get_name(component), + ) end - components[outage_uuid] = per_type end end end - return components + return contributing_devices, outaged_generators, monitored_components end function _create_post_contingency_reserve_variables!( container::OptimizationContainer, contributing_devices::_OUTAGE_MAP, - outaged_gens::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, ) jump_model = get_jump_model(container) + time_steps = get_time_steps(container) for (uuid, per_type) in contributing_devices - for (device_type, names) in per_device - var = lazy_add_container!(container, PostContingencyActivePowerReserveDeploymentVariable, device_type, String[], Int[], Int[]; sparse = true) + for (device_type, names) in per_type + var = lazy_container_addition!( + container, + PostContingencyActivePowerReserveDeploymentVariable, + device_type, + String[], + Int[], + Int[]; + sparse = true, + ) + outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names - (device_type, name) in outaged_generators[uuid] && continue + name in outaged && continue for t in time_steps - v = var[name, uuid, t] = JuMP.@variable(jump_model, base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(device_type)_{$(name), $(uuid), $(t)}") # TODO bounds? start? + var[name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(device_type)_{$(name), $(uuid), $(t)}", + lower_bound = 0.0, + ) end end end @@ -91,7 +123,8 @@ end function _modeled_interchange_names(container::OptimizationContainer) if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) - @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 + @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = + LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 String[] else var = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) @@ -99,22 +132,41 @@ function _modeled_interchange_names(container::OptimizationContainer) end end +# Runs in the ModelConstructStage: the AreaInterchange flow variables are created in the +# branch ArgumentConstructStage, which runs after the services one. function _create_post_contingency_interchange_variables!( container::OptimizationContainer, uuids::Vector{Int}, ::NetworkModel{AreaBalanceNetworkModel}, ) names = _modeled_interchange_names(container) - isempty(names) && return + (isempty(names) || isempty(uuids)) && return + jump_model = get_jump_model(container) time_steps = get_time_steps(container) - var = add_variable_container!(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange, names, uuids, time_steps) + var = add_variable_container!( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + names, + uuids, + time_steps, + ) for name in names, uuid in uuids, t in time_steps - var[name, uuid, t] = JuMP.@variable(jump_model, base_name = "PostContignencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", start = 0.0) + var[name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", + start = 0.0, + ) end + return end -_create_post_contingency_interchange_variables!(::OptimizationContainer, ::Vector, ::NetworkModel) = nothing +_create_post_contingency_interchange_variables!( + ::OptimizationContainer, + ::Vector{Int}, + ::NetworkModel, +) = nothing _deployment_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment _deployment_expression(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaActivePowerDeployment @@ -152,46 +204,62 @@ function _build_post_contingency_locational_power!( return end +# Monitored names resolve to their reduction entry, the row the pre-contingency +# `PTDFBranchFlow` and the post-contingency expression are keyed by. function _build_post_contingency_flow!( container::OptimizationContainer, - monitored_lines::_OUTAGE_MAP, - ::NetworkModel{<:AbstractPTDFNetworkModel} + monitored_components::_OUTAGE_MAP, + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, ) + time_steps = get_time_steps(container) catalog = get_branch_catalog(network_model) - reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, M) - name_to_arc_map = PNM.get_name_to_arc_map(catalog, M) - - ptdf = get_PTDF_matrix(network_model) + ptdf = get_network_matrix(network_model) bus_axis = PNM.get_bus_axis(ptdf) nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus) + buses = Dict{Int, Vector{String}}() + for (bus, uuid, t) in keys(nodal.data) + t == first(time_steps) && push!(get!(Vector{String}, buses, uuid), bus) + end - dfs = Dict{String, Vector{Float64}}() - for (uuid, per_type) in monitored_lines + # Signed distribution factors per monitored branch, keyed by the nodal bus key. + dfs = Dict{Tuple{DataType, String}, Dict{String, Float64}}() + for (uuid, per_type) in monitored_components + outage_buses = get(buses, uuid, String[]) for (line_type, names) in per_type - expr = lazy_add_container!(container, PostContingencyBranchFlow, line_type, String[], Int[], Int[]; sparse = true) + expr = lazy_container_addition!( + container, + PostContingencyBranchFlow, + line_type, + String[], + Int[], + Int[]; + sparse = true, + meta = _G1_META, + ) # TODO is pre_flow needed when nodal already considers active power? pre_flow = get_expression(container, PTDFBranchFlow, line_type) - - # Build distribution factor cache (if needed) + arc_map = PNM.get_name_to_arc_map(catalog, line_type) + reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, line_type) for name in names - (line_type, name) in dfs && continue - arc = name_to_arc_map[reduction_name_map[name]] - ptdf_col = ptdf[arc, :] - sign = get_ptdf_orientation_sign(catalog, line_type, name) - for bus in buses + entry_name = haskey(arc_map, name) ? name : reduction_name_map[name] + # Build distribution factor cache (if needed) + df = get!(dfs, (line_type, name)) do + ptdf_col = ptdf[arc_map[entry_name], :] + sign = get_ptdf_orientation_sign(catalog, line_type, name) # TODO: am I indexing the bus correctly? - df = sign * ptdf_col[bus_axis[bus]] - abs(df) < PTDF_ZERO_TOL && continue - push!(dfs[name], df) + Dict{String, Float64}( + string(bus_axis[i]) => sign * ptdf_col[i] for + i in eachindex(ptdf_col) if abs(ptdf_col[i]) > PTDF_ZERO_TOL + ) end - end - - for name in names, t in time_steps - ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, pre_flow) - for bus in buses - JuMP.add_to_expression!(ex, dfs[name][bus], nodal[bus, uuid, t]) + for t in time_steps + ex = expr[entry_name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, pre_flow[entry_name, t]) + for bus in outage_buses + haskey(df, bus) || continue + JuMP.add_to_expression!(ex, df[bus], nodal[bus, uuid, t]) + end end end end @@ -201,44 +269,88 @@ end function _build_post_contingency_flow!( container::OptimizationContainer, - monitored_interchanges::_OUTAGE_MAP, + monitored_components::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) - expr = add_expression_container!(container, PostContingencyAreaInterchangeFlow, PSY.AreaInterchange, String[], Int[], Int[]; sparse = true) + has_container_key( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) || return + time_steps = get_time_steps(container) flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) - deviation = get_variable(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange) - for (uuid, per_type) in monitored_interchanges - for name in per_type[PSY.AreaInterchange], t in time_steps - ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, flow[name, t]) - JuMP.add_to_expression!(ex, deviation[name, uuid, t]) + deviation = get_variable( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) + modeled = Set{String}(axes(deviation, 1)) + expr = add_expression_container!( + container, + PostContingencyAreaInterchangeFlow, + PSY.AreaInterchange, + String[], + Int[], + Int[]; + sparse = true, + meta = _G1_META, + ) + for (uuid, per_type) in monitored_components + for name in get(per_type, PSY.AreaInterchange, Set{String}()) + name in modeled || throw( + IS.ConflictingInputsError( + "Monitored AreaInterchange $(name) has no modeled flow; it is excluded by the AreaInterchange DeviceModel's filter_function.", + ), + ) + for t in time_steps + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, flow[name, t]) + JuMP.add_to_expression!(ex, deviation[name, uuid, t]) + end end end return end +_build_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkModel) = + nothing + function _constrain_post_contingency_balance!( container::OptimizationContainer, + ::PSY.System, contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, - ::NetworkModel{<:AbstractPTDFNetworkModel}, + ::NetworkModel, ) - uuids = collect(keys(contributing_devices)) + uuids = sort!(collect(keys(contributing_devices))) time_steps = get_time_steps(container) - cons = add_constraints_container!(container, PostContingencyGenerationBalanceConstraint, PSY.System, uuids, time_steps) + jump_model = get_jump_model(container) + cons = add_constraints_container!( + container, + PostContingencyGenerationBalanceConstraint, + PSY.System, + uuids, + time_steps, + ) - for uuid in keys(contributing_devices), t in time_steps + for uuid in uuids, t in time_steps balance = JuMP.AffExpr(0.0) for (generator_type, names) in outaged_generators[uuid] power = get_variable(container, ActivePowerVariable, generator_type) for name in names - JuMP.add_to_expression!(balance, -power[name, t]) + JuMP.add_to_expression!(balance, -1.0, power[name, t]) end end for (device_type, names) in contributing_devices[uuid] - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + reserve = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable, + device_type, + ) + outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names - JuMP.add_to_expression!(balance, reserve[name, t]) + name in outaged && continue + JuMP.add_to_expression!(balance, reserve[name, uuid, t]) end end @@ -250,45 +362,100 @@ end # TODO Why does each area listen to every outage? but not every bus? function _constrain_post_contingency_balance!( container::OptimizationContainer, + sys::PSY.System, contributing_devices::_OUTAGE_MAP, - outaged_generators::_OUTAGE_MAP, + ::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) + uuids = sort!(collect(keys(contributing_devices))) + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + + # Area name => (sign, interchange name); flow leaving an area counts against it. interchanges = Dict{String, Vector{Tuple{Float64, String}}}() - for interchange in PSY.get_components(PSY.AreaInterchange, sys) - name = PYS.get_name(interchange) - name in modeled_interchange_areas || continue - push!(get!(interchanges, PSY.get_name(PSY.get_from_area(interchange)), Vector{Tuple{Float64, String}}()), (-1.0, name)) - push!(get!(interchanges, PSY.get_name(PSY.get_to_area(interchange)), Vector{Tuple{Float64, String}}()), (1.0, name)) + has_deviation = has_container_key( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) + if has_deviation + deviation = get_variable( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) + modeled = Set{String}(axes(deviation, 1)) + for interchange in PSY.get_components(PSY.AreaInterchange, sys) + name = PSY.get_name(interchange) + name in modeled || continue + from_area = PSY.get_name(PSY.get_from_area(interchange)) + to_area = PSY.get_name(PSY.get_to_area(interchange)) + push!(get!(Vector{Tuple{Float64, String}}, interchanges, from_area), (-1.0, name)) + push!(get!(Vector{Tuple{Float64, String}}, interchanges, to_area), (1.0, name)) + end end - deployment = get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) + deployment = + get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) - for area_name in names - exchange = JuMP.AffExpr(0.0) - for (sign, interchange_name) in interchanges[area_name] - JuMP.add_to_expression!(exchange, sign, deviation[interchange_name, uuid, t]) + area_names = PSY.get_name.(PSY.get_components(PSY.Area, sys)) + cons = add_constraints_container!( + container, + PostContingencyGenerationBalanceConstraint, + PSY.Area, + area_names, + uuids, + time_steps, + ) + for area_name in area_names, uuid in uuids, t in time_steps + balance = JuMP.AffExpr(0.0) + if haskey(deployment.data, (area_name, uuid, t)) + JuMP.add_to_expression!(balance, deployment[area_name, uuid, t]) end - JuMP.@constraint(jump_model, deployment[area_name, uuid, t] + exchange == 0.0) + for (sign, interchange_name) in get(interchanges, area_name, ()) + JuMP.add_to_expression!(balance, sign, deviation[interchange_name, uuid, t]) + end + cons[area_name, uuid, t] = JuMP.@constraint(jump_model, balance == 0.0) end + return end function _constrain_post_contingency_generation!( container::OptimizationContainer, sys::PSY.System, - outaged_gens::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, contributing_devices::_OUTAGE_MAP, ) jump_model = get_jump_model(container) + time_steps = get_time_steps(container) for (uuid, per_type) in contributing_devices for (device_type, names) in per_type - cons = lazy_add_container!(container, PostContingencyActivePowerGenerationLimitsConstraint, device_type, String[], Int[], Int[]; sparse = true) + cons = lazy_container_addition!( + container, + PostContingencyActivePowerGenerationLimitsConstraint, + device_type, + String[], + Int[], + Int[]; + sparse = true, + ) power = get_variable(container, ActivePowerVariable, device_type) - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + reserve = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable, + device_type, + ) + outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names - (device_type, name) in outaged_gens[uuid] && continue - limit = PSY.get_max_active_power(PSY.get_component(device_type, sys, name)) + name in outaged && continue + limit = PSY.get_max_active_power( + PSY.get_component(device_type, sys, name), + PSY.SU, + ) for t in time_steps - cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, t] <= limit) + cons[name, uuid, t] = JuMP.@constraint( + jump_model, + power[name, t] + reserve[name, uuid, t] <= limit + ) end end end @@ -322,26 +489,90 @@ function _constrain_post_contingency_reserve!( return end + +_post_contingency_flow_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = + PostContingencyBranchFlow +_post_contingency_flow_expression(::NetworkModel{AreaBalanceNetworkModel}) = + PostContingencyAreaInterchangeFlow + +function _post_contingency_flow_limits( + ::PSY.System, + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, + ::Type{T}, + entry_name::String, +) where {T <: PSY.ACTransmission} + catalog = get_branch_catalog(network_model) + arc = PNM.get_name_to_arc_map(catalog, T)[entry_name] + return _emergency_flow_limits(PNM.get_reduction_entry(catalog, arc)) +end + +function _post_contingency_flow_limits( + sys::PSY.System, + ::NetworkModel{AreaBalanceNetworkModel}, + ::Type{PSY.AreaInterchange}, + name::String, +) + limits = PSY.get_flow_limits(PSY.get_component(PSY.AreaInterchange, sys, name), PSY.SU) + return (min = -limits.to_from, max = limits.from_to) +end + # TODO Why do we support area interchanges here but not on N-1? # TODO refactor with add_constraints!(PostContingencyFlowRate, ACTransmission) from N-1? # TODO I think we don't need to check for shared sources because we're creating at the create_services level +# TODO: slacks? function _constrain_post_contingency_flow!( container::OptimizationContainer, + sys::PSY.System, monitored_components::_OUTAGE_MAP, - network_model::NetworkModel{Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, + network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) - for (uuid, per_type) in monitored_components - for (component_type, names) in per_type - limits = _flow_limits(network_model) - # TODO: slacks? - for name in names, t in time_steps - cons_ub[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] <= limits.max) - cons_lb[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] >= limits.min) + jump_model = get_jump_model(container) + expression_type = _post_contingency_flow_expression(network_model) + component_types = Set{DataType}() + for per_type in values(monitored_components) + union!(component_types, keys(per_type)) + end + for component_type in component_types + has_container_key(container, expression_type, component_type, _G1_META) || + continue + flow = get_expression(container, expression_type, component_type, _G1_META) + cons_lb = add_constraints_container!( + container, + PostContingencyFlowRateConstraint, + component_type, + String[], + Int[], + Int[]; + sparse = true, + meta = "$(_G1_META)_lb", + ) + cons_ub = add_constraints_container!( + container, + PostContingencyFlowRateConstraint, + component_type, + String[], + Int[], + Int[]; + sparse = true, + meta = "$(_G1_META)_ub", + ) + limits = Dict{String, NamedTuple{(:min, :max), Tuple{Float64, Float64}}}() + for (name, uuid, t) in keys(flow.data) + lims = get!(limits, name) do + _post_contingency_flow_limits(sys, network_model, component_type, name) end + cons_ub[name, uuid, t] = + JuMP.@constraint(jump_model, flow[name, uuid, t] <= lims.max) + cons_lb[name, uuid, t] = + JuMP.@constraint(jump_model, flow[name, uuid, t] >= lims.min) end end return end -_constrain_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkModel) = nothing - +_constrain_post_contingency_flow!( + ::OptimizationContainer, + ::PSY.System, + ::_OUTAGE_MAP, + ::NetworkModel, +) = nothing diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 536c6b7a..3a20d0ff 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -139,11 +139,9 @@ function construct_services!( ) end - contributing_devices = _security_constrained_contributing_devices(sys, services_template) - uuids = collect(keys(contributing_devices)) - outaged_generators = _outaged_generators(sys, uuids) + contributing_devices, outaged_generators, _ = + _security_constrained_outages(sys, services_template, network_model) _create_post_contingency_reserve_variables!(container, contributing_devices, outaged_generators) - _create_post_contingency_interchange_variables!(container, uuids, network_model) return end @@ -187,14 +185,15 @@ function construct_services!( ) end - contributing_devices = _security_constrained_contributing_devices(sys, services_template) # TODO: combine into prev function - uuids = collect(keys(contributing_devices)) - monitored_components = _monitored_components(sys, services_template, network_model) - outaged_generators = _outaged_generators(sys, uuids) - _constrain_post_contingency_balance!(container, contributing_devices, outaged_generators, network_model) - _constrain_post_contingency_generation!(container, sys, contributing_devices, outaged_generators) - _constrain_post_contingency_flow!(container, monitored_components, network_model) + contributing_devices, outaged_generators, monitored_components = + _security_constrained_outages(sys, services_template, network_model) + uuids = sort!(collect(keys(contributing_devices))) + _create_post_contingency_interchange_variables!(container, uuids, network_model) + _build_post_contingency_flow!(container, monitored_components, network_model) + _constrain_post_contingency_balance!(container, sys, contributing_devices, outaged_generators, network_model) + _constrain_post_contingency_generation!(container, sys, outaged_generators, contributing_devices) + _constrain_post_contingency_flow!(container, sys, monitored_components, network_model) return end diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 6bbbfffb..01df29b4 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -76,9 +76,9 @@ function get_outage_total_power_by_step_dict( col_name::String = "name", ) required_variables = variables[var_name] - total_variable_dict = Dict{String, Vector{Float64}}() + total_variable_dict = Dict{Int, Vector{Float64}}() for outage in associated_outages - outage_name = string(IS.get_id(outage)) + outage_name = IS.get_id(outage) outage_power_v = Vector{Float64}() devices = PSY.get_associated_components( sys, @@ -108,19 +108,21 @@ function get_reserve_total_power_by_step_dict( IS.FlattenIteratorWrapper{<:PSY.Generator}, Vector{<:PSY.Generator}, }; - col_name::String = "name2", + col_name::String = "name", ) required_variables = variables[var_name] - total_variable_dict = Dict{String, Vector{Float64}}() + total_variable_dict = Dict{Int, Vector{Float64}}() for outage in associated_outages - outage_name = string(IS.get_uuid(outage)) - outage_rows = filter(x -> x["name"] == outage_name, required_variables) + outage_name = IS.get_id(outage) + outage_rows = filter(x -> x["name2"] == outage_name, required_variables) outage_power_v = Vector{Float64}() - for (i, device) in enumerate(contributing_devices) + for device in contributing_devices device_name = PSY.get_name(device) current_v = filter(x -> x[col_name] == device_name, outage_rows)[!, "value"] - if i == 1 + # The outaged generator has no deployment rows. + isempty(current_v) && continue + if isempty(outage_power_v) outage_power_v = current_v else outage_power_v .+= current_v @@ -159,17 +161,15 @@ function compare_outage_power_and_deployed_reserves( col_name = "name", ) contributing_devices = PSY.get_contributing_devices(sys, service) - service_name = PSY.get_name(service) - @show variablesdict reserve_dict = get_reserve_total_power_by_step_dict( variablesdict, - "PostContingencyActivePowerReserveDeploymentVariable_", + "PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard", associated_outages, contributing_devices; - col_name = "name2", + col_name = "name", ) for outage in associated_outages - outage_name = string(IS.get_uuid(outage)) + outage_name = IS.get_id(outage) # Guard against a vacuous `0 == 0` pass: if the outaged unit never # dispatches, both sides of the identity are trivially zero and the # check below verifies nothing. `require_positive_outage = false` @@ -206,12 +206,6 @@ function compare_outage_power_and_deployed_reserves( return nothing end -# Shorthand for the `IOM.ConstraintKey(T, PSY.OnlineReserve{ReserveUp}, name)` -# pattern that dominates the constraint-key lists below. -function sc_key(constraint_type::DataType, name::String) - return IOM.ConstraintKey(constraint_type, PSY.OnlineReserve{ReserveUp}, name) -end - # Shared tail for the SC reserve deliverability testsets: build, check # constraint keys, MOI counts, objective function type, solve status/value, # and (when `reserve_names` is non-empty) that deployed reserves match the @@ -252,7 +246,7 @@ function run_sc_reserve_case!( return nothing end -# Shared zone-balance verification for the `AreaBalancePowerModel` testsets: +# Shared zone-balance verification for the `AreaBalanceNetworkModel` testsets: # the monitored "1_2" tie stays within its flow limits, and each area's # generation plus load nets to zero against the tie flow. function verify_zone_balance!( @@ -334,16 +328,16 @@ end ), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - sc_key(RequirementConstraint, "Reserve1"), - sc_key(RampConstraint, "Reserve1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1", + PSY.ThermalStandard, ), ] gen = get_component(ThermalStandard, sys, "Solitude") @@ -399,15 +393,15 @@ end ), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - sc_key(RequirementConstraint, "Reserve1"), - sc_key(RampConstraint, "Reserve1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1", + PSY.ThermalStandard, ), ] # Counts are smaller than the all-lines baseline `[360, 0, 600, 432, 72]` @@ -469,15 +463,15 @@ end ), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - sc_key(RequirementConstraint, "Reserve1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1", + PSY.ThermalStandard, ), ] reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") @@ -530,12 +524,15 @@ end ), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), - sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_ub"), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.ThermalStandard, + ), ] gen = get_component(ThermalStandard, sys, "Solitude") set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) #Increase ramp limits to make the problem feasible @@ -646,11 +643,10 @@ end @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT constraints = ps_model.internal.container.constraints - flow_rate_cons = constraints[IOM.ConstraintKey{ + flow_rate_cons = constraints[IOM.ConstraintKey( PostContingencyFlowRateConstraint, - OnlineReserve{ReserveUp}, - }( - "Reserve1_lb", + PSY.Line, + "G1_lb", )] @test length(flow_rate_cons) == 1 * 5 * 24 end @@ -700,25 +696,16 @@ end IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve11_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve11_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - sc_key(RequirementConstraint, "Reserve1"), - sc_key(RequirementConstraint, "Reserve11"), - sc_key(RampConstraint, "Reserve1"), - sc_key(RampConstraint, "Reserve11"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve11"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve11", + PSY.ThermalStandard, ), ] component = get_component(ThermalStandard, sys, "Alta") @@ -736,12 +723,6 @@ end ThermalStandardUnitCommitment, ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -768,24 +749,15 @@ end IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key(RampConstraint, "Reserve1_1"), - sc_key(RampConstraint, "Reserve1_2"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), ] components_outages_names, reserve_names = @@ -805,12 +777,6 @@ end template = get_thermal_dispatch_template_network( NetworkModel(AreaPTDFNetworkModel), ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve; - use_slacks = reserve_slack, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -850,24 +816,15 @@ end IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key(RampConstraint, "Reserve1_1"), - sc_key(RampConstraint, "Reserve1_2"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), ] # Counts are smaller than the all-lines baseline `[744, 0, 1536, 1200, 120]` @@ -897,11 +854,6 @@ end template = get_thermal_dispatch_template_network( NetworkModel(AreaPTDFNetworkModel), ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -927,22 +879,14 @@ end IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), ] components_outages_names, reserve_names = @@ -960,12 +904,6 @@ end template = get_thermal_dispatch_template_network( NetworkModel(AreaPTDFNetworkModel), ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve; - use_slacks = reserve_slack, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1000,16 +938,15 @@ end IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), - sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.ThermalStandard, + ), ] components_outages_names, reserve_names = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) @@ -1046,11 +983,6 @@ end template = get_thermal_dispatch_template_network( NetworkModel(AreaPTDFNetworkModel), ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1070,25 +1002,18 @@ end ) end -@testset "G-n with Ramp reserve deliverability constraints with CopperPlatePowerModel" begin +@testset "G-n with Ramp reserve deliverability constraints with CopperPlateNetworkModel" begin sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) transform_single_time_series!(sys, Hour(24), Hour(1)) constraint_keys = [ IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key(RampConstraint, "Reserve1_1"), - sc_key(RampConstraint, "Reserve1_2"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), ] components_outages_names, reserve_names = @@ -1101,13 +1026,8 @@ end end template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlatePowerModel), + NetworkModel(CopperPlateNetworkModel), ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1127,23 +1047,17 @@ end ) end -@testset "G-n with Contingency reserve deliverability constraints with CopperPlatePowerModel with Reserve Requirement" begin +@testset "G-n with Contingency reserve deliverability constraints with CopperPlateNetworkModel with Reserve Requirement" begin sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) transform_single_time_series!(sys, Hour(24), Hour(1)) constraint_keys = [ IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), ] components_outages_names, reserve_names = @@ -1156,13 +1070,8 @@ end end template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlatePowerModel), + NetworkModel(CopperPlateNetworkModel), ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1182,14 +1091,18 @@ end ) end -@testset "G-n with Contingency reserve deliverability constraints with CopperPlatePowerModel with NO Reserve Requirement" begin +@testset "G-n with Contingency reserve deliverability constraints with CopperPlateNetworkModel with NO Reserve Requirement" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) constraint_keys = [ IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1"), - sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_ub")] + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), + IOM.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.ThermalStandard, + ), + ] reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") remove_time_series!( sys, @@ -1201,7 +1114,7 @@ end attach_geometric_outage!(sys, component, [reserve_up]) template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlatePowerModel), + NetworkModel(CopperPlateNetworkModel), ) set_service_model!(template, ServiceModel( @@ -1227,24 +1140,15 @@ end constraint_keys = [ IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key(RampConstraint, "Reserve1_1"), - sc_key(RampConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), - sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_2"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_ub"), ] c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) @@ -1267,14 +1171,9 @@ end ) end - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) set_device_model!(template, AreaInterchange, StaticBranch) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1306,26 +1205,18 @@ end compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) end -@testset "G-n with Contingency reserve deliverability constraints with AreaBalancePowerModel with Reserve Requirement" begin +@testset "G-n with Contingency reserve deliverability constraints with AreaBalanceNetworkModel with Reserve Requirement" begin constraint_keys = [ IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - sc_key(RequirementConstraint, "Reserve1_1"), - sc_key(RequirementConstraint, "Reserve1_2"), - sc_key( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_1", - ), - sc_key( + IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), + IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - "Reserve1_2", + PSY.ThermalStandard, ), - sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_2"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_ub"), ] c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) @@ -1348,14 +1239,9 @@ end ) end - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) set_device_model!(template, AreaInterchange, StaticBranch) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1387,18 +1273,17 @@ end compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) end -@testset "G-n with Contingency reserve deliverability constraints with AreaBalancePowerModel with NO Reserve Requirement" begin +@testset "G-n with Contingency reserve deliverability constraints with AreaBalanceNetworkModel with NO Reserve Requirement" begin constraint_keys = [ IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyActivePowerGenerationLimitsConstraint, "Reserve1_2_ub"), - sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_1"), - sc_key(PostContingencyCopperPlateBalanceConstraint, "Reserve1_2"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_1_ub"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_lb"), - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_ub"), + IOM.ConstraintKey( + PostContingencyActivePowerGenerationLimitsConstraint, + PSY.ThermalStandard, + ), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_ub"), ] c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) @@ -1437,14 +1322,9 @@ end ) end - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) set_device_model!(template, AreaInterchange, StaticBranch) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) set_service_model!(template, ServiceModel( OnlineReserve{ReserveUp}, @@ -1494,7 +1374,7 @@ end # reserve that should respond. Reserve1_2 is intentionally NOT attached. alta1 = get_component(ThermalStandard, sys, "Alta_1") transition_data = attach_geometric_outage!(sys, alta1, [reserve1]) - outage_uuid = IS.get_uuid(transition_data) + outage_id = IS.get_id(transition_data) template = get_thermal_dispatch_template_network( NetworkModel(AreaPTDFNetworkModel), @@ -1504,57 +1384,47 @@ end OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, )) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - # --- Unit-level: attachment scoping populates only the responding ServiceModel --- - IOM._build_service_model_outages!(template, sys) - services = IOM.get_service_models(template) - sm1 = services[("Reserve1_1", Symbol(OnlineReserve{ReserveUp}))] - sm2 = services[("Reserve1_2", Symbol(OnlineReserve{ReserveUp}))] - @test haskey(sm1.outages, outage_uuid) - @test !haskey(sm2.outages, outage_uuid) - - # --- Build-level: post-contingency constraints fire only on Reserve1_1 --- + # Constructing the model runs `finalize_template!`, which fills the contributing map. ps_model = DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) + built_template = IOM.get_template(ps_model) + + # --- Unit-level: only Reserve1_1's contributing devices respond to the outage --- + contributing_devices, _, _ = POM._security_constrained_outages( + sys, + IOM.get_service_models(built_template), + IOM.get_network_model(built_template), + ) + @test collect(keys(contributing_devices)) == [outage_id] + reserve1_devices = Set( + PSY.get_name(d) for + d in PSY.get_contributing_devices(sys, reserve1) if d isa ThermalStandard + ) + @test contributing_devices[outage_id][ThermalStandard] == reserve1_devices + + # --- Build-level: one outage, one balance row per time step --- @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(ps_model) - @test IOM.has_container_key( - container, - PostContingencyGenerationBalanceConstraint, - PSY.OnlineReserve{ReserveUp}, - "Reserve1_1", - ) - @test !IOM.has_container_key( - container, - PostContingencyGenerationBalanceConstraint, - PSY.OnlineReserve{ReserveUp}, - "Reserve1_2", - ) cons_resp = IOM.get_constraint( container, - sc_key(PostContingencyGenerationBalanceConstraint, "Reserve1_1"), + IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), ) @test size(cons_resp) == (1, 24) end # 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. +# generator and the reserve must be discovered, with the outaged generator recorded. @testset "SC reserve outage attachment covers single-reserve case" begin sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) alta = get_component(ThermalStandard, sys, "Alta") reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") transition_data = attach_geometric_outage!(sys, alta, [reserve_up]) - outage_uuid = IS.get_uuid(transition_data) + outage_id = IS.get_id(transition_data) template = get_thermal_dispatch_template_network( NetworkModel(PTDFNetworkModel), @@ -1564,16 +1434,21 @@ end OnlineReserve{ReserveUp}, SecurityConstrainedRampReserve, )) + ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + built_template = IOM.get_template(ps_model) - IOM._build_service_model_outages!(template, sys) - - services = IOM.get_service_models(template) - sm = services[("Reserve1", Symbol(OnlineReserve{ReserveUp}))] - @test haskey(sm.outages, outage_uuid) + contributing_devices, outaged_generators, _ = POM._security_constrained_outages( + sys, + IOM.get_service_models(built_template), + IOM.get_network_model(built_template), + ) + @test haskey(contributing_devices, outage_id) + @test outaged_generators[outage_id] == + Dict{DataType, Set{String}}(ThermalStandard => Set(["Alta"])) end # ---------------------------------------------------------------------------- -# Regression tests for the AreaBalancePowerModel post-contingency +# Regression tests for the AreaBalanceNetworkModel post-contingency # reformulation (PR #1617 review remediation, task 1): the per-area balance # no longer references the pre-contingency `ActivePowerBalance` expression, # and inter-area rebalancing flows through a free @@ -1586,15 +1461,15 @@ end # ---------------------------------------------------------------------------- # `PostContingencyAreaInterchangeFlowDeviationVariable` is dense over -# (outage_id, area_interchange_name, t), so `read_variable` returns separate -# "name"/"name2" columns (outage, tie) like the reserve deployment variable. +# (area_interchange_name, outage_id, t), so `read_variable` returns separate +# "name"/"name2" columns (tie, outage) like the reserve deployment variable. # Filter to one (outage, tie) pair and return the value series sorted by time. function _sc_flow_deviation_series( df::DataFrame, - outage_id::String, + outage_id::Int, monitored_name::String, ) - rows = filter(x -> x["name"] == outage_id && x["name2"] == monitored_name, df) + rows = filter(x -> x["name2"] == outage_id && x["name"] == monitored_name, df) sorted = sort(rows, :DateTime) return sorted[!, "value"] end @@ -1602,8 +1477,8 @@ end # Sum `PostContingencyActivePowerReserveDeploymentVariable` across # contributing devices for one outage, sorted by time. Shared by the # cross-area deployment/Δf identity checks below. -function total_deployment_series(df::DataFrame, outage_id::String) - outage_rows = filter(x -> x["name"] == outage_id, df) +function total_deployment_series(df::DataFrame, outage_id::Int) + outage_rows = filter(x -> x["name2"] == outage_id, df) total_by_t = combine(groupby(outage_rows, :DateTime), :value => sum => :dep) sort!(total_by_t, :DateTime) return total_by_t.dep @@ -1628,12 +1503,12 @@ function add_parallel_tie!(sys::PSY.System; available::Bool = true) return tie end -# Shared setup for the AreaBalancePowerModel G-1 testsets below: builds +# Shared setup for the AreaBalanceNetworkModel G-1 testsets below: builds # `two_area_pjm_DA`, optionally adds the parallel "1_2_b" tie # (`add_parallel_tie = false` skips it), attaches a `FixedForcedOutage` on # `outaged_gen_name` to "Reserve1_2" with the caller-supplied monitored # components (a function of `sys`, since some callers need components created -# inside this setup), and builds the AreaBalancePowerModel template/service +# inside this setup), and builds the AreaBalanceNetworkModel template/service # model/decision model. `set_interchange_model!` registers the # `AreaInterchange` DeviceModel; callers override it to attach a # `filter_function` or to omit the DeviceModel entirely. @@ -1661,7 +1536,7 @@ function _two_area_g1_setup!(; monitored_components = monitored(sys), ) - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalancePowerModel)) + template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) set_interchange_model!(template) set_service_model!(template, ServiceModel( @@ -1675,7 +1550,7 @@ function _two_area_g1_setup!(; return ps_model, sys, reserve_up end -@testset "G-1 AreaBalancePowerModel: cross-area reserve deployment covers out-of-area outage" begin +@testset "G-1 AreaBalanceNetworkModel: cross-area reserve deployment covers out-of-area outage" begin # Outage sited in Area1; the only responding service, Reserve1_2, has # contributing devices exclusively in Area2 (see reserve setup in # `two_area_pjm_DA`). Deployment must cross the "1_2" tie. @@ -1713,21 +1588,21 @@ end resolved_outages = collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up)) @test length(resolved_outages) == 1 - outage_id = string(IS.get_uuid(only(resolved_outages))) + outage_id = IS.get_id(only(resolved_outages)) deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard"] total_deployment = total_deployment_series(deployment, outage_id) flow_dev = - variables["PostContingencyAreaInterchangeFlowDeviationVariable__OnlineReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyAreaInterchangeFlowDeviationVariable__AreaInterchange"] tie_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") @test all(total_deployment .> 0.0) @test maximum(abs.(total_deployment .+ tie_flow_dev)) < 1e-3 end -@testset "G-1 AreaBalancePowerModel: multi-tie area splits response without over-counting" begin +@testset "G-1 AreaBalanceNetworkModel: multi-tie area splits response without over-counting" begin # Add a second, parallel AreaInterchange between Area1 and Area2 so the # post-contingency response can split across two ties. ps_model, sys, reserve_up = _two_area_g1_setup!(; @@ -1747,14 +1622,14 @@ end resolved_outages = collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up)) - outage_id = string(IS.get_uuid(only(resolved_outages))) + outage_id = IS.get_id(only(resolved_outages)) deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard"] total_deployment = total_deployment_series(deployment, outage_id) flow_dev = - variables["PostContingencyAreaInterchangeFlowDeviationVariable__OnlineReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyAreaInterchangeFlowDeviationVariable__AreaInterchange"] tie1_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") tie2_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2_b") @@ -1766,7 +1641,7 @@ end ) < 1e-3 end -@testset "G-1 AreaBalancePowerModel: unmonitored tie has no rate constraint despite dense Δf axis" begin +@testset "G-1 AreaBalanceNetworkModel: unmonitored tie has no rate constraint despite dense Δf axis" begin # Add a second, available parallel tie but monitor only the original # "1_2" tie. Δf must still span both (it is dense over every tie in the # AreaInterchange DeviceModel's set), while the rate constraint is @@ -1781,25 +1656,24 @@ end container = IOM.get_optimization_container(ps_model) flow_dev = IOM.get_variable( container, - IOM.PostContingencyAreaInterchangeFlowDeviationVariable(), - OnlineReserve{ReserveUp}, - "Reserve1_2", + PostContingencyAreaInterchangeFlowDeviationVariable(), + AreaInterchange, ) - @test Set(axes(flow_dev, 2)) == Set(["1_2", "1_2_b"]) + @test Set(axes(flow_dev, 1)) == Set(["1_2", "1_2_b"]) - for suffix in ("lb", "ub") + for meta in ("G1_lb", "G1_ub") # `SparseAxisArray` does not forward `Base.keys` to the container # axes, so index its `.data` dict directly to get the actual - # `(outage_id, name, t)` tuples that were written. + # `(name, outage_id, t)` tuples that were written. cons = IOM.get_constraint( container, - sc_key(PostContingencyFlowRateConstraint, "Reserve1_2_$(suffix)"), + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, meta), ) - @test Set(k[2] for k in keys(cons.data)) == Set(["1_2"]) + @test Set(k[1] for k in keys(cons.data)) == Set(["1_2"]) end end -@testset "G-1 AreaBalancePowerModel: binding monitored-tie limit uses post-contingency flow slack" begin +@testset "G-1 AreaBalanceNetworkModel: binding monitored-tie limit uses post-contingency flow slack" begin ps_model, sys, reserve_up = _two_area_g1_setup!(; monitored = s -> vcat( collect(get_components(ACTransmission, s)), @@ -1826,17 +1700,16 @@ end compare_outage_power_and_deployed_reserves(sys, res, reserve_up) slack_lb = - variables["PostContingencyFlowActivePowerSlackLowerBound__OnlineReserve__ReserveUp__Reserve1_2"] + variables["PostContingencyFlowActivePowerSlackLowerBound__AreaInterchange"] @test maximum(slack_lb[!, "value"]) > 1e-3 end -@testset "G-1 AreaBalancePowerModel: unavailable AreaInterchange excluded from Δf axis" begin +@testset "G-1 AreaBalanceNetworkModel: unavailable AreaInterchange excluded from Δf axis" begin # Add a second, parallel AreaInterchange between Area1 and Area2, then # disable it. The original "1_2" tie stays available, so it remains the # sole path for cross-area response. Monitor only available - # AreaInterchanges: a monitored-but-unavailable tie is a separate error - # path (`_resolve_service_monitored_area_interchanges`), not what this - # test targets. + # AreaInterchanges: a monitored-but-unavailable tie is covered by the + # testset below, not what this test targets. ps_model, sys, reserve_up = _two_area_g1_setup!(; add_parallel_tie = true, tie_available = false, @@ -1851,11 +1724,10 @@ end container = IOM.get_optimization_container(ps_model) flow_deviation_var = IOM.get_variable( container, - IOM.PostContingencyAreaInterchangeFlowDeviationVariable(), - OnlineReserve{ReserveUp}, - "Reserve1_2", + PostContingencyAreaInterchangeFlowDeviationVariable(), + AreaInterchange, ) - interchange_axis = axes(flow_deviation_var, 2) + interchange_axis = axes(flow_deviation_var, 1) @test "1_2" in interchange_axis @test !("1_2_b" in interchange_axis) @@ -1867,27 +1739,25 @@ end compare_outage_power_and_deployed_reserves(sys, res, reserve_up) end -@testset "G-1 AreaBalancePowerModel: unavailable monitored AreaInterchange rejected at validation" begin - # Monitor every AreaInterchange, including the disabled one: an admissible - # tie must be both available and in the network model's scope. +@testset "G-1 AreaBalanceNetworkModel: unavailable monitored AreaInterchange is dropped" begin + # Monitor every AreaInterchange, including the disabled one: only available + # ties get a post-contingency rate constraint. ps_model, sys, reserve_up = _two_area_g1_setup!(; add_parallel_tie = true, tie_available = false, monitored = s -> collect(get_components(AreaInterchange, s)), ) - template = IOM.get_template(ps_model) - - # `build!` catches this internally and reports `ModelBuildStatus.FAILED` - # rather than propagating it, so the throw is only observable by calling - # the template-validation step directly (mirrors the `ReserveDown` - # rejection testset below). - @test_throws IS.ConflictingInputsError IOM._build_service_model_outages!( - template, - sys, - ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.FAILED + IOM.ModelBuildStatus.BUILT + + container = IOM.get_optimization_container(ps_model) + for meta in ("G1_lb", "G1_ub") + cons = IOM.get_constraint( + container, + IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, meta), + ) + @test Set(k[1] for k in keys(cons.data)) == Set(["1_2"]) + end end # The AreaInterchange `DeviceModel`'s set — not the network model's scope — is @@ -1896,10 +1766,10 @@ end # be an unanchored transfer path in the per-area balance (and the monitored # post-contingency flow expression would hit a `KeyError`). # -# Both testsets below build under `AreaBalancePowerModel`, so -# `ignores_branch_filtering(AreaBalancePowerModel) = true` makes +# Both testsets below build under `AreaBalanceNetworkModel`, so +# `ignores_branch_filtering(AreaBalanceNetworkModel) = true` makes # `_validate_branch_models` log a stray "Branch filtering is ignored for -# network model AreaBalancePowerModel" warning during `build!`. That warning +# network model AreaBalanceNetworkModel" warning during `build!`. That warning # is inaccurate for this DeviceModel: filtering IS honored here (Δf's axis # comes straight from the filtered `FlowActivePowerVariable__AreaInterchange` # container, asserted below). Do not "fix" these tests by removing @@ -1914,7 +1784,7 @@ _exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_dev ), ) -@testset "G-1 AreaBalancePowerModel: filter_function-excluded tie gets no Δf term" begin +@testset "G-1 AreaBalanceNetworkModel: filter_function-excluded tie gets no Δf term" begin ps_model, sys, reserve_up = _two_area_g1_setup!(; add_parallel_tie = true, monitored = s -> [get_component(AreaInterchange, s, "1_2")], @@ -1928,30 +1798,27 @@ _exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_dev IOM.get_variable(container, IOM.FlowActivePowerVariable(), AreaInterchange) flow_deviation_var = IOM.get_variable( container, - IOM.PostContingencyAreaInterchangeFlowDeviationVariable(), - OnlineReserve{ReserveUp}, - "Reserve1_2", + PostContingencyAreaInterchangeFlowDeviationVariable(), + AreaInterchange, ) # Δf spans exactly the pre-contingency flow variable's ties. @test Set(axes(flow_var, 1)) == Set(["1_2"]) - @test Set(axes(flow_deviation_var, 2)) == Set(["1_2"]) + @test Set(axes(flow_deviation_var, 1)) == Set(["1_2"]) @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = OptimizationProblemOutputs(ps_model) variables = read_variables(res) compare_outage_power_and_deployed_reserves(sys, res, reserve_up) - outage_id = string( - IS.get_uuid( - only(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up))), - ), + outage_id = IS.get_id( + only(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up))), ) total_deployment = total_deployment_series( - variables["PostContingencyActivePowerReserveDeploymentVariable__OnlineReserve__ReserveUp__Reserve1_2"], + variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard"], outage_id, ) tie_flow_dev = _sc_flow_deviation_series( - variables["PostContingencyAreaInterchangeFlowDeviationVariable__OnlineReserve__ReserveUp__Reserve1_2"], + variables["PostContingencyAreaInterchangeFlowDeviationVariable__AreaInterchange"], outage_id, "1_2", ) @@ -1961,26 +1828,21 @@ _exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_dev @test maximum(abs.(total_deployment .+ tie_flow_dev)) < 1e-3 end -@testset "G-1 AreaBalancePowerModel: monitored tie excluded by filter_function rejected at validation" begin +@testset "G-1 AreaBalanceNetworkModel: monitored tie excluded by filter_function rejected at validation" begin # Both ties are available and in the network model's scope, so only the - # DeviceModel-inclusion check can reject the monitored "1_2_b". + # DeviceModel-inclusion check can reject the monitored "1_2_b". `build!` + # catches the `ConflictingInputsError` and reports `FAILED`. ps_model, sys, reserve_up = _two_area_g1_setup!(; add_parallel_tie = true, monitored = s -> collect(get_components(AreaInterchange, s)), set_interchange_model! = _exclude_parallel_tie_model!, ) - template = IOM.get_template(ps_model) - - @test_throws IS.ConflictingInputsError IOM._build_service_model_outages!( - template, - sys, - ) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.FAILED end -@testset "G-1 AreaBalancePowerModel: no AreaInterchange device model warns and covers in-area" begin +@testset "G-1 AreaBalanceNetworkModel: no AreaInterchange device model warns and covers in-area" begin # No AreaInterchange DeviceModel means no modeled tie at all: the per-area # post-contingency balance reduces to in-area coverage, so the outage is # sited in Area2 where Reserve1_2's contributing devices live. @@ -2002,16 +1864,20 @@ end # to in-area coverage. @test !IOM.has_container_key( container, - IOM.PostContingencyAreaInterchangeFlowDeviationVariable, - OnlineReserve{ReserveUp}, - "Reserve1_2", + PostContingencyAreaInterchangeFlowDeviationVariable, + AreaInterchange, ) # `build!` installs its own logger, so the construct-time warning is only # observable in the model's log file; it must be emitted exactly once. log_text = read(IOM.get_log_file(ps_model), String) @test length( - collect(eachmatch(r"no PSY\.AreaInterchange device model in the", log_text)), + collect( + eachmatch( + r"needs PSY\.AreaInterchange\(s\) and DeviceModel\{PSY\.AreaInterchange\}", + log_text, + ), + ), ) == 1 @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -2030,73 +1896,11 @@ end # `build!` catches this internally and reports `ModelBuildStatus.FAILED` # rather than propagating it (see `build_impl!`), so the throw is only - # observable by calling the template-validation step directly — mirrors - # the sibling AC-transmission test file's convention for internal - # validation checks. - @test_throws IS.ConflictingInputsError IOM._build_service_model_outages!( - template, - sys, - ) -end - -@testset "SC guard rails: fail-fast on unresolved monitored components" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - template = get_thermal_dispatch_template_network(PTDFNetworkModel) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - network_model = IOM.get_network_model(IOM.get_template(ps_model)) - net_reduction_data = IOM.get_network_reduction(network_model) - uuid = IS.get_uuid(sys) - - # (a) device-side: an ACTransmission-type monitor absent from the - # network-reduction's branch-arc maps (this system has no MonitoredLine - # components) raises, rather than being silently dropped. - device_model = DeviceModel(MonitoredLine, SecurityConstrainedStaticBranch) - device_model.outages[uuid] = - Dict{DataType, Set{String}}(MonitoredLine => Set(["fake_line"])) - @test_throws ErrorException IOM._resolve_monitored_arcs( - device_model, - net_reduction_data, - ) - - # (a) service-side counterpart. - service_model = ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - ) - service_model.outages[uuid] = - Dict{DataType, Set{String}}(MonitoredLine => Set(["fake_line"])) - @test_throws ErrorException IOM._resolve_service_monitored_arcs( - service_model, - net_reduction_data, - ) - - # (b) AreaInterchange monitors have no branch-arc entry under a PTDF - # network model; dropped with a warning naming the component instead of - # being silently skipped. - service_model_ai = ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - ) - service_model_ai.outages[uuid] = - Dict{DataType, Set{String}}(AreaInterchange => Set(["tie1"])) - @test_logs (:warn, r"AreaInterchange monitor.*tie1.*dropped") IOM._resolve_service_monitored_arcs( - service_model_ai, - net_reduction_data, - ) - - # (c) an AreaInterchange monitor naming a component absent from the - # system raises, rather than being silently skipped. - service_model_missing = ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - ) - service_model_missing.outages[uuid] = - Dict{DataType, Set{String}}(AreaInterchange => Set(["not_a_real_tie"])) - @test_throws ErrorException IOM._resolve_service_monitored_area_interchanges( + # observable by calling the helper directly. + @test_throws IS.ConflictingInputsError POM._security_constrained_outages( sys, - service_model_missing, + IOM.get_service_models(template), + IOM.get_network_model(template), ) end @@ -2128,9 +1932,9 @@ end @test_logs ( :warn, r"AreaInterchange.*is not a modeled ACTransmission branch type", - ) IOM._build_device_model_outages!(template, sys) + ) POM._build_device_model_outages!(template, sys) - device_model = only(IOM._sc_branch_models(template)) + device_model = only(POM._sc_branch_models(template)) outage_uuid = only(keys(device_model.outages)) per_type = device_model.outages[outage_uuid] @test haskey(per_type, Line) @@ -2199,51 +2003,50 @@ end container = IOM.get_optimization_container(ps_model) network_model = IOM.get_network_model(IOM.get_template(ps_model)) - net_reduction_data = IOM.get_network_reduction(network_model) - orientation_sign = - IOM.get_ptdf_orientation_sign(net_reduction_data, Line, monitored_name) + catalog = POM.get_branch_catalog(network_model) + orientation_sign = POM.get_ptdf_orientation_sign(catalog, Line, monitored_name) @test orientation_sign == -1.0 # guard: fixture must exercise the :ToFrom member time_steps = IOM.get_time_steps(container) - outage_id = string(IS.get_uuid(outage)) - actual_container = IOM.get_expression( - container, - IOM.PostContingencyBranchFlow(), - OnlineReserve{ReserveUp}, - "Reserve1", - ) - pre_flow = IOM.get_expression(container, IOM.PTDFBranchFlow(), Line) + outage_id = IS.get_id(outage) + actual_container = + IOM.get_expression(container, PostContingencyBranchFlow(), Line, "G1") + pre_flow = IOM.get_expression(container, PTDFBranchFlow(), Line) nodal_deployment = IOM.get_expression( container, - IOM.PostContingencyNodalActivePowerDeployment(), - OnlineReserve{ReserveUp}, - "Reserve1", - ) - # `nodal_deployment`'s bus axis is scoped to injection-relevant buses - # (contributing-device + outaged-generator buses), not the full PTDF bus - # axis — buses outside that subset always carry a zero deployment - # expression, so they're skipped here rather than indexed positionally. - name_to_arc_maps = PNM.get_name_to_arc_maps(net_reduction_data) - arc, _ = name_to_arc_maps[Line][monitored_name] - ptdf_col = IOM.get_network_matrix(network_model)[arc, :] - full_bus_axis = PNM.get_bus_axis(IOM.get_network_matrix(network_model)) - relevant_buses = Set(nodal_deployment.axes[2]) + PostContingencyNodalActivePowerDeployment(), + ACBus, + ) + # The series member resolves to its reduction entry, which keys both the + # pre-contingency and post-contingency flow rows. + arc_map = PNM.get_name_to_arc_map(catalog, Line) + entry_name = if haskey(arc_map, monitored_name) + monitored_name + else + PNM.get_component_to_reduction_name_map(catalog, Line)[monitored_name] + end + ptdf = POM.get_network_matrix(network_model) + ptdf_col = ptdf[arc_map[entry_name], :] + full_bus_axis = PNM.get_bus_axis(ptdf) + # Nodal deployment is sparse over injection-relevant buses (contributing-device + # and outaged-generator buses); other buses carry no deployment and are skipped. + relevant_buses = Set(k[1] for k in keys(nodal_deployment.data)) for t in time_steps - actual = actual_container[outage_id, monitored_name, t] - correctly_signed = copy(pre_flow[monitored_name, t]) - naively_signed = copy(pre_flow[monitored_name, t]) + actual = actual_container[entry_name, outage_id, t] + correctly_signed = copy(pre_flow[entry_name, t]) + naively_signed = copy(pre_flow[entry_name, t]) for b in eachindex(ptdf_col) coef = ptdf_col[b] abs(coef) < IOM.PTDF_ZERO_TOL && continue - bus_number = full_bus_axis[b] - bus_number in relevant_buses || continue + bus_key = string(full_bus_axis[b]) + bus_key in relevant_buses || continue JuMP.add_to_expression!( correctly_signed, orientation_sign * coef, - nodal_deployment[outage_id, bus_number, t], + nodal_deployment[bus_key, outage_id, t], ) JuMP.add_to_expression!( - naively_signed, coef, nodal_deployment[outage_id, bus_number, t], + naively_signed, coef, nodal_deployment[bus_key, outage_id, t], ) end @test aff_exprs_approx_equal(actual, correctly_signed; atol = 1e-8) @@ -2294,13 +2097,12 @@ end deployment_var = IOM.get_variable( container, PostContingencyActivePowerReserveDeploymentVariable(), - OnlineReserve{ReserveUp}, - "Reserve1", + ThermalStandard, ) - outage_id = string(IS.get_uuid(outage)) + outage_id = IS.get_id(outage) for t in IOM.get_time_steps(container) JuMP.fix(on_var["Park City", t], 0.0; force = true) - JuMP.fix(deployment_var[outage_id, "Park City", t], 0.0; force = true) + JuMP.fix(deployment_var["Park City", outage_id, t], 0.0; force = true) end @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED From 5fa1af0be9c991e5148f53dc4f5e4c7634c7bf90 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 00:33:22 -0400 Subject: [PATCH 13/22] clean up claude code; fix locational power; remaining: that weird constraint and slacks on flow --- .../security_constrained_injectors.jl | 256 +++++++----------- src/services_models/services_constructor.jl | 26 +- 2 files changed, 113 insertions(+), 169 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 33e040e6..00224536 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -1,14 +1,6 @@ const _PER_TYPE = Dict{DataType, Set{String}} const _OUTAGE_MAP = Dict{Int, _PER_TYPE} -# One outage may be tied to several reserves with overlapping contributing devices. So aggregate -# those devices across all modeled reserves -# TODO: Need to do only security constrained ones -# TODO: separate? we aggregate across services for contributing devices because services across different templates may have different contributing devices. as for outaged generators and monitored lines which are just per-outage and keep no info about the services... I think it makes sense to group them and do them at the construct_serviceS level (plural) because the constraints e.g. on the monitored lines need to only be added once per outage, but if we do it at the construct_service (singular) level, then we are setting ourselves up to do it once per service per outage... it's a convenience thing -# TODO: monitored lines? - -# Keeps G-1 post-contingency flow containers apart from the N-1 ones keyed on the same -# branch type. const _G1_META = "G1" _validate_reserve_formulation(::ServiceModel) = false @@ -27,29 +19,22 @@ _valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetwork _valid_component_type(::PSY.AreaInterchange, ::NetworkModel{AreaBalanceNetworkModel}) = true _valid_component_type(::PSY.Component, ::NetworkModel) = false -# TODO move to template_validation or keep here? -# TODO: split between ptdf and area? """ -Per outage attached to a security-constrained reserve: the devices contributing to any -reserve responding to it, the outaged generators, and -the available, modeled monitored components. +Per outage attached to a security-constrained reserve: the devices contributing to any reserve +responding to it, the outaged generators, and the available, modeled monitored components. -Contributing devices are aggregated across services because one outage may be tied to -reserves with different contributing devices and different service models. +Contributing devices are aggregated across services because one outage may be tied to reserves +with different contributing devices and different service models. """ function _security_constrained_outages( sys::PSY.System, services_template::ServicesModelContainer, network_model::NetworkModel, ) - contributing_devices = _OUTAGE_MAP() - outaged_generators = _OUTAGE_MAP() - monitored_components = _OUTAGE_MAP() - modeled_types = network_model.modeled_branch_types + contributing_devices, outaged_generators, monitored_components = _OUTAGE_MAP(), _OUTAGE_MAP(), _OUTAGE_MAP() for model in values(services_template) _validate_reserve_formulation(model) || continue service_type = get_component_type(model) - # TODO: validate that its nonempty? for (service_name, per_type) in get_contributing_devices_map(model) service = PSY.get_component(service_type, sys, service_name) for outage in PSY.get_supplemental_attributes(PSY.Outage, service) @@ -61,11 +46,7 @@ function _security_constrained_outages( haskey(outaged_generators, uuid) && continue outaged = outaged_generators[uuid] = _PER_TYPE() - for generator in PSY.get_associated_components( - sys, - outage; - component_type = PSY.Generator, - ) + for generator in PSY.get_associated_components(sys, outage; component_type = PSY.Generator) push!(get!(Set{String}, outaged, typeof(generator)), PSY.get_name(generator)) end @@ -74,7 +55,7 @@ function _security_constrained_outages( component = IS.get_component(sys, component_uuid) _valid_component_type(component, network_model) || continue PSY.get_available(component) || continue - typeof(component) in modeled_types || continue + typeof(component) in network_model.modeled_branch_types || continue push!( get!(Set{String}, monitored, typeof(component)), PSY.get_name(component), @@ -104,11 +85,9 @@ function _create_post_contingency_reserve_variables!( Int[]; sparse = true, ) - outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names - name in outaged && continue + name in outaged_generators[uuid] && continue for t in time_steps - # TODO bounds? start? var[name, uuid, t] = JuMP.@variable( jump_model, base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(device_type)_{$(name), $(uuid), $(t)}", @@ -121,43 +100,30 @@ function _create_post_contingency_reserve_variables!( return end -function _modeled_interchange_names(container::OptimizationContainer) - if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) - @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = - LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 - String[] - else - var = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) - collect(axes(var, 1)) - end -end - -# Runs in the ModelConstructStage: the AreaInterchange flow variables are created in the -# branch ArgumentConstructStage, which runs after the services one. function _create_post_contingency_interchange_variables!( container::OptimizationContainer, - uuids::Vector{Int}, + monitored_components::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) - names = _modeled_interchange_names(container) - (isempty(names) || isempty(uuids)) && return - jump_model = get_jump_model(container) time_steps = get_time_steps(container) var = add_variable_container!( container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange, - names, - uuids, - time_steps, + String[], + Int[], + Int[]; + sparse = true, ) - for name in names, uuid in uuids, t in time_steps - var[name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", - start = 0.0, - ) + for (uuid, per_type) in monitored_components + for name in get!(Vector{String}, per_type, PSY.AreaInterchange) + var[name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", + start = 0.0, + ) + end end return end @@ -168,14 +134,16 @@ _create_post_contingency_interchange_variables!( ::NetworkModel, ) = nothing -_deployment_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment -_deployment_expression(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaActivePowerDeployment +_deployment_expression_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment +_deployment_expression_type(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaActivePowerDeployment + +_deployment_component_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PSY.ACBus +_deployment_component_type(::NetworkModel{AreaBalanceNetworkModel}) = PSY.Area _location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string(PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(component))) -# TODO are there reductions on area models? _location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) -# TODO: What about outaged gens? Subtract their power on the node? +# [contributing device reserve] minus [outaged generator power] per bus or area function _build_post_contingency_locational_power!( container::OptimizationContainer, sys::PSY.System, @@ -183,20 +151,26 @@ function _build_post_contingency_locational_power!( outaged_gens::_OUTAGE_MAP, network_model::NetworkModel ) + expr = lazy_add_container(container, _deployment_expression_type(network_model), _deployment_component_type(network_model), String[], Int[], Int[]; sparse = true) jump_model = get_jump_model(container) - for (uuid, per_type) in contributing_devices - for (device_type, names) in per_type - expr = lazy_add_container(container, _deployment_expression(network_model), PSY.ACBus, String[], Int[], Int[]; sparse = true) - power = get_variable(container, ActivePowerReserveVariable, device_type) + uuids = collect(keys(contributing_devices)) + for uuid in uuids + for (device_type, names) in contributing_devices[uuid] reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) for name in names (device_type, name) in outaged_gens[uuid] && continue - bus = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(PSY.get_component(device_type, sys, name))) key = _location_key(PSY.get_component(device_type, sys, name), network_model) for t in time_steps - ex = expr[key, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, power[name, t]) - JuMP.add_to_expression!(ex, reserve[name, t]) + JuMP.add_to_expression!(expr[key, uuid, t], reserve[name, t]) + end + end + end + for (generator_type, names) in outaged_gens[uuid] + power = get_variable(container, ActivePowerVariable, _deployment_component_type(network_model)) + for name in names + key = _location_key(PSY.get_component(generator_type, sys, name), network_model) + for t in time_steps + JuMP.add_to_expression!(expr[key, uuid, t], -1.0, power[name, t]) end end end @@ -225,7 +199,6 @@ function _build_post_contingency_flow!( # Signed distribution factors per monitored branch, keyed by the nodal bus key. dfs = Dict{Tuple{DataType, String}, Dict{String, Float64}}() for (uuid, per_type) in monitored_components - outage_buses = get(buses, uuid, String[]) for (line_type, names) in per_type expr = lazy_container_addition!( container, @@ -237,26 +210,23 @@ function _build_post_contingency_flow!( sparse = true, meta = _G1_META, ) - # TODO is pre_flow needed when nodal already considers active power? pre_flow = get_expression(container, PTDFBranchFlow, line_type) arc_map = PNM.get_name_to_arc_map(catalog, line_type) reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, line_type) for name in names entry_name = haskey(arc_map, name) ? name : reduction_name_map[name] - # Build distribution factor cache (if needed) df = get!(dfs, (line_type, name)) do ptdf_col = ptdf[arc_map[entry_name], :] sign = get_ptdf_orientation_sign(catalog, line_type, name) - # TODO: am I indexing the bus correctly? Dict{String, Float64}( string(bus_axis[i]) => sign * ptdf_col[i] for i in eachindex(ptdf_col) if abs(ptdf_col[i]) > PTDF_ZERO_TOL ) end for t in time_steps - ex = expr[entry_name, uuid, t] = JuMP.AffExpr(0.0) + ex = expr[entry_name, uuid, t] = get_hinted_aff_expr(length(JuMP.linear_terms(pre_flow[entry_name, t])) + length(buses[uuid])) JuMP.add_to_expression!(ex, pre_flow[entry_name, t]) - for bus in outage_buses + for bus in buses[uuid] haskey(df, bus) || continue JuMP.add_to_expression!(ex, df[bus], nodal[bus, uuid, t]) end @@ -267,24 +237,12 @@ function _build_post_contingency_flow!( return end +# TODO: Only monitored interchanges can take post-contingency flow, is that sensible? function _build_post_contingency_flow!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) - has_container_key( - container, - PostContingencyAreaInterchangeFlowDeviationVariable, - PSY.AreaInterchange, - ) || return - time_steps = get_time_steps(container) - flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) - deviation = get_variable( - container, - PostContingencyAreaInterchangeFlowDeviationVariable, - PSY.AreaInterchange, - ) - modeled = Set{String}(axes(deviation, 1)) expr = add_expression_container!( container, PostContingencyAreaInterchangeFlow, @@ -295,15 +253,14 @@ function _build_post_contingency_flow!( sparse = true, meta = _G1_META, ) + flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) + deviation = get_variable(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange) + modeled = Set{String}(axes(flow, 1)) for (uuid, per_type) in monitored_components for name in get(per_type, PSY.AreaInterchange, Set{String}()) - name in modeled || throw( - IS.ConflictingInputsError( - "Monitored AreaInterchange $(name) has no modeled flow; it is excluded by the AreaInterchange DeviceModel's filter_function.", - ), - ) + name in modeled || throw(IS.ConflictingInputsError("Monitored AreaInterchange $(name) is not modeled.")) for t in time_steps - ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + ex = expr[name, uuid, t] JuMP.add_to_expression!(ex, flow[name, t]) JuMP.add_to_expression!(ex, deviation[name, uuid, t]) end @@ -322,7 +279,7 @@ function _constrain_post_contingency_balance!( outaged_generators::_OUTAGE_MAP, ::NetworkModel, ) - uuids = sort!(collect(keys(contributing_devices))) + uuids = collect(keys(contributing_devices)) time_steps = get_time_steps(container) jump_model = get_jump_model(container) cons = add_constraints_container!( @@ -342,11 +299,7 @@ function _constrain_post_contingency_balance!( end end for (device_type, names) in contributing_devices[uuid] - reserve = get_variable( - container, - PostContingencyActivePowerReserveDeploymentVariable, - device_type, - ) + reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names name in outaged && continue @@ -359,7 +312,6 @@ function _constrain_post_contingency_balance!( return end -# TODO Why does each area listen to every outage? but not every bus? function _constrain_post_contingency_balance!( container::OptimizationContainer, sys::PSY.System, @@ -371,14 +323,9 @@ function _constrain_post_contingency_balance!( time_steps = get_time_steps(container) jump_model = get_jump_model(container) - # Area name => (sign, interchange name); flow leaving an area counts against it. + # Area name => (sign, interchange name) interchanges = Dict{String, Vector{Tuple{Float64, String}}}() - has_deviation = has_container_key( - container, - PostContingencyAreaInterchangeFlowDeviationVariable, - PSY.AreaInterchange, - ) - if has_deviation + if has_container_key(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange) deviation = get_variable( container, PostContingencyAreaInterchangeFlowDeviationVariable, @@ -394,8 +341,7 @@ function _constrain_post_contingency_balance!( push!(get!(Vector{Tuple{Float64, String}}, interchanges, to_area), (1.0, name)) end end - deployment = - get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) + deployment = get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) area_names = PSY.get_name.(PSY.get_components(PSY.Area, sys)) cons = add_constraints_container!( @@ -406,11 +352,9 @@ function _constrain_post_contingency_balance!( uuids, time_steps, ) - for area_name in area_names, uuid in uuids, t in time_steps + for (area_name, uuid, t) in keys(deployment.data) balance = JuMP.AffExpr(0.0) - if haskey(deployment.data, (area_name, uuid, t)) - JuMP.add_to_expression!(balance, deployment[area_name, uuid, t]) - end + JuMP.add_to_expression!(balance, deployment[area_name, uuid, t]) for (sign, interchange_name) in get(interchanges, area_name, ()) JuMP.add_to_expression!(balance, sign, deviation[interchange_name, uuid, t]) end @@ -427,6 +371,7 @@ function _constrain_post_contingency_generation!( ) jump_model = get_jump_model(container) time_steps = get_time_steps(container) + limits = Dict{Tuple{DataType, String}, Float64}() for (uuid, per_type) in contributing_devices for (device_type, names) in per_type cons = lazy_container_addition!( @@ -447,15 +392,11 @@ function _constrain_post_contingency_generation!( outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names name in outaged && continue - limit = PSY.get_max_active_power( - PSY.get_component(device_type, sys, name), - PSY.SU, - ) + limit = get!(limits, (device_type, name)) do + PSY.get_max_active_power(PSY.get_component(device_type, sys, name), PSY.SU) + end for t in time_steps - cons[name, uuid, t] = JuMP.@constraint( - jump_model, - power[name, t] + reserve[name, uuid, t] <= limit - ) + cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, uuid, t] <= limit) end end end @@ -463,9 +404,7 @@ function _constrain_post_contingency_generation!( return end -# Contributing devices with a TS-backed pre-contingency reserve limit maintain that limit -# post-contingency. -# TODO: That's not quite what this is doing... +# TODO: this constraint confuses me function _constrain_post_contingency_reserve!( container::OptimizationContainer, contributing_devices::_OUTAGE_MAP, @@ -489,7 +428,6 @@ function _constrain_post_contingency_reserve!( return end - _post_contingency_flow_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyBranchFlow _post_contingency_flow_expression(::NetworkModel{AreaBalanceNetworkModel}) = @@ -516,9 +454,6 @@ function _post_contingency_flow_limits( return (min = -limits.to_from, max = limits.from_to) end -# TODO Why do we support area interchanges here but not on N-1? -# TODO refactor with add_constraints!(PostContingencyFlowRate, ACTransmission) from N-1? -# TODO I think we don't need to check for shared sources because we're creating at the create_services level # TODO: slacks? function _constrain_post_contingency_flow!( container::OptimizationContainer, @@ -527,44 +462,37 @@ function _constrain_post_contingency_flow!( network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) jump_model = get_jump_model(container) - expression_type = _post_contingency_flow_expression(network_model) - component_types = Set{DataType}() - for per_type in values(monitored_components) - union!(component_types, keys(per_type)) - end - for component_type in component_types - has_container_key(container, expression_type, component_type, _G1_META) || - continue - flow = get_expression(container, expression_type, component_type, _G1_META) - cons_lb = add_constraints_container!( - container, - PostContingencyFlowRateConstraint, - component_type, - String[], - Int[], - Int[]; - sparse = true, - meta = "$(_G1_META)_lb", - ) - cons_ub = add_constraints_container!( - container, - PostContingencyFlowRateConstraint, - component_type, - String[], - Int[], - Int[]; - sparse = true, - meta = "$(_G1_META)_ub", - ) - limits = Dict{String, NamedTuple{(:min, :max), Tuple{Float64, Float64}}}() - for (name, uuid, t) in keys(flow.data) - lims = get!(limits, name) do - _post_contingency_flow_limits(sys, network_model, component_type, name) + for (uuid, per_type) in monitored_components + for (component_type, names) in per_type + flow = get_expression(container, _post_contingency_flow_expression(network_model), component_type, _G1_META) + cons_lb = lazy_container_addition!( + container, + PostContingencyFlowRateConstraint, + component_type, + String[], + Int[], + Int[]; + sparse = true, + meta = "$(_G1_META)_lb", + ) + cons_ub = lazy_container_addition!( + container, + PostContingencyFlowRateConstraint, + component_type, + String[], + Int[], + Int[]; + sparse = true, + meta = "$(_G1_META)_ub", + ) + limits = Dict{String, MinMax}() + for name in names, t in time_steps + lims = get!(limits, name) do + _post_contingency_flow_limits(sys, network_model, component_type, name) + end + cons_ub[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] <= lims.max) + cons_lb[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] >= lims.min) end - cons_ub[name, uuid, t] = - JuMP.@constraint(jump_model, flow[name, uuid, t] <= lims.max) - cons_lb[name, uuid, t] = - JuMP.@constraint(jump_model, flow[name, uuid, t] >= lims.min) end end return diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 3a20d0ff..4b54416e 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -139,9 +139,10 @@ function construct_services!( ) end - contributing_devices, outaged_generators, _ = + contributing_devices, outaged_generators, monitored_components = _security_constrained_outages(sys, services_template, network_model) _create_post_contingency_reserve_variables!(container, contributing_devices, outaged_generators) + _create_post_contingency_interchange_variables!(container, monitored_components, network_model) return end @@ -185,11 +186,8 @@ function construct_services!( ) end - # TODO: combine into prev function contributing_devices, outaged_generators, monitored_components = _security_constrained_outages(sys, services_template, network_model) - uuids = sort!(collect(keys(contributing_devices))) - _create_post_contingency_interchange_variables!(container, uuids, network_model) _build_post_contingency_flow!(container, monitored_components, network_model) _constrain_post_contingency_balance!(container, sys, contributing_devices, outaged_generators, network_model) _constrain_post_contingency_generation!(container, sys, outaged_generators, contributing_devices) @@ -1107,6 +1105,24 @@ function construct_service!( return end +function construct_service!(container::OptimizationContainer, sys::PSY.System, ::ArgumentConstructStage, model::ServiceModel{R, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) where {R <: PSY.AbstractReserve} + services = _services_with_contributors(model, sys) + isempty(services) && return + ts_services = [s for s in services if _has_ts_requirement(model, s)] + isempty(ts_services) || + add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) + for service in services + contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + add_service_variables!( + container, + ActivePowerReserveVariable, + service, + contributing_devices, + R, + ) + add_feedforward_arguments!(container, model, service) + end + return +end # These models are constructed entirely in construct_services! -construct_service!(::OptimizationContainer, ::PSY.System, ::ArgumentConstructStage, ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) = nothing construct_service!(::OptimizationContainer, ::PSY.System, ::ModelConstructStage, ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) = nothing From e41800586d39b2020357a2bb8f63e87648557da1 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 11:46:44 -0400 Subject: [PATCH 14/22] some bug fixes + claude fix reserve constraint --- .../security_constrained_injectors.jl | 102 ++++++++++++------ src/services_models/services_constructor.jl | 11 +- ...c_injection_security_constrained_models.jl | 2 +- 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 00224536..547fc710 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -85,8 +85,9 @@ function _create_post_contingency_reserve_variables!( Int[]; sparse = true, ) + outaged = get(Set{String}, outaged_generators[uuid], device_type) for name in names - name in outaged_generators[uuid] && continue + name in outaged && continue for t in time_steps var[name, uuid, t] = JuMP.@variable( jump_model, @@ -130,7 +131,7 @@ end _create_post_contingency_interchange_variables!( ::OptimizationContainer, - ::Vector{Int}, + ::_OUTAGE_MAP, ::NetworkModel, ) = nothing @@ -140,7 +141,7 @@ _deployment_expression_type(::NetworkModel{AreaBalanceNetworkModel}) = PostConti _deployment_component_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PSY.ACBus _deployment_component_type(::NetworkModel{AreaBalanceNetworkModel}) = PSY.Area -_location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string(PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(component))) +_location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string(PNM.get_mapped_bus_number(get_network_reduction(network_model), PSY.get_bus(component))) _location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) # [contributing device reserve] minus [outaged generator power] per bus or area @@ -148,25 +149,28 @@ function _build_post_contingency_locational_power!( container::OptimizationContainer, sys::PSY.System, contributing_devices::_OUTAGE_MAP, - outaged_gens::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, network_model::NetworkModel ) - expr = lazy_add_container(container, _deployment_expression_type(network_model), _deployment_component_type(network_model), String[], Int[], Int[]; sparse = true) + expr = lazy_container_addition!(container, _deployment_expression_type(network_model), _deployment_component_type(network_model), String[], Int[], Int[]; sparse = true) jump_model = get_jump_model(container) uuids = collect(keys(contributing_devices)) + time_steps = get_time_steps(container) for uuid in uuids for (device_type, names) in contributing_devices[uuid] reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + outaged = get(Set{String}, outaged_generators[uuid], device_type) for name in names - (device_type, name) in outaged_gens[uuid] && continue + name in outaged && continue key = _location_key(PSY.get_component(device_type, sys, name), network_model) for t in time_steps - JuMP.add_to_expression!(expr[key, uuid, t], reserve[name, t]) + ex = get!(expr.data, (key, uuid, t), JuMP.AffExpr(0.0)) + JuMP.add_to_expression!(ex, reserve[name, uuid, t]) end end end - for (generator_type, names) in outaged_gens[uuid] - power = get_variable(container, ActivePowerVariable, _deployment_component_type(network_model)) + for (generator_type, names) in outaged_generators[uuid] + power = get_variable(container, ActivePowerVariable, generator_type) for name in names key = _location_key(PSY.get_component(generator_type, sys, name), network_model) for t in time_steps @@ -211,10 +215,10 @@ function _build_post_contingency_flow!( meta = _G1_META, ) pre_flow = get_expression(container, PTDFBranchFlow, line_type) - arc_map = PNM.get_name_to_arc_map(catalog, line_type) reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, line_type) + arc_map = PNM.get_name_to_arc_map(catalog, line_type) for name in names - entry_name = haskey(arc_map, name) ? name : reduction_name_map[name] + entry_name = reduction_name_map[name] df = get!(dfs, (line_type, name)) do ptdf_col = ptdf[arc_map[entry_name], :] sign = get_ptdf_orientation_sign(catalog, line_type, name) @@ -224,7 +228,7 @@ function _build_post_contingency_flow!( ) end for t in time_steps - ex = expr[entry_name, uuid, t] = get_hinted_aff_expr(length(JuMP.linear_terms(pre_flow[entry_name, t])) + length(buses[uuid])) + ex = expr[entry_name, uuid, t] = IOM.get_hinted_aff_expr(length(JuMP.linear_terms(pre_flow[entry_name, t])) + length(buses[uuid])) JuMP.add_to_expression!(ex, pre_flow[entry_name, t]) for bus in buses[uuid] haskey(df, bus) || continue @@ -363,6 +367,8 @@ function _constrain_post_contingency_balance!( return end +# Redundant for devices bounded by `_constrain_post_contingency_reserve!`, since their awards +# already sit in the device headroom; kept unconditional so no device can deploy past pmax. function _constrain_post_contingency_generation!( container::OptimizationContainer, sys::PSY.System, @@ -404,27 +410,59 @@ function _constrain_post_contingency_generation!( return end -# TODO: this constraint confuses me +# Deployment can only draw on procured reserve: bounded by the sum of the device's awards in +# every reserve with a requirement series that responds to the outage. Reserves without one have +# no meaningful award, so their deployment is limited by generation headroom alone. function _constrain_post_contingency_reserve!( container::OptimizationContainer, - contributing_devices::_OUTAGE_MAP, - outaged_gens::_OUTAGE_MAP, + sys::PSY.System, + services_template::ServicesModelContainer, + outaged_generators::_OUTAGE_MAP, ) jump_model = get_jump_model(container) - for (uuid, per_type) in contributing_devices - for (device_type, names) in per_type - # TODO: shorter name - cons = lazy_add_container!(container, PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, device_type, String[], Int[], Int[]; sparse = true) - pre_reserve = get_variable(container, ActivePowerReserveVariable, device_type) - post_reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) - for name in names - (device_type, name) in outaged_gens[uuid] && continue - for t in time_steps - cons[name, uuid, t] = JuMP.@constraint(jump_model, post_reserve[name, t] <= pre_reserve[name, t]) + time_steps = get_time_steps(container) + awards = Dict{Tuple{DataType, String, Int, Int}, JuMP.AffExpr}() + for model in values(services_template) + _validate_reserve_formulation(model) || continue + service_type = get_component_type(model) + for (service_name, per_type) in get_contributing_devices_map(model) + service = PSY.get_component(service_type, sys, service_name) + _has_ts_requirement(model, service) || continue + reserve = get_variable(container, ActivePowerReserveVariable, service_type) + for outage in PSY.get_supplemental_attributes(PSY.Outage, service) + uuid = IS.get_id(outage) + for (device_type, devices) in per_type + outaged = get(Set{String}, outaged_generators[uuid], device_type) + for device in devices + name = PSY.get_name(device) + name in outaged && continue + for t in time_steps + ex = get!(JuMP.AffExpr, awards, (device_type, name, uuid, t)) + JuMP.add_to_expression!(ex, reserve[(service_name, name, t)]) + end + end end end end end + for ((device_type, name, uuid, t), award) in awards + cons = lazy_container_addition!( + container, + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + device_type, + String[], + Int[], + Int[]; + sparse = true, + ) + deployment = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable, + device_type, + ) + cons[name, uuid, t] = + JuMP.@constraint(jump_model, deployment[name, uuid, t] <= award) + end return end @@ -437,9 +475,10 @@ function _post_contingency_flow_limits( ::PSY.System, network_model::NetworkModel{<:AbstractPTDFNetworkModel}, ::Type{T}, - entry_name::String, + name::String, ) where {T <: PSY.ACTransmission} catalog = get_branch_catalog(network_model) + entry_name = PNM.get_component_to_reduction_name_map(catalog, T)[name] arc = PNM.get_name_to_arc_map(catalog, T)[entry_name] return _emergency_flow_limits(PNM.get_reduction_entry(catalog, arc)) end @@ -461,6 +500,7 @@ function _constrain_post_contingency_flow!( monitored_components::_OUTAGE_MAP, network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) + catalog = get_branch_catalog(network_model) jump_model = get_jump_model(container) for (uuid, per_type) in monitored_components for (component_type, names) in per_type @@ -485,13 +525,15 @@ function _constrain_post_contingency_flow!( sparse = true, meta = "$(_G1_META)_ub", ) + reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, component_type) limits = Dict{String, MinMax}() - for name in names, t in time_steps - lims = get!(limits, name) do + for name in names, t in get_time_steps(container) + entry_name = reduction_name_map[name] + lims = get!(limits, entry_name) do _post_contingency_flow_limits(sys, network_model, component_type, name) end - cons_ub[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] <= lims.max) - cons_lb[name, uuid, t] = JuMP.@constraint(jump_model, flow[name, uuid, t] >= lims.min) + cons_ub[entry_name, uuid, t] = JuMP.@constraint(jump_model, flow[entry_name, uuid, t] <= lims.max) + cons_lb[entry_name, uuid, t] = JuMP.@constraint(jump_model, flow[entry_name, uuid, t] >= lims.min) end end end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 4b54416e..ce27d6e9 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -188,9 +188,11 @@ function construct_services!( contributing_devices, outaged_generators, monitored_components = _security_constrained_outages(sys, services_template, network_model) + _build_post_contingency_locational_power!(container, sys, contributing_devices, outaged_generators, network_model) _build_post_contingency_flow!(container, monitored_components, network_model) _constrain_post_contingency_balance!(container, sys, contributing_devices, outaged_generators, network_model) _constrain_post_contingency_generation!(container, sys, outaged_generators, contributing_devices) + _constrain_post_contingency_reserve!(container, sys, services_template, outaged_generators) _constrain_post_contingency_flow!(container, sys, monitored_components, network_model) return end @@ -1118,7 +1120,14 @@ function construct_service!(container::OptimizationContainer, sys::PSY.System, : ActivePowerReserveVariable, service, contributing_devices, - R, + RampReserve, + ) + add_to_expression!( + container, + ActivePowerReserveVariable, + service, + model, + devices_template, ) add_feedforward_arguments!(container, model, service) end diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 01df29b4..ff762fd3 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -114,7 +114,7 @@ function get_reserve_total_power_by_step_dict( total_variable_dict = Dict{Int, Vector{Float64}}() for outage in associated_outages outage_name = IS.get_id(outage) - outage_rows = filter(x -> x["name2"] == outage_name, required_variables) + outage_rows = filter(x -> x["name"] == outage_name, required_variables) outage_power_v = Vector{Float64}() for device in contributing_devices device_name = PSY.get_name(device) From 524d3ed10f183b865d16ec2d4b7c6ef5208a414f Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 11:53:33 -0400 Subject: [PATCH 15/22] formatting --- src/core/formulations.jl | 3 +- .../security_constrained_injectors.jl | 120 ++++++++++++++---- src/services_models/services_constructor.jl | 62 +++++++-- ...c_injection_security_constrained_models.jl | 12 +- 4 files changed, 159 insertions(+), 38 deletions(-) diff --git a/src/core/formulations.jl b/src/core/formulations.jl index 1db1a425..d599b4ee 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -842,7 +842,8 @@ per-(outage, generator) post-contingency active power expression. See also `SecurityConstrainedRampReserve`. """ -struct SecurityConstrainedContingencyReserve <: AbstractSecurityConstrainedReservesFormulation end +struct SecurityConstrainedContingencyReserve <: + AbstractSecurityConstrainedReservesFormulation end """ Security-constrained ramp reserve formulation: like `RampReserve` for the diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 547fc710..ef06a00c 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -5,7 +5,10 @@ const _G1_META = "G1" _validate_reserve_formulation(::ServiceModel) = false _validate_reserve_formulation( - ::ServiceModel{<:PSY.Reserve{PSY.ReserveUp}, <:AbstractSecurityConstrainedReservesFormulation}, + ::ServiceModel{ + <:PSY.Reserve{PSY.ReserveUp}, + <:AbstractSecurityConstrainedReservesFormulation, + }, ) = true _validate_reserve_formulation( ::ServiceModel{<:PSY.AbstractReserve, <:AbstractSecurityConstrainedReservesFormulation}, @@ -15,7 +18,8 @@ _validate_reserve_formulation( ), ) -_valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetworkModel}) = true +_valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetworkModel}) = + true _valid_component_type(::PSY.AreaInterchange, ::NetworkModel{AreaBalanceNetworkModel}) = true _valid_component_type(::PSY.Component, ::NetworkModel) = false @@ -31,7 +35,8 @@ function _security_constrained_outages( services_template::ServicesModelContainer, network_model::NetworkModel, ) - contributing_devices, outaged_generators, monitored_components = _OUTAGE_MAP(), _OUTAGE_MAP(), _OUTAGE_MAP() + contributing_devices, outaged_generators, monitored_components = + _OUTAGE_MAP(), _OUTAGE_MAP(), _OUTAGE_MAP() for model in values(services_template) _validate_reserve_formulation(model) || continue service_type = get_component_type(model) @@ -46,8 +51,15 @@ function _security_constrained_outages( haskey(outaged_generators, uuid) && continue outaged = outaged_generators[uuid] = _PER_TYPE() - for generator in PSY.get_associated_components(sys, outage; component_type = PSY.Generator) - push!(get!(Set{String}, outaged, typeof(generator)), PSY.get_name(generator)) + for generator in PSY.get_associated_components( + sys, + outage; + component_type = PSY.Generator, + ) + push!( + get!(Set{String}, outaged, typeof(generator)), + PSY.get_name(generator), + ) end monitored = monitored_components[uuid] = _PER_TYPE() @@ -135,14 +147,19 @@ _create_post_contingency_interchange_variables!( ::NetworkModel, ) = nothing -_deployment_expression_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment -_deployment_expression_type(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaActivePowerDeployment +_deployment_expression_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = + PostContingencyNodalActivePowerDeployment +_deployment_expression_type(::NetworkModel{AreaBalanceNetworkModel}) = + PostContingencyAreaActivePowerDeployment _deployment_component_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PSY.ACBus _deployment_component_type(::NetworkModel{AreaBalanceNetworkModel}) = PSY.Area -_location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string(PNM.get_mapped_bus_number(get_network_reduction(network_model), PSY.get_bus(component))) -_location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) +_location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string( + PNM.get_mapped_bus_number(get_network_reduction(network_model), PSY.get_bus(component)), +) +_location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = + PSY.get_name(PSY.get_area(PSY.get_bus(component))) # [contributing device reserve] minus [outaged generator power] per bus or area function _build_post_contingency_locational_power!( @@ -150,19 +167,32 @@ function _build_post_contingency_locational_power!( sys::PSY.System, contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, - network_model::NetworkModel + network_model::NetworkModel, ) - expr = lazy_container_addition!(container, _deployment_expression_type(network_model), _deployment_component_type(network_model), String[], Int[], Int[]; sparse = true) + expr = lazy_container_addition!( + container, + _deployment_expression_type(network_model), + _deployment_component_type(network_model), + String[], + Int[], + Int[]; + sparse = true, + ) jump_model = get_jump_model(container) uuids = collect(keys(contributing_devices)) time_steps = get_time_steps(container) for uuid in uuids for (device_type, names) in contributing_devices[uuid] - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + reserve = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable, + device_type, + ) outaged = get(Set{String}, outaged_generators[uuid], device_type) for name in names name in outaged && continue - key = _location_key(PSY.get_component(device_type, sys, name), network_model) + key = + _location_key(PSY.get_component(device_type, sys, name), network_model) for t in time_steps ex = get!(expr.data, (key, uuid, t), JuMP.AffExpr(0.0)) JuMP.add_to_expression!(ex, reserve[name, uuid, t]) @@ -172,7 +202,10 @@ function _build_post_contingency_locational_power!( for (generator_type, names) in outaged_generators[uuid] power = get_variable(container, ActivePowerVariable, generator_type) for name in names - key = _location_key(PSY.get_component(generator_type, sys, name), network_model) + key = _location_key( + PSY.get_component(generator_type, sys, name), + network_model, + ) for t in time_steps JuMP.add_to_expression!(expr[key, uuid, t], -1.0, power[name, t]) end @@ -228,7 +261,11 @@ function _build_post_contingency_flow!( ) end for t in time_steps - ex = expr[entry_name, uuid, t] = IOM.get_hinted_aff_expr(length(JuMP.linear_terms(pre_flow[entry_name, t])) + length(buses[uuid])) + ex = + expr[entry_name, uuid, t] = IOM.get_hinted_aff_expr( + length(JuMP.linear_terms(pre_flow[entry_name, t])) + + length(buses[uuid]), + ) JuMP.add_to_expression!(ex, pre_flow[entry_name, t]) for bus in buses[uuid] haskey(df, bus) || continue @@ -258,11 +295,19 @@ function _build_post_contingency_flow!( meta = _G1_META, ) flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) - deviation = get_variable(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange) + deviation = get_variable( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) modeled = Set{String}(axes(flow, 1)) for (uuid, per_type) in monitored_components for name in get(per_type, PSY.AreaInterchange, Set{String}()) - name in modeled || throw(IS.ConflictingInputsError("Monitored AreaInterchange $(name) is not modeled.")) + name in modeled || throw( + IS.ConflictingInputsError( + "Monitored AreaInterchange $(name) is not modeled.", + ), + ) for t in time_steps ex = expr[name, uuid, t] JuMP.add_to_expression!(ex, flow[name, t]) @@ -303,7 +348,11 @@ function _constrain_post_contingency_balance!( end end for (device_type, names) in contributing_devices[uuid] - reserve = get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, device_type) + reserve = get_variable( + container, + PostContingencyActivePowerReserveDeploymentVariable, + device_type, + ) outaged = get(outaged_generators[uuid], device_type, Set{String}()) for name in names name in outaged && continue @@ -329,7 +378,11 @@ function _constrain_post_contingency_balance!( # Area name => (sign, interchange name) interchanges = Dict{String, Vector{Tuple{Float64, String}}}() - if has_container_key(container, PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange) + if has_container_key( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) deviation = get_variable( container, PostContingencyAreaInterchangeFlowDeviationVariable, @@ -341,11 +394,15 @@ function _constrain_post_contingency_balance!( name in modeled || continue from_area = PSY.get_name(PSY.get_from_area(interchange)) to_area = PSY.get_name(PSY.get_to_area(interchange)) - push!(get!(Vector{Tuple{Float64, String}}, interchanges, from_area), (-1.0, name)) + push!( + get!(Vector{Tuple{Float64, String}}, interchanges, from_area), + (-1.0, name), + ) push!(get!(Vector{Tuple{Float64, String}}, interchanges, to_area), (1.0, name)) end end - deployment = get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) + deployment = + get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) area_names = PSY.get_name.(PSY.get_components(PSY.Area, sys)) cons = add_constraints_container!( @@ -402,7 +459,10 @@ function _constrain_post_contingency_generation!( PSY.get_max_active_power(PSY.get_component(device_type, sys, name), PSY.SU) end for t in time_steps - cons[name, uuid, t] = JuMP.@constraint(jump_model, power[name, t] + reserve[name, uuid, t] <= limit) + cons[name, uuid, t] = JuMP.@constraint( + jump_model, + power[name, t] + reserve[name, uuid, t] <= limit + ) end end end @@ -504,7 +564,12 @@ function _constrain_post_contingency_flow!( jump_model = get_jump_model(container) for (uuid, per_type) in monitored_components for (component_type, names) in per_type - flow = get_expression(container, _post_contingency_flow_expression(network_model), component_type, _G1_META) + flow = get_expression( + container, + _post_contingency_flow_expression(network_model), + component_type, + _G1_META, + ) cons_lb = lazy_container_addition!( container, PostContingencyFlowRateConstraint, @@ -525,15 +590,18 @@ function _constrain_post_contingency_flow!( sparse = true, meta = "$(_G1_META)_ub", ) - reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, component_type) + reduction_name_map = + PNM.get_component_to_reduction_name_map(catalog, component_type) limits = Dict{String, MinMax}() for name in names, t in get_time_steps(container) entry_name = reduction_name_map[name] lims = get!(limits, entry_name) do _post_contingency_flow_limits(sys, network_model, component_type, name) end - cons_ub[entry_name, uuid, t] = JuMP.@constraint(jump_model, flow[entry_name, uuid, t] <= lims.max) - cons_lb[entry_name, uuid, t] = JuMP.@constraint(jump_model, flow[entry_name, uuid, t] >= lims.min) + cons_ub[entry_name, uuid, t] = + JuMP.@constraint(jump_model, flow[entry_name, uuid, t] <= lims.max) + cons_lb[entry_name, uuid, t] = + JuMP.@constraint(jump_model, flow[entry_name, uuid, t] >= lims.min) end end end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index ce27d6e9..79a30db9 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -141,8 +141,16 @@ function construct_services!( contributing_devices, outaged_generators, monitored_components = _security_constrained_outages(sys, services_template, network_model) - _create_post_contingency_reserve_variables!(container, contributing_devices, outaged_generators) - _create_post_contingency_interchange_variables!(container, monitored_components, network_model) + _create_post_contingency_reserve_variables!( + container, + contributing_devices, + outaged_generators, + ) + _create_post_contingency_interchange_variables!( + container, + monitored_components, + network_model, + ) return end @@ -188,11 +196,33 @@ function construct_services!( contributing_devices, outaged_generators, monitored_components = _security_constrained_outages(sys, services_template, network_model) - _build_post_contingency_locational_power!(container, sys, contributing_devices, outaged_generators, network_model) + _build_post_contingency_locational_power!( + container, + sys, + contributing_devices, + outaged_generators, + network_model, + ) _build_post_contingency_flow!(container, monitored_components, network_model) - _constrain_post_contingency_balance!(container, sys, contributing_devices, outaged_generators, network_model) - _constrain_post_contingency_generation!(container, sys, outaged_generators, contributing_devices) - _constrain_post_contingency_reserve!(container, sys, services_template, outaged_generators) + _constrain_post_contingency_balance!( + container, + sys, + contributing_devices, + outaged_generators, + network_model, + ) + _constrain_post_contingency_generation!( + container, + sys, + outaged_generators, + contributing_devices, + ) + _constrain_post_contingency_reserve!( + container, + sys, + services_template, + outaged_generators, + ) _constrain_post_contingency_flow!(container, sys, monitored_components, network_model) return end @@ -1107,7 +1137,15 @@ function construct_service!( return end -function construct_service!(container::OptimizationContainer, sys::PSY.System, ::ArgumentConstructStage, model::ServiceModel{R, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) where {R <: PSY.AbstractReserve} +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + model::ServiceModel{R, SecurityConstrainedContingencyReserve}, + ::Dict, + ::Set, + ::NetworkModel, +) where {R <: PSY.AbstractReserve} services = _services_with_contributors(model, sys) isempty(services) && return ts_services = [s for s in services if _has_ts_requirement(model, s)] @@ -1134,4 +1172,12 @@ function construct_service!(container::OptimizationContainer, sys::PSY.System, : return end # These models are constructed entirely in construct_services! -construct_service!(::OptimizationContainer, ::PSY.System, ::ModelConstructStage, ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, ::Dict, ::Set, ::NetworkModel) = nothing +construct_service!( + ::OptimizationContainer, + ::PSY.System, + ::ModelConstructStage, + ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, + ::Dict, + ::Set, + ::NetworkModel, +) = nothing diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index ff762fd3..e181e09a 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -341,7 +341,10 @@ end ), ] gen = get_component(ThermalStandard, sys, "Solitude") - set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) + set_ramp_limits!( + gen, + (up = 0.4 * PSY.CU / u"minute", down = 0.4 * PSY.CU / u"minute"), + ) reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") component = get_component(ThermalStandard, sys, "Alta") attach_geometric_outage!(sys, component, [reserve_up]) @@ -408,7 +411,7 @@ end # because only the monitored subset contributes # `PostContingencyFlowRateConstraint` rows per outage step. gen = get_component(ThermalStandard, sys, "Solitude") - set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) #Increase ramp limits to make the problem feasible + set_ramp_limits!(gen, (up = 0.4 * PSY.CU / u"minute", down = 0.4 * PSY.CU / u"minute")) #Increase ramp limits to make the problem feasible reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") monitored_subset = [get_component(Line, sys, n) for n in monitored_line_names] component = get_component(ThermalStandard, sys, "Alta") @@ -535,7 +538,10 @@ end ), ] gen = get_component(ThermalStandard, sys, "Solitude") - set_ramp_limits!(gen, (up = 0.4 * PSY.CU/u"minute", down = 0.4 * PSY.CU/u"minute")) #Increase ramp limits to make the problem feasible + set_ramp_limits!( + gen, + (up = 0.4 * PSY.CU / u"minute", down = 0.4 * PSY.CU / u"minute"), + ) #Increase ramp limits to make the problem feasible reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") remove_time_series!( sys, From 12d513d637e1360141af9a6c8d10ab22c0cf81fc Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 12:51:01 -0400 Subject: [PATCH 16/22] claude fixing some tests; not a fan of the MOI checks, and then these slacks keys need to be reconsidered I think --- src/core/variables.jl | 2 + .../security_constrained_injectors.jl | 178 ++++++++++++++++-- src/services_models/services_constructor.jl | 64 +++++-- ...c_injection_security_constrained_models.jl | 31 ++- 4 files changed, 226 insertions(+), 49 deletions(-) diff --git a/src/core/variables.jl b/src/core/variables.jl index 0e08e35f..d686303e 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -895,3 +895,5 @@ convert_output_to_natural_units(::Type{HVDCLosses}) = true convert_output_to_natural_units(::Type{InterfaceFlowSlackUp}) = true convert_output_to_natural_units(::Type{InterfaceFlowSlackDown}) = true convert_output_to_natural_units(::Type{ActivePowerPumpVariable}) = true +convert_output_to_natural_units(::Type{PostContingencyActivePowerReserveDeploymentVariable}) = + true diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index ef06a00c..903b0dcf 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -130,7 +130,7 @@ function _create_post_contingency_interchange_variables!( sparse = true, ) for (uuid, per_type) in monitored_components - for name in get!(Vector{String}, per_type, PSY.AreaInterchange) + for name in get(Set{String}, per_type, PSY.AreaInterchange), t in time_steps var[name, uuid, t] = JuMP.@variable( jump_model, base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", @@ -147,6 +147,95 @@ _create_post_contingency_interchange_variables!( ::NetworkModel, ) = nothing +# Flow constraints are shared by every reserve responding to an outage, so the service +# models carrying that outage must agree on relaxing them. +function _create_post_contingency_flow_slacks!( + container::OptimizationContainer, + sys::PSY.System, + services_template::ServicesModelContainer, + monitored_components::_OUTAGE_MAP, + network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, +) + use_slacks = Dict{Int, Bool}() + for model in values(services_template) + _validate_reserve_formulation(model) || continue + service_type = get_component_type(model) + model_slacks = get_use_slacks(model) + for service_name in keys(get_contributing_devices_map(model)) + service = PSY.get_component(service_type, sys, service_name) + for outage in PSY.get_supplemental_attributes(PSY.Outage, service) + uuid = IS.get_id(outage) + if get!(use_slacks, uuid, model_slacks) != model_slacks + throw( + IS.ConflictingInputsError( + "Outage $uuid is attached to security-constrained reserves \ + with different `use_slacks` settings; set `use_slacks` \ + consistently across their service models.", + ), + ) + end + end + end + end + + jump_model = get_jump_model(container) + time_steps = get_time_steps(container) + for (uuid, per_type) in monitored_components + use_slacks[uuid] || continue + for (component_type, names) in per_type + # Meta keeps these apart from the branch-side MODF slacks of the same type. + slack_ub = lazy_container_addition!( + container, + PostContingencyFlowActivePowerSlackUpperBound, + component_type, + String[], + Int[], + Int[]; + sparse = true, + meta = _G1_META, + ) + slack_lb = lazy_container_addition!( + container, + PostContingencyFlowActivePowerSlackLowerBound, + component_type, + String[], + Int[], + Int[]; + sparse = true, + meta = _G1_META, + ) + for entry_name in + keys(_post_contingency_flow_entries(network_model, component_type, names)), + t in time_steps + + ub = slack_ub[entry_name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyFlowActivePowerSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + lower_bound = 0.0, + ) + lb = slack_lb[entry_name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyFlowActivePowerSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + lower_bound = 0.0, + ) + add_to_objective_invariant_expression!( + container, + (ub + lb) * CONSTRAINT_VIOLATION_SLACK_COST, + ) + end + end + end + return +end + +_create_post_contingency_flow_slacks!( + ::OptimizationContainer, + ::PSY.System, + ::ServicesModelContainer, + ::_OUTAGE_MAP, + ::NetworkModel, +) = nothing + _deployment_expression_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyNodalActivePowerDeployment _deployment_expression_type(::NetworkModel{AreaBalanceNetworkModel}) = @@ -233,7 +322,7 @@ function _build_post_contingency_flow!( t == first(time_steps) && push!(get!(Vector{String}, buses, uuid), bus) end - # Signed distribution factors per monitored branch, keyed by the nodal bus key. + # Arc distribution factors per reduced entry, keyed by the nodal bus key. dfs = Dict{Tuple{DataType, String}, Dict{String, Float64}}() for (uuid, per_type) in monitored_components for (line_type, names) in per_type @@ -248,15 +337,13 @@ function _build_post_contingency_flow!( meta = _G1_META, ) pre_flow = get_expression(container, PTDFBranchFlow, line_type) - reduction_name_map = PNM.get_component_to_reduction_name_map(catalog, line_type) arc_map = PNM.get_name_to_arc_map(catalog, line_type) - for name in names - entry_name = reduction_name_map[name] - df = get!(dfs, (line_type, name)) do + for entry_name in + keys(_post_contingency_flow_entries(network_model, line_type, names)) + df = get!(dfs, (line_type, entry_name)) do ptdf_col = ptdf[arc_map[entry_name], :] - sign = get_ptdf_orientation_sign(catalog, line_type, name) Dict{String, Float64}( - string(bus_axis[i]) => sign * ptdf_col[i] for + string(bus_axis[i]) => ptdf_col[i] for i in eachindex(ptdf_col) if abs(ptdf_col[i]) > PTDF_ZERO_TOL ) end @@ -531,6 +618,28 @@ _post_contingency_flow_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = _post_contingency_flow_expression(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaInterchangeFlow +# Post-contingency flow key => a representative monitored component name. Parallel +# circuits share one reduced entry, so each entry is constrained once. +function _post_contingency_flow_entries( + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, + ::Type{T}, + names::Set{String}, +) where {T <: PSY.ACTransmission} + reduction_name_map = + PNM.get_component_to_reduction_name_map(get_branch_catalog(network_model), T) + entries = Dict{String, String}() + for name in names + get!(entries, reduction_name_map[name], name) + end + return entries +end + +_post_contingency_flow_entries( + ::NetworkModel{AreaBalanceNetworkModel}, + ::Type{PSY.AreaInterchange}, + names::Set{String}, +) = Dict{String, String}(name => name for name in names) + function _post_contingency_flow_limits( ::PSY.System, network_model::NetworkModel{<:AbstractPTDFNetworkModel}, @@ -553,15 +662,14 @@ function _post_contingency_flow_limits( return (min = -limits.to_from, max = limits.from_to) end -# TODO: slacks? function _constrain_post_contingency_flow!( container::OptimizationContainer, sys::PSY.System, monitored_components::_OUTAGE_MAP, network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) - catalog = get_branch_catalog(network_model) jump_model = get_jump_model(container) + time_steps = get_time_steps(container) for (uuid, per_type) in monitored_components for (component_type, names) in per_type flow = get_expression( @@ -590,18 +698,48 @@ function _constrain_post_contingency_flow!( sparse = true, meta = "$(_G1_META)_ub", ) - reduction_name_map = - PNM.get_component_to_reduction_name_map(catalog, component_type) - limits = Dict{String, MinMax}() - for name in names, t in get_time_steps(container) - entry_name = reduction_name_map[name] - lims = get!(limits, entry_name) do + has_slacks = has_container_key( + container, + PostContingencyFlowActivePowerSlackUpperBound, + component_type, + _G1_META, + ) + if has_slacks + slack_ub = get_variable( + container, + PostContingencyFlowActivePowerSlackUpperBound, + component_type, + _G1_META, + ) + slack_lb = get_variable( + container, + PostContingencyFlowActivePowerSlackLowerBound, + component_type, + _G1_META, + ) + end + for (entry_name, name) in + _post_contingency_flow_entries(network_model, component_type, names) + lims = _post_contingency_flow_limits(sys, network_model, component_type, name) + for t in time_steps + f = flow[entry_name, uuid, t] + if has_slacks && haskey(slack_ub.data, (entry_name, uuid, t)) + cons_ub[entry_name, uuid, t] = JuMP.@constraint( + jump_model, + f - slack_ub[entry_name, uuid, t] <= lims.max + ) + cons_lb[entry_name, uuid, t] = JuMP.@constraint( + jump_model, + f + slack_lb[entry_name, uuid, t] >= lims.min + ) + else + cons_ub[entry_name, uuid, t] = + JuMP.@constraint(jump_model, f <= lims.max) + cons_lb[entry_name, uuid, t] = + JuMP.@constraint(jump_model, f >= lims.min) + end end - cons_ub[entry_name, uuid, t] = - JuMP.@constraint(jump_model, flow[entry_name, uuid, t] <= lims.max) - cons_lb[entry_name, uuid, t] = - JuMP.@constraint(jump_model, flow[entry_name, uuid, t] >= lims.min) end end end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index 79a30db9..dc73e6ac 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -151,6 +151,13 @@ function construct_services!( monitored_components, network_model, ) + _create_post_contingency_flow_slacks!( + container, + sys, + services_template, + monitored_components, + network_model, + ) return end @@ -1142,9 +1149,9 @@ function construct_service!( sys::PSY.System, ::ArgumentConstructStage, model::ServiceModel{R, SecurityConstrainedContingencyReserve}, - ::Dict, - ::Set, - ::NetworkModel, + devices_template::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, ) where {R <: PSY.AbstractReserve} services = _services_with_contributors(model, sys) isempty(services) && return @@ -1171,13 +1178,46 @@ function construct_service!( end return end -# These models are constructed entirely in construct_services! -construct_service!( - ::OptimizationContainer, - ::PSY.System, + +function construct_service!( + container::OptimizationContainer, + sys::PSY.System, ::ModelConstructStage, - ::ServiceModel{<:PSY.AbstractReserve, SecurityConstrainedContingencyReserve}, - ::Dict, - ::Set, - ::NetworkModel, -) = nothing + model::ServiceModel{R, SecurityConstrainedContingencyReserve}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, +) where {R <: PSY.AbstractReserve} + services = _services_with_contributors(model, sys) + isempty(services) && return + service_names = PSY.get_name.(services) + add_constraints_container!( + container, + RequirementConstraint, + R, + service_names, + get_time_steps(container), + ) + get_use_slacks(model) && add_reserve_slacks!(container, R, service_names) + for service in services + contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + add_constraints!( + container, + RequirementConstraint, + service, + contributing_devices, + model, + ) + add_constraints!( + container, + ParticipationFractionConstraint, + service, + contributing_devices, + model, + ) + add_to_objective_function!(container, service, model) + add_feedforward_constraints!(container, model, service) + end + add_constraint_dual!(container, sys, model) + return +end diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index e181e09a..b37db41c 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -114,12 +114,12 @@ function get_reserve_total_power_by_step_dict( total_variable_dict = Dict{Int, Vector{Float64}}() for outage in associated_outages outage_name = IS.get_id(outage) - outage_rows = filter(x -> x["name"] == outage_name, required_variables) outage_power_v = Vector{Float64}() for device in contributing_devices - device_name = PSY.get_name(device) + # Sparse (device, outage, t) outputs are flattened to a single "device__outage" column. + column = IOM.encode_tuple_to_column((PSY.get_name(device), string(outage_name))) current_v = - filter(x -> x[col_name] == device_name, outage_rows)[!, "value"] + filter(x -> x[col_name] == column, required_variables)[!, "value"] # The outaged generator has no deployment rows. isempty(current_v) && continue if isempty(outage_power_v) @@ -364,7 +364,7 @@ end ps_model, sys, constraint_keys, - [360, 0, 600, 432, 48], + [576, 0, 888, 480, 48], false, GAEVF, 329000.0, @@ -407,7 +407,7 @@ end PSY.ThermalStandard, ), ] - # Counts are smaller than the all-lines baseline `[360, 0, 600, 432, 72]` + # Counts are smaller than the all-lines baseline `[576, 0, 888, 480, 48]` # because only the monitored subset contributes # `PostContingencyFlowRateConstraint` rows per outage step. gen = get_component(ThermalStandard, sys, "Solitude") @@ -437,7 +437,7 @@ end ps_model, sys, constraint_keys, - [360, 0, 504, 336, 48], + [576, 0, 792, 384, 48], false, GAEVF, 329000.0, @@ -496,7 +496,7 @@ end ps_model, sys, constraint_keys, - [360, 0, 504, 432, 48], + [576, 0, 600, 480, 48], false, GAEVF, 329000.0, @@ -505,7 +505,7 @@ 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 +@testset "G-n with contingency reserves deliverability constraints including responding reserves only up, NO requirement time series, 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) @@ -567,7 +567,7 @@ end ps_model, sys, constraint_keys, - [240, 0, 504, 408, 72], + [576, 0, 504, 480, 48], false, GAEVF, 329000.0, @@ -598,8 +598,7 @@ end 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, TwoWindingTransformer, StaticBranch) set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) set_service_model!(template, @@ -634,8 +633,7 @@ end 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, TwoWindingTransformer, StaticBranch) set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) set_service_model!(template, @@ -679,8 +677,7 @@ end 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, TwoWindingTransformer, StaticBranch) set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) set_service_model!(template, @@ -741,7 +738,7 @@ end ps_model, sys, constraint_keys, - [960, 0, 1296, 600, 192], + [936, 0, 1248, 480, 168], true, GAEVF, 254242.0, @@ -794,7 +791,7 @@ end if reserve_slack moi_counts = [2040, 0, 1536, 1200, 120] else - moi_counts = [744, 0, 1536, 1200, 120] + moi_counts = [1176, 0, 2112, 1296, 120] end run_sc_reserve_case!( ps_model, From 25f91d29f916d06650265f9dcd67daa79743a391 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 21:48:21 -0400 Subject: [PATCH 17/22] reorganize around ComponentPair-keyed reserve variables; update rest of reserves code; enumx --- Project.toml | 34 +- ext/PowerFlowsExt/pf_headroom.jl | 10 +- src/PowerOperationsModels.jl | 1 + src/common_models/add_to_expression.jl | 8 +- src/common_models/converter_control.jl | 8 +- src/core/expressions.jl | 6 + src/core/variables.jl | 4 +- src/energy_storage_models/storage_models.jl | 2 +- src/hybrid_system_models/hybrid_systems.jl | 4 +- src/services_models/reserve_group.jl | 38 +- src/services_models/reserve_offers.jl | 8 +- src/services_models/reserves.jl | 302 ++++++------ .../security_constrained_injectors.jl | 454 +++++++++--------- src/services_models/services_constructor.jl | 167 ++++--- .../hydro_generation.jl | 8 +- .../thermal_generation.jl | 6 +- test/Project.toml | 43 +- test/test_device_reserve_offers.jl | 105 ++-- test/test_model_decision.jl | 32 +- test/test_services_constructor.jl | 55 ++- ...c_injection_security_constrained_models.jl | 22 +- 21 files changed, 676 insertions(+), 641 deletions(-) diff --git a/Project.toml b/Project.toml index c6989cea..f4d662b7 100644 --- a/Project.toml +++ b/Project.toml @@ -7,16 +7,16 @@ authors = ["Sienna-Platform"] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" InfraStore = "6aee0376-896a-4a59-96ff-12f221c99746" +InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" InfrastructureOptimizationModels = "bed98974-b02a-5e2f-9ee0-a103f5c45069" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" +InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" -InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" -InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" PowerDynamicsOpenAPIModels = "044a0b22-31f8-4ef6-8282-c9a61b3013f6" PowerInvestmentsOpenAPIModels = "33cb4396-f4d9-4f59-8585-787ebb56cb1b" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" @@ -24,8 +24,8 @@ PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" @@ -34,23 +34,17 @@ TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" [sources] -InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git", rev = "IS4"} -InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} -PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} +InfrastructureCoreOpenAPIModels = {rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +InfrastructureOptimizationModels = {path = "/Users/acostare/Sienna/InfrastructureOptimizationModels.jl/service-containers/"} +InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} +InfrastructureTimeSeriesOpenAPIModels = {rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerCoreOpenAPIModels = {rev = "main", subdir = "PowerCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerDynamicsOpenAPIModels = {rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerInvestmentsOpenAPIModels = {rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} -# PSY psy6 depends on these unregistered packages; [sources] of non-root projects are -# ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as -# PSY's own sources). POM imports none of them: they sit in [deps] only because Pkg -# rejects a [sources] entry for a package absent from [deps]. Mirrors PSY's own -# Project.toml. -# Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} -PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerOpenAPIModels = {rev = "main", subdir = "PowerOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerOperationsOpenAPIModels = {rev = "main", subdir = "PowerOperationsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl.git"} [extensions] PowerFlowsExt = "PowerFlows" @@ -65,7 +59,7 @@ PowerFlows = "^0.24" PowerNetworkMatrices = "^0.24" PowerSystems = "^5.10" PrettyTables = "3" -Random = "1" ProgressMeter = "1.11.0" +Random = "1" TimerOutputs = "1" julia = "^1.11" diff --git a/ext/PowerFlowsExt/pf_headroom.jl b/ext/PowerFlowsExt/pf_headroom.jl index 41855c12..636f60dd 100644 --- a/ext/PowerFlowsExt/pf_headroom.jl +++ b/ext/PowerFlowsExt/pf_headroom.jl @@ -13,7 +13,7 @@ _accumulate_headroom!( ::OptimizationContainerKey{<:ISOPT.ParameterType, <:PSY.Component}, ::Dict{String, Int}, ::Int, - ::Matrix{PSY.ACBusTypes}, + ::Matrix{PSY.ACBusTypes.Value}, ::Vector{Dict{Tuple{DataType, String}, Float64}}, ) = nothing @@ -27,7 +27,7 @@ _accumulate_headroom!( ::OptimizationContainerKey{<:ISOPT.OptimizationKeyType, <:PSY.Storage}, ::Dict{String, Int}, ::Int, - ::Matrix{PSY.ACBusTypes}, + ::Matrix{PSY.ACBusTypes.Value}, ::Vector{Dict{Tuple{DataType, String}, Float64}}, ) = nothing @@ -40,7 +40,7 @@ _accumulate_headroom!( ::OptimizationContainerKey{<:ISOPT.ParameterType, <:PSY.Storage}, ::Dict{String, Int64}, ::Int, - ::Matrix{PSY.ACBusTypes}, + ::Matrix{PSY.ACBusTypes.Value}, ::Vector{Dict{Tuple{DataType, String}, Float64}}, ) = nothing @@ -60,7 +60,7 @@ function _accumulate_headroom!( key::OptimizationContainerKey{<:ISOPT.OptimizationKeyType, U}, component_map::Dict{String, Int}, n_time_steps::Int, - bus_types::Matrix{PSY.ACBusTypes}, + bus_types::Matrix{PSY.ACBusTypes.Value}, computed_gspf::Vector{Dict{Tuple{DataType, String}, Float64}}, ) where {U <: PSY.Component} result = lookup_value(container, key) @@ -129,7 +129,7 @@ function _update_headroom_participation_factors!( PFS.get_computed_gspf(pf_data)::Vector{Dict{Tuple{DataType, String}, Float64}} n_time_steps = length(get_time_steps(container)) - bus_types = PFS.get_bus_type(pf_data)::Matrix{PSY.ACBusTypes} + bus_types = PFS.get_bus_type(pf_data)::Matrix{PSY.ACBusTypes.Value} bus_slack_pf = PFS.get_bus_slack_participation_factors( pf_data, diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index bf31a7ab..ede2b6e3 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -910,6 +910,7 @@ export BThetaBranchFlow export PostContingencyNodalActivePowerDeployment export PostContingencyAreaActivePowerDeployment export PostContingencyAreaInterchangeFlow +export PostContingencyTotalReserveDeployment ################################################################################# # Exports - Parameter Types (defined in core/parameters.jl) diff --git a/src/common_models/add_to_expression.jl b/src/common_models/add_to_expression.jl index 59acd065..f1e8c0ea 100644 --- a/src/common_models/add_to_expression.jl +++ b/src/common_models/add_to_expression.jl @@ -1971,7 +1971,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) - variable = get_variable(container, U, X) + variable = get_variable(container, U, IOM.ComponentPairKey{V, X}) if !has_container_key(container, T, V) add_expressions!(container, T, devices, model) end @@ -2410,7 +2410,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) - variable = get_variable(container, U, X) + variable = get_variable(container, U, IOM.ComponentPairKey{V, X}) if !has_container_key(container, T, V) add_expressions!(container, T, devices, model) end @@ -2444,7 +2444,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) - variable = get_variable(container, U, X) + variable = get_variable(container, U, IOM.ComponentPairKey{V, X}) if !has_container_key(container, T, V) add_expressions!(container, T, devices, model) end @@ -2478,7 +2478,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) - variable = get_variable(container, U, X) + variable = get_variable(container, U, IOM.ComponentPairKey{V, X}) if !has_container_key(container, T, V) add_expressions!(container, T, devices, model) end diff --git a/src/common_models/converter_control.jl b/src/common_models/converter_control.jl index 3ac476a4..480374bc 100644 --- a/src/common_models/converter_control.jl +++ b/src/common_models/converter_control.jl @@ -18,7 +18,7 @@ end # AC control on one terminal/converter: AC_VOLTAGE pins the regulated bus # VoltageMagnitude; AC_REACTIVE_POWER pins the reactive injection to its setpoint. function _fix_converter_ac_control!( - mode::PSY.VSCACControlModes, + mode::PSY.VSCACControlModes.Value, setpoint::Float64, vm, bus_name::String, @@ -43,7 +43,7 @@ end # magnitude deviation phi = |V| - 1, so AC_VOLTAGE pins phi to setpoint - 1. # AC_REACTIVE_POWER pins the reactive injection to its setpoint, unshifted. function _fix_converter_ac_control_lpacc!( - mode::PSY.VSCACControlModes, + mode::PSY.VSCACControlModes.Value, setpoint::Float64, phi, bus_name::String, @@ -70,7 +70,7 @@ end function _fill_converter_dc_control!( jump_model, con::AbstractArray, - mode::PSY.VSCDCControlModes, + mode::PSY.VSCDCControlModes.Value, setpoint::Float64, droop_gain::Float64, vdc_var, @@ -103,7 +103,7 @@ end # through the RegulatedVoltageMagnitude aux variable by the caller, not here): # AC_REACTIVE_POWER pins the reactive injection; AC_VOLTAGE is a no-op here. function _fix_converter_ac_reactive!( - mode::PSY.VSCACControlModes, + mode::PSY.VSCACControlModes.Value, setpoint::Float64, q_var, name::String, diff --git a/src/core/expressions.jl b/src/core/expressions.jl index 69e326c1..88b811de 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -138,6 +138,12 @@ Per-interchange flow considering post-contingency reserve deployment. """ struct PostContingencyAreaInterchangeFlow <: ExpressionType end +""" +Post-contingency reserve deployed by a contributing device, summed across every +security-constrained reserve responding to the outage. +""" +struct PostContingencyTotalReserveDeployment <: ExpressionType end + # Method extensions for output writing should_write_resulting_value(::Type{InterfaceTotalFlow}) = true should_write_resulting_value(::Type{PTDFBranchFlow}) = true diff --git a/src/core/variables.jl b/src/core/variables.jl index d686303e..99c6ec2b 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -895,5 +895,7 @@ convert_output_to_natural_units(::Type{HVDCLosses}) = true convert_output_to_natural_units(::Type{InterfaceFlowSlackUp}) = true convert_output_to_natural_units(::Type{InterfaceFlowSlackDown}) = true convert_output_to_natural_units(::Type{ActivePowerPumpVariable}) = true -convert_output_to_natural_units(::Type{PostContingencyActivePowerReserveDeploymentVariable}) = +convert_output_to_natural_units( + ::Type{PostContingencyActivePowerReserveDeploymentVariable}, +) = true diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index dc1b7def..56dce775 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -724,7 +724,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } s_name = PSY.get_name(service) - variable = get_variable(container, U, V) + variable = get_variable(container, U, IOM.ComponentPairKey{UV, V}) for d in devices name = PSY.get_name(d) expression = get_expression(container, T, UV, _service_container_meta(service)) diff --git a/src/hybrid_system_models/hybrid_systems.jl b/src/hybrid_system_models/hybrid_systems.jl index a8329e28..ef06556a 100644 --- a/src/hybrid_system_models/hybrid_systems.jl +++ b/src/hybrid_system_models/hybrid_systems.jl @@ -744,7 +744,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } s_name = PSY.get_name(service) - variable = get_variable(container, U, V) + variable = get_variable(container, U, IOM.ComponentPairKey{UV, V}) for d in devices name = PSY.get_name(d) expression = get_expression(container, T, UV, _service_container_meta(service)) @@ -2065,7 +2065,7 @@ function add_constraints!( names, time_steps; meta = "$(s_type)_$s_name") # System-level reserve variable for this service, keyed `(service, device, time)`. - sys_reserve = get_variable(container, ActivePowerReserveVariable, s_type) + sys_reserve = _reserve_variable(container, V, s_type) # Per-hybrid reserve variables for this service r_out = get_variable( container, diff --git a/src/services_models/reserve_group.jl b/src/services_models/reserve_group.jl index c495c1b5..588d825d 100644 --- a/src/services_models/reserve_group.jl +++ b/src/services_models/reserve_group.jl @@ -112,10 +112,13 @@ function check_activeservice_variables( ) where {T <: PSY.Service} for service in contributing_services service_name = PSY.get_name(service) - variable = get_variable(container, ActivePowerReserveVariable, typeof(service)) - # The container is keyed `(service_name, device_name, time)` and shared by the whole - # service type, so check for this service's own entries, not just that it exists. - any(k -> k[1] == service_name, keys(variable.data)) || error( + # Containers are keyed `(service_name, device_name, time)` and shared by the whole + # service type, so check for this service's own entries, not just that one exists. + has_entries = any( + variable -> any(k -> k[1] == service_name, keys(variable.data)), + _reserve_variables(container, typeof(service)), + ) + has_entries || error( "The contributing service $service_name has no ActivePowerReserveVariable \ entries; it must be modeled before the group reserve that references it.", ) @@ -196,8 +199,8 @@ end # Collect the group's contributing reserve variables into one bucket per time step, so the # constraint loop above indexes straight in rather than re-scanning per `(group, t)`. Services -# of the same type share one `(service_name, device_name, time)` container, so each container -# is scanned once. +# of the same type share their `(service_name, device_name, time)` containers, so each service +# type is scanned once. function _group_member_variables( container::OptimizationContainer, contributing_services::Vector{<:PSY.Service}, @@ -210,11 +213,26 @@ function _group_member_variables( rtype = typeof(r) rtype in scanned && continue push!(scanned, rtype) - reserve_variable = get_variable(container, ActivePowerReserveVariable, rtype) - for (key, var) in reserve_variable.data - key[1] in member_names || continue - push!(index[key[3]], var) + for reserve_variable in _reserve_variables(container, rtype) + for (key, var) in reserve_variable.data + key[1] in member_names || continue + push!(index[key[3]], var) + end end end return index end + +# Award containers of every contributing device type for service type `SR`. Groups only know +# their member services, not the members' contributing device types. +function _reserve_variables( + container::OptimizationContainer, + ::Type{SR}, +) where {SR <: PSY.Service} + S = get_component_type(VariableKey(ActivePowerReserveVariable, SR)) + return [ + variable for (key, variable) in IOM.get_variables(container) if + IOM.get_entry_type(key) === ActivePowerReserveVariable && + get_component_type(key) <: IOM.ComponentPairKey{<:PSY.Component, S} + ] +end diff --git a/src/services_models/reserve_offers.jl b/src/services_models/reserve_offers.jl index e60dad28..f1a2bfd0 100644 --- a/src/services_models/reserve_offers.jl +++ b/src/services_models/reserve_offers.jl @@ -22,7 +22,7 @@ _cost_offers_reserve(cost::Union{PSY.MarketBidCost, PSY.MarketBidTimeSeriesCost} _cost_offers_reserve(::PSY.OperationalCost, service) = false # Price every contributing device that offers into `service` by its offer curve; returns the set of -# device names so priced (the flat-cost pass skips them). +# `(device type, device name)` so priced (the flat-cost pass skips them). # A group has no contributing devices, so it can carry no per-device offers; with # `GroupReserve <: AbstractReserve` the generic method below would otherwise accept it. # Offers live on the group's contributing services and are priced by their own models. @@ -45,19 +45,19 @@ function add_reserve_offer_costs!( model::ServiceModel{SR, T}, ) where {SR <: PSY.AbstractReserve, T <: AbstractReservesFormulation} service_name = PSY.get_name(service) - award = get_variable(container, ActivePowerReserveVariable, SR) time_steps = get_time_steps(container) resolution = get_resolution(container) dt = Dates.value(Dates.Second(resolution)) / SECONDS_IN_HOUR base_p = get_model_base_power(container) initial_time = IOM.get_initial_time(container) jump_model = get_jump_model(container) - offered = Set{String}() + offered = Set{Tuple{DataType, String}}() for (device_type, devices) in get_contributing_devices_map(model, service_name) offering = [d for d in devices if _has_reserve_offer(d, service)] isempty(offering) && continue names = [PSY.get_name(d) for d in offering] + award = _reserve_variable(container, device_type, SR) # Block var keyed `(service, device, segment, time)` via # `IOM.sparse_variable_key_type(PiecewiseLinearBlockReserveOffer)`. Segments vary per # device and time, so they are filled sparsely below. @@ -99,7 +99,7 @@ function add_reserve_offer_costs!( add_to_objective_invariant_expression!( container, get_pwl_cost_expression_delta(pwl_vars, slopes, dt)) end - push!(offered, dev_name) + push!(offered, (device_type, dev_name)) end end return offered diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index b65e4ffc..fe2eaec0 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -197,41 +197,58 @@ function add_reserve_variables!( return end -# Sum the reserve provision of one service across its contributing devices at time `t`, -# reading the service type's sparse container keyed `(service_name, device_name, time)`. +_reserve_variable( + container::OptimizationContainer, + ::Type{D}, + ::Type{SR}, +) where {D <: PSY.Component, SR <: PSY.Service} = + get_variable(container, ActivePowerReserveVariable, IOM.ComponentPairKey{D, SR}) + +# Sum the reserve provision of one service across all its contributing device types at time `t`. function _sum_service_reserves( - reserve_variable::SparseAxisArray, + container::OptimizationContainer, + ::Type{SR}, service_name::String, - contributing_devices::U, + contributing_devices::AbstractDict, t::Int, extra::Int, -) where { - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, -} where {D <: PSY.Component} - acc = IOM.get_hinted_aff_expr(length(contributing_devices) + extra) - for d in contributing_devices - JuMP.add_to_expression!(acc, reserve_variable[(service_name, PSY.get_name(d), t)]) +) where {SR <: PSY.Service} + acc = IOM.get_hinted_aff_expr( + sum(length, values(contributing_devices); init = 0) + extra, + ) + for devices in values(contributing_devices) + _add_service_reserves!(acc, container, SR, service_name, devices, t) end return acc end +function _add_service_reserves!( + acc::JuMP.AffExpr, + container::OptimizationContainer, + ::Type{SR}, + service_name::String, + devices::Vector{D}, + t::Int, +) where {SR <: PSY.Service, D <: PSY.Component} + reserve_variable = _reserve_variable(container, D, SR) + for d in devices + JuMP.add_to_expression!(acc, reserve_variable[(service_name, PSY.get_name(d), t)]) + end + return +end + ################################## Reserve Requirement Constraint ########################## function add_constraints!( container::OptimizationContainer, T::Type{RequirementConstraint}, service::SR, - contributing_devices::U, + contributing_devices::AbstractDict, model::ServiceModel{SR, V}, -) where { - SR <: PSY.AbstractReserve, - V <: AbstractReservesFormulation, - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, -} where {D <: PSY.Component} +) where {SR <: PSY.AbstractReserve, V <: AbstractReservesFormulation} time_steps = get_time_steps(container) service_name = PSY.get_name(service) # Dense container keyed `[service_name, time]`, built per type; fill this service's row. constraint = get_constraint(container, T, SR) - reserve_variable = get_variable(container, ActivePowerReserveVariable, SR) use_slacks = get_use_slacks(model) use_slacks && (slack_vars = get_variable(container, ReserveRequirementSlack, SR)) requirement = _get_requirement(service) @@ -247,7 +264,7 @@ function add_constraints!( param = get_parameter_column_refs(param_container, service_name) for t in time_steps resource_expression = - _sum_service_reserves(reserve_variable, service_name, + _sum_service_reserves(container, SR, service_name, contributing_devices, t, extra) use_slacks && @@ -270,7 +287,7 @@ function add_constraints!( ) for t in time_steps resource_expression = - _sum_service_reserves(reserve_variable, service_name, + _sum_service_reserves(container, SR, service_name, contributing_devices, t, extra) use_slacks && @@ -287,7 +304,7 @@ function add_constraints!( else for t in time_steps resource_expression = - _sum_service_reserves(reserve_variable, service_name, contributing_devices, + _sum_service_reserves(container, SR, service_name, contributing_devices, t, extra) use_slacks && @@ -303,13 +320,9 @@ function add_constraints!( container::OptimizationContainer, T::Type{ParticipationFractionConstraint}, service::SR, - contributing_devices::U, + contributing_devices::AbstractDict, model::ServiceModel{SR, V}, -) where { - SR <: PSY.AbstractReserve, - V <: AbstractReservesFormulation, - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, -} where {D <: PSY.Device} +) where {SR <: PSY.AbstractReserve, V <: AbstractReservesFormulation} max_participation_factor = PSY.get_max_participation_factor(service) if max_participation_factor >= 1.0 @@ -321,12 +334,10 @@ function add_constraints!( # Sparse constraint container keyed `(service_name, device_name, time)`. cons = lazy_container_addition!(container, T, SR, [service_name], - [PSY.get_name(d) for d in contributing_devices], + [PSY.get_name(d) for devices in values(contributing_devices) for d in devices], time_steps; sparse = true, ) - var_r = get_variable(container, ActivePowerReserveVariable, SR) - jump_model = get_jump_model(container) requirement = _get_requirement(service) cap = requirement * max_participation_factor # Static reserve: constant participation cap. Time-varying reserve: scale by the requirement @@ -336,13 +347,7 @@ function add_constraints!( param_container = get_parameter(container, RequirementTimeSeriesParameter, SR) param = get_parameter_column_refs(param_container, service_name) - for t in time_steps, d in contributing_devices - name = PSY.get_name(d) - cons[(service_name, name, t)] = JuMP.@constraint( - jump_model, - var_r[(service_name, name, t)] <= cap * param[t] - ) - end + limits = [cap * param[t] for t in time_steps] else ts_vector = IOM.get_time_series( container, @@ -350,24 +355,35 @@ function add_constraints!( get_time_series_names(model)[RequirementTimeSeriesParameter]; interval = get_interval(get_settings(container)), ) - for t in time_steps, d in contributing_devices - name = PSY.get_name(d) - cons[(service_name, name, t)] = JuMP.@constraint( - jump_model, - var_r[(service_name, name, t)] <= cap * ts_vector[t] - ) - end + limits = [cap * ts_vector[t] for t in time_steps] end else - for t in time_steps, d in contributing_devices - name = PSY.get_name(d) - cons[(service_name, name, t)] = JuMP.@constraint( - jump_model, - var_r[(service_name, name, t)] <= cap - ) - end + limits = fill(cap, length(time_steps)) + end + for devices in values(contributing_devices) + _add_participation_fraction_constraints!( + cons, container, SR, service_name, devices, limits) end + return +end +function _add_participation_fraction_constraints!( + cons::SparseAxisArray, + container::OptimizationContainer, + ::Type{SR}, + service_name::String, + devices::Vector{D}, + limits::Vector, +) where {SR <: PSY.AbstractReserve, D <: PSY.Component} + var_r = _reserve_variable(container, D, SR) + jump_model = get_jump_model(container) + for t in get_time_steps(container), d in devices + name = PSY.get_name(d) + cons[(service_name, name, t)] = JuMP.@constraint( + jump_model, + var_r[(service_name, name, t)] <= limits[t] + ) + end return end @@ -379,10 +395,9 @@ function add_to_objective_function!( # Devices that submitted a reserve OFFER are priced by their offer curve; the rest keep the # flat DEFAULT_RESERVE_COST. offered = add_reserve_offer_costs!(container, service, model) - contributing_names = - [PSY.get_name(d) for d in get_contributing_devices(model, PSY.get_name(service))] add_reserves_proportional_cost!( - container, ActivePowerReserveVariable, service, T, contributing_names; + container, ActivePowerReserveVariable, service, T, + get_contributing_devices_map(model, PSY.get_name(service)); skip_devices = offered) return end @@ -391,24 +406,21 @@ function add_constraints!( container::OptimizationContainer, T::Type{RequirementConstraint}, service::SR, - contributing_devices::U, + contributing_devices::AbstractDict, ::ServiceModel{SR, StepwiseCostReserve}, -) where { - SR <: PSY.AbstractReserve, - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, -} where {D <: PSY.Component} +) where {SR <: PSY.AbstractReserve} time_steps = get_time_steps(container) service_name = PSY.get_name(service) # Dense container keyed `[service_name, time]`, built per type; fill this service's row. constraint = get_constraint(container, T, SR) - reserve_variable = get_variable(container, ActivePowerReserveVariable, SR) requirement_variable = get_variable(container, ServiceRequirementVariable, SR) jump_model = get_jump_model(container) for t in time_steps resource_expression = _sum_service_reserves( - reserve_variable, + container, + SR, service_name, contributing_devices, t, @@ -449,82 +461,62 @@ function _get_ramp_constraint_contributing_devices( return filtered_device end +_directional_ramp_limit(ramp_limits, ::Type{<:PSY.Reserve{PSY.ReserveUp}}) = + ramp_limits.up +_directional_ramp_limit(ramp_limits, ::Type{<:PSY.Reserve{PSY.ReserveDown}}) = + ramp_limits.down + function add_constraints!( container::OptimizationContainer, T::Type{RampConstraint}, service::SR, - contributing_devices::Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, + contributing_devices::AbstractDict, ::ServiceModel{SR, V}, ) where { - SR <: PSY.Reserve{PSY.ReserveUp}, + SR <: Union{PSY.Reserve{PSY.ReserveUp}, PSY.Reserve{PSY.ReserveDown}}, V <: AbstractReservesFormulation, - D <: PSY.Component, } - ramp_devices = _get_ramp_constraint_contributing_devices(service, contributing_devices) service_name = PSY.get_name(service) - if !isempty(ramp_devices) - jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - time_frame = PSY.get_time_frame(service) - variable = get_variable(container, ActivePowerReserveVariable, SR) - device_name_set = [PSY.get_name(d) for d in ramp_devices] - con_up = lazy_container_addition!(container, T, - SR, - [service_name], - device_name_set, - time_steps; - sparse = true, - ) - for d in ramp_devices, t in time_steps - name = PSY.get_name(d) - ramp_limits = PSY.get_ramp_limits(d, PSY.SU / u"minute") - con_up[(service_name, name, t)] = JuMP.@constraint( - jump_model, - variable[(service_name, name, t)] <= ramp_limits.up * time_frame - ) - end - else + ramp_devices = [ + _get_ramp_constraint_contributing_devices(service, devices) for + devices in values(contributing_devices) + ] + if all(isempty, ramp_devices) @warn "Data doesn't contain contributing devices with ramp limits for service $service_name, consider adjusting your formulation" + return + end + time_steps = get_time_steps(container) + cons = lazy_container_addition!(container, T, + SR, + [service_name], + [PSY.get_name(d) for devices in ramp_devices for d in devices], + time_steps; + sparse = true, + ) + for devices in ramp_devices + isempty(devices) && continue + _add_reserve_ramp_constraints!(cons, container, service, devices) end return end -function add_constraints!( +function _add_reserve_ramp_constraints!( + cons::SparseAxisArray, container::OptimizationContainer, - T::Type{RampConstraint}, service::SR, - contributing_devices::Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, - ::ServiceModel{SR, V}, -) where { - SR <: PSY.Reserve{PSY.ReserveDown}, - V <: AbstractReservesFormulation, - D <: PSY.Component, -} - ramp_devices = _get_ramp_constraint_contributing_devices(service, contributing_devices) + devices::Vector{D}, +) where {SR <: PSY.Reserve, D <: PSY.Component} + variable = _reserve_variable(container, D, SR) + jump_model = get_jump_model(container) service_name = PSY.get_name(service) - if !isempty(ramp_devices) - jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - time_frame = PSY.get_time_frame(service) - variable = get_variable(container, ActivePowerReserveVariable, SR) - device_name_set = [PSY.get_name(d) for d in ramp_devices] - con_down = lazy_container_addition!(container, T, - SR, - [service_name], - device_name_set, - time_steps; - sparse = true, + time_frame = PSY.get_time_frame(service) + for d in devices, t in get_time_steps(container) + name = PSY.get_name(d) + limit = _directional_ramp_limit(PSY.get_ramp_limits(d, PSY.SU / u"minute"), SR) + cons[(service_name, name, t)] = JuMP.@constraint( + jump_model, + variable[(service_name, name, t)] <= limit * time_frame ) - for d in ramp_devices, t in time_steps - name = PSY.get_name(d) - ramp_limits = PSY.get_ramp_limits(d, PSY.SU / u"minute") - con_down[(service_name, name, t)] = JuMP.@constraint( - jump_model, - variable[(service_name, name, t)] <= ramp_limits.down * time_frame - ) - end - else - @warn "Data doesn't contain contributing devices with ramp limits for service $service_name, consider adjusting your formulation" end return end @@ -533,13 +525,9 @@ function add_constraints!( container::OptimizationContainer, T::Type{ReservePowerConstraint}, service::SR, - contributing_devices::U, + contributing_devices::AbstractDict, ::ServiceModel{SR, V}, -) where { - SR <: PSY.OfflineReserve, - V <: AbstractReservesFormulation, - U <: Union{Vector{D}, IS.FlattenIteratorWrapper{D}}, -} where {D <: PSY.Component} +) where {SR <: PSY.OfflineReserve, V <: AbstractReservesFormulation} time_steps = get_time_steps(container) resolution = get_resolution(container) if resolution > Dates.Minute(1) @@ -552,18 +540,39 @@ function add_constraints!( cons = lazy_container_addition!(container, T, SR, [service_name], - [PSY.get_name(d) for d in contributing_devices], + [PSY.get_name(d) for devices in values(contributing_devices) for d in devices], time_steps; sparse = true, ) - var_r = get_variable(container, ActivePowerReserveVariable, SR) reserve_response_time = PSY.get_time_frame(service) + for devices in values(contributing_devices) + _add_reserve_power_constraints!( + cons, + container, + SR, + devices, + service_name, + reserve_response_time, + minutes_per_period, + ) + end + return +end + +function _add_reserve_power_constraints!( + cons::SparseAxisArray, + container::OptimizationContainer, + ::Type{SR}, + devices::Vector{D}, + service_name::String, + reserve_response_time, + minutes_per_period, +) where {SR <: PSY.OfflineReserve, D <: PSY.Component} + var_r = _reserve_variable(container, D, SR) + varstatus = get_variable(container, OnVariable, D) jump_model = get_jump_model(container) - for d in contributing_devices - # Function barrier: `contributing_devices` may have an abstract element type, so the - # callee specializes on the concrete types and dispatches once per device rather than - # once per timestep. - varstatus = get_variable(container, OnVariable, typeof(d)) + time_steps = get_time_steps(container) + for d in devices _add_reserve_power_constraint_device!( cons, var_r, @@ -735,22 +744,33 @@ function add_reserves_proportional_cost!( ::Type{U}, service::T, ::Type{V}, - contributing_names::Vector{String}; - skip_devices = Set{String}(), + contributing_devices::AbstractDict; + skip_devices = Set{Tuple{DataType, String}}(), ) where { T <: PSY.AbstractReserve, U <: ActivePowerReserveVariable, V <: AbstractReservesFormulation, } - base_p = get_model_base_power(container) + for devices in values(contributing_devices) + _add_reserves_proportional_cost!(container, U, service, devices, skip_devices) + end + return +end + +# `skip_devices` are priced by their offer curve in `add_reserve_offer_costs!` instead. +function _add_reserves_proportional_cost!( + container::OptimizationContainer, + ::Type{U}, + service::T, + devices::Vector{D}, + skip_devices::Set{Tuple{DataType, String}}, +) where {T <: PSY.AbstractReserve, U <: ActivePowerReserveVariable, D <: PSY.Component} service_name = PSY.get_name(service) - reserve_variable = get_variable(container, U, T) - # Index this service's slice of the `(service, device, time)` container by its contributing - # device names, so each provision is priced once without scanning the whole container. - # `skip_devices` are priced by their offer curve in `add_reserve_offer_costs!` instead. - cost = DEFAULT_RESERVE_COST / base_p - for name in contributing_names - name in skip_devices && continue + reserve_variable = get_variable(container, U, IOM.ComponentPairKey{D, T}) + cost = DEFAULT_RESERVE_COST / get_model_base_power(container) + for d in devices + name = PSY.get_name(d) + (D, name) in skip_devices && continue for t in get_time_steps(container) add_to_objective_invariant_expression!( container, diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 903b0dcf..7efbe89b 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -3,10 +3,13 @@ const _OUTAGE_MAP = Dict{Int, _PER_TYPE} const _G1_META = "G1" +const _SECURITY_CONSTRAINED_RESERVE = + Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve} + _validate_reserve_formulation(::ServiceModel) = false _validate_reserve_formulation( ::ServiceModel{ - <:PSY.Reserve{PSY.ReserveUp}, + <:_SECURITY_CONSTRAINED_RESERVE, <:AbstractSecurityConstrainedReservesFormulation, }, ) = true @@ -14,7 +17,7 @@ _validate_reserve_formulation( ::ServiceModel{<:PSY.AbstractReserve, <:AbstractSecurityConstrainedReservesFormulation}, ) = throw( IS.ConflictingInputsError( - "Security-constrained formulations currently only support Reserve{ReserveUp}.", + "Security-constrained formulations currently only support reserve-up services.", ), ) @@ -23,96 +26,189 @@ _valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetwork _valid_component_type(::PSY.AreaInterchange, ::NetworkModel{AreaBalanceNetworkModel}) = true _valid_component_type(::PSY.Component, ::NetworkModel) = false +function _outaged_generators(sys::PSY.System, outage::PSY.Outage) + outaged = _PER_TYPE() + for generator in + PSY.get_associated_components(sys, outage; component_type = PSY.Generator) + push!(get!(Set{String}, outaged, typeof(generator)), PSY.get_name(generator)) + end + return outaged +end + +function _monitored_components( + sys::PSY.System, + outage::PSY.Outage, + network_model::NetworkModel, +) + monitored = _PER_TYPE() + for component_uuid in PSY.get_monitored_components(outage) + component = IS.get_component(sys, component_uuid) + _valid_component_type(component, network_model) || continue + PSY.get_available(component) || continue + typeof(component) in network_model.modeled_branch_types || continue + push!(get!(Set{String}, monitored, typeof(component)), PSY.get_name(component)) + end + return monitored +end + """ -Per outage attached to a security-constrained reserve: the devices contributing to any reserve -responding to it, the outaged generators, and the available, modeled monitored components. +Per outage attached to a security-constrained reserve: the outaged generators, the available, +modeled monitored components, and whether its post-contingency flow limits are relaxed. -Contributing devices are aggregated across services because one outage may be tied to reserves -with different contributing devices and different service models. +Flow constraints are shared by every reserve responding to an outage, so the service models +carrying that outage must agree on `use_slacks`. """ function _security_constrained_outages( sys::PSY.System, services_template::ServicesModelContainer, network_model::NetworkModel, ) - contributing_devices, outaged_generators, monitored_components = - _OUTAGE_MAP(), _OUTAGE_MAP(), _OUTAGE_MAP() + outaged_generators, monitored_components = _OUTAGE_MAP(), _OUTAGE_MAP() + use_slacks = Dict{Int, Bool}() for model in values(services_template) _validate_reserve_formulation(model) || continue - service_type = get_component_type(model) - for (service_name, per_type) in get_contributing_devices_map(model) - service = PSY.get_component(service_type, sys, service_name) + model_slacks = get_use_slacks(model) + for service in _services_with_contributors(model, sys) for outage in PSY.get_supplemental_attributes(PSY.Outage, service) uuid = IS.get_id(outage) - devices = get!(_PER_TYPE, contributing_devices, uuid) - for (device_type, ds) in per_type - union!(get!(Set{String}, devices, device_type), PSY.get_name.(ds)) - end - - haskey(outaged_generators, uuid) && continue - outaged = outaged_generators[uuid] = _PER_TYPE() - for generator in PSY.get_associated_components( - sys, - outage; - component_type = PSY.Generator, - ) - push!( - get!(Set{String}, outaged, typeof(generator)), - PSY.get_name(generator), - ) - end - - monitored = monitored_components[uuid] = _PER_TYPE() - for component_uuid in PSY.get_monitored_components(outage) - component = IS.get_component(sys, component_uuid) - _valid_component_type(component, network_model) || continue - PSY.get_available(component) || continue - typeof(component) in network_model.modeled_branch_types || continue - push!( - get!(Set{String}, monitored, typeof(component)), - PSY.get_name(component), + if get!(use_slacks, uuid, model_slacks) != model_slacks + throw( + IS.ConflictingInputsError( + "Outage $uuid is attached to security-constrained reserves \ + with different `use_slacks` settings; set `use_slacks` \ + consistently across their service models.", + ), ) end + haskey(outaged_generators, uuid) && continue + outaged_generators[uuid] = _outaged_generators(sys, outage) + monitored_components[uuid] = + _monitored_components(sys, outage, network_model) end end end - return contributing_devices, outaged_generators, monitored_components + return outaged_generators, monitored_components, use_slacks end -function _create_post_contingency_reserve_variables!( +# Each service deploys its own contributors under every outage it responds to; the per-device +# sum across services is what the outage-level constraints see. +function _add_post_contingency_deployment!( container::OptimizationContainer, - contributing_devices::_OUTAGE_MAP, - outaged_generators::_OUTAGE_MAP, -) - jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - for (uuid, per_type) in contributing_devices - for (device_type, names) in per_type - var = lazy_container_addition!( + sys::PSY.System, + service::R, + model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, +) where {R <: _SECURITY_CONSTRAINED_RESERVE} + per_type = get_contributing_devices_map(model, PSY.get_name(service)) + for outage in PSY.get_supplemental_attributes(PSY.Outage, service) + uuid = IS.get_id(outage) + outaged = _outaged_generators(sys, outage) + for (device_type, devices) in per_type + _add_post_contingency_deployment!( container, - PostContingencyActivePowerReserveDeploymentVariable, - device_type, - String[], - Int[], - Int[]; - sparse = true, + service, + devices, + uuid, + get(Set{String}, outaged, device_type), ) - outaged = get(Set{String}, outaged_generators[uuid], device_type) - for name in names - name in outaged && continue - for t in time_steps - var[name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(device_type)_{$(name), $(uuid), $(t)}", - lower_bound = 0.0, - ) - end - end end end return end +function _add_post_contingency_deployment!( + container::OptimizationContainer, + service::R, + devices::Vector{D}, + uuid::Int, + outaged::Set{String}, +) where {R <: PSY.Service, D <: PSY.Component} + jump_model = get_jump_model(container) + service_name = PSY.get_name(service) + deployment = lazy_container_addition!( + container, + PostContingencyActivePowerReserveDeploymentVariable, + IOM.ComponentPairKey{D, R}, + String[], + String[], + Int[], + Int[]; + sparse = true, + ) + total = lazy_container_addition!( + container, + PostContingencyTotalReserveDeployment, + D, + String[], + Int[], + Int[]; + sparse = true, + ) + for d in devices + name = PSY.get_name(d) + name in outaged && continue + for t in get_time_steps(container) + var = + deployment[service_name, name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(D)_$(R)_{$(service_name), $(name), $(uuid), $(t)}", + lower_bound = 0.0, + ) + JuMP.add_to_expression!(get!(JuMP.AffExpr, total.data, (name, uuid, t)), var) + end + end + return +end + +_total_deployments(container::OptimizationContainer) = [ + (get_component_type(key), expr) for (key, expr) in IOM.get_expressions(container) if + IOM.get_entry_type(key) === PostContingencyTotalReserveDeployment +] + +# Deployment can only draw on procured reserve. Reserves without a requirement series have no +# meaningful award, so their deployment is limited by generation headroom alone. +function _constrain_post_contingency_reserve!( + container::OptimizationContainer, + service::R, + model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, +) where {R <: _SECURITY_CONSTRAINED_RESERVE} + _has_ts_requirement(model, service) || return + for device_type in keys(get_contributing_devices_map(model, PSY.get_name(service))) + _constrain_post_contingency_reserve!(container, service, device_type) + end + return +end + +function _constrain_post_contingency_reserve!( + container::OptimizationContainer, + service::R, + ::Type{D}, +) where {R <: PSY.Service, D <: PSY.Component} + key = IOM.ComponentPairKey{D, R} + has_container_key(container, PostContingencyActivePowerReserveDeploymentVariable, key) || + return + jump_model = get_jump_model(container) + service_name = PSY.get_name(service) + deployment = + get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, key) + award = get_variable(container, ActivePowerReserveVariable, key) + cons = lazy_container_addition!( + container, + PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + key, + String[], + String[], + Int[], + Int[]; + sparse = true, + ) + for ((s, name, uuid, t), r) in deployment.data + s == service_name || continue + cons[s, name, uuid, t] = + JuMP.@constraint(jump_model, r <= award[service_name, name, t]) + end + return +end + function _create_post_contingency_interchange_variables!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, @@ -147,37 +243,12 @@ _create_post_contingency_interchange_variables!( ::NetworkModel, ) = nothing -# Flow constraints are shared by every reserve responding to an outage, so the service -# models carrying that outage must agree on relaxing them. function _create_post_contingency_flow_slacks!( container::OptimizationContainer, - sys::PSY.System, - services_template::ServicesModelContainer, monitored_components::_OUTAGE_MAP, + use_slacks::Dict{Int, Bool}, network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) - use_slacks = Dict{Int, Bool}() - for model in values(services_template) - _validate_reserve_formulation(model) || continue - service_type = get_component_type(model) - model_slacks = get_use_slacks(model) - for service_name in keys(get_contributing_devices_map(model)) - service = PSY.get_component(service_type, sys, service_name) - for outage in PSY.get_supplemental_attributes(PSY.Outage, service) - uuid = IS.get_id(outage) - if get!(use_slacks, uuid, model_slacks) != model_slacks - throw( - IS.ConflictingInputsError( - "Outage $uuid is attached to security-constrained reserves \ - with different `use_slacks` settings; set `use_slacks` \ - consistently across their service models.", - ), - ) - end - end - end - end - jump_model = get_jump_model(container) time_steps = get_time_steps(container) for (uuid, per_type) in monitored_components @@ -204,9 +275,7 @@ function _create_post_contingency_flow_slacks!( sparse = true, meta = _G1_META, ) - for entry_name in - keys(_post_contingency_flow_entries(network_model, component_type, names)), - t in time_steps + for entry_name in _flow_entries(network_model, component_type, names), t in time_steps ub = slack_ub[entry_name, uuid, t] = JuMP.@variable( jump_model, @@ -230,9 +299,8 @@ end _create_post_contingency_flow_slacks!( ::OptimizationContainer, - ::PSY.System, - ::ServicesModelContainer, ::_OUTAGE_MAP, + ::Dict{Int, Bool}, ::NetworkModel, ) = nothing @@ -254,10 +322,10 @@ _location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = function _build_post_contingency_locational_power!( container::OptimizationContainer, sys::PSY.System, - contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, network_model::NetworkModel, ) + totals = _total_deployments(container) expr = lazy_container_addition!( container, _deployment_expression_type(network_model), @@ -267,36 +335,31 @@ function _build_post_contingency_locational_power!( Int[]; sparse = true, ) - jump_model = get_jump_model(container) - uuids = collect(keys(contributing_devices)) - time_steps = get_time_steps(container) - for uuid in uuids - for (device_type, names) in contributing_devices[uuid] - reserve = get_variable( - container, - PostContingencyActivePowerReserveDeploymentVariable, - device_type, - ) - outaged = get(Set{String}, outaged_generators[uuid], device_type) - for name in names - name in outaged && continue - key = - _location_key(PSY.get_component(device_type, sys, name), network_model) - for t in time_steps - ex = get!(expr.data, (key, uuid, t), JuMP.AffExpr(0.0)) - JuMP.add_to_expression!(ex, reserve[name, uuid, t]) - end + + for (device_type, total) in totals + locations = Dict{String, String}() + for ((name, uuid, t), deployed) in total.data + key = get!(locations, name) do + _location_key(PSY.get_component(D, sys, name), network_model) end + JuMP.add_to_expression!(get!(JuMP.AffExpr, expr.data, (key, uuid, t)), deployed) end - for (generator_type, names) in outaged_generators[uuid] + end + + for (uuid, per_type) in outaged_generators + for (generator_type, names) in per_type power = get_variable(container, ActivePowerVariable, generator_type) for name in names key = _location_key( PSY.get_component(generator_type, sys, name), network_model, ) - for t in time_steps - JuMP.add_to_expression!(expr[key, uuid, t], -1.0, power[name, t]) + for t in get_time_steps(container) + JuMP.add_to_expression!( + get!(JuMP.AffExpr, expr.data, (key, uuid, t)), + -1.0, + power[name, t], + ) end end end @@ -317,9 +380,9 @@ function _build_post_contingency_flow!( bus_axis = PNM.get_bus_axis(ptdf) nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus) - buses = Dict{Int, Vector{String}}() + buses = Dict{Int, Set{String}}() for (bus, uuid, t) in keys(nodal.data) - t == first(time_steps) && push!(get!(Vector{String}, buses, uuid), bus) + push!(get!(Set{String}, buses, uuid), bus) end # Arc distribution factors per reduced entry, keyed by the nodal bus key. @@ -338,8 +401,7 @@ function _build_post_contingency_flow!( ) pre_flow = get_expression(container, PTDFBranchFlow, line_type) arc_map = PNM.get_name_to_arc_map(catalog, line_type) - for entry_name in - keys(_post_contingency_flow_entries(network_model, line_type, names)) + for entry_name in _flow_entries(network_model, line_type, names) df = get!(dfs, (line_type, entry_name)) do ptdf_col = ptdf[arc_map[entry_name], :] Dict{String, Float64}( @@ -371,6 +433,7 @@ function _build_post_contingency_flow!( monitored_components::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) + time_steps = get_time_steps(container) expr = add_expression_container!( container, PostContingencyAreaInterchangeFlow, @@ -411,11 +474,10 @@ _build_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkM function _constrain_post_contingency_balance!( container::OptimizationContainer, ::PSY.System, - contributing_devices::_OUTAGE_MAP, outaged_generators::_OUTAGE_MAP, ::NetworkModel, ) - uuids = collect(keys(contributing_devices)) + uuids = collect(keys(outaged_generators)) time_steps = get_time_steps(container) jump_model = get_jump_model(container) cons = add_constraints_container!( @@ -426,28 +488,22 @@ function _constrain_post_contingency_balance!( time_steps, ) - for uuid in uuids, t in time_steps - balance = JuMP.AffExpr(0.0) - for (generator_type, names) in outaged_generators[uuid] + balance = Dict((uuid, t) => JuMP.AffExpr(0.0) for uuid in uuids, t in time_steps) + for (uuid, per_type) in outaged_generators + for (generator_type, names) in per_type power = get_variable(container, ActivePowerVariable, generator_type) - for name in names - JuMP.add_to_expression!(balance, -1.0, power[name, t]) + for name in names, t in time_steps + JuMP.add_to_expression!(balance[uuid, t], -1.0, power[name, t]) end end - for (device_type, names) in contributing_devices[uuid] - reserve = get_variable( - container, - PostContingencyActivePowerReserveDeploymentVariable, - device_type, - ) - outaged = get(outaged_generators[uuid], device_type, Set{String}()) - for name in names - name in outaged && continue - JuMP.add_to_expression!(balance, reserve[name, uuid, t]) - end + end + for (_, total) in _total_deployments(container) + for ((_, uuid, t), deployed) in total.data + JuMP.add_to_expression!(balance[uuid, t], deployed) end - - cons[uuid, t] = JuMP.@constraint(jump_model, balance == 0.0) + end + for ((uuid, t), ex) in balance + cons[uuid, t] = JuMP.@constraint(jump_model, ex == 0.0) end return end @@ -455,11 +511,10 @@ end function _constrain_post_contingency_balance!( container::OptimizationContainer, sys::PSY.System, - contributing_devices::_OUTAGE_MAP, - ::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) - uuids = sort!(collect(keys(contributing_devices))) + uuids = sort!(collect(keys(outaged_generators))) time_steps = get_time_steps(container) jump_model = get_jump_model(container) @@ -516,99 +571,27 @@ end function _constrain_post_contingency_generation!( container::OptimizationContainer, sys::PSY.System, - outaged_generators::_OUTAGE_MAP, - contributing_devices::_OUTAGE_MAP, -) - jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - limits = Dict{Tuple{DataType, String}, Float64}() - for (uuid, per_type) in contributing_devices - for (device_type, names) in per_type - cons = lazy_container_addition!( - container, - PostContingencyActivePowerGenerationLimitsConstraint, - device_type, - String[], - Int[], - Int[]; - sparse = true, - ) - power = get_variable(container, ActivePowerVariable, device_type) - reserve = get_variable( - container, - PostContingencyActivePowerReserveDeploymentVariable, - device_type, - ) - outaged = get(outaged_generators[uuid], device_type, Set{String}()) - for name in names - name in outaged && continue - limit = get!(limits, (device_type, name)) do - PSY.get_max_active_power(PSY.get_component(device_type, sys, name), PSY.SU) - end - for t in time_steps - cons[name, uuid, t] = JuMP.@constraint( - jump_model, - power[name, t] + reserve[name, uuid, t] <= limit - ) - end - end - end - end - return -end - -# Deployment can only draw on procured reserve: bounded by the sum of the device's awards in -# every reserve with a requirement series that responds to the outage. Reserves without one have -# no meaningful award, so their deployment is limited by generation headroom alone. -function _constrain_post_contingency_reserve!( - container::OptimizationContainer, - sys::PSY.System, - services_template::ServicesModelContainer, - outaged_generators::_OUTAGE_MAP, ) jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - awards = Dict{Tuple{DataType, String, Int, Int}, JuMP.AffExpr}() - for model in values(services_template) - _validate_reserve_formulation(model) || continue - service_type = get_component_type(model) - for (service_name, per_type) in get_contributing_devices_map(model) - service = PSY.get_component(service_type, sys, service_name) - _has_ts_requirement(model, service) || continue - reserve = get_variable(container, ActivePowerReserveVariable, service_type) - for outage in PSY.get_supplemental_attributes(PSY.Outage, service) - uuid = IS.get_id(outage) - for (device_type, devices) in per_type - outaged = get(Set{String}, outaged_generators[uuid], device_type) - for device in devices - name = PSY.get_name(device) - name in outaged && continue - for t in time_steps - ex = get!(JuMP.AffExpr, awards, (device_type, name, uuid, t)) - JuMP.add_to_expression!(ex, reserve[(service_name, name, t)]) - end - end - end - end - end - end - for ((device_type, name, uuid, t), award) in awards + for (device_type, total) in _total_deployments(container) cons = lazy_container_addition!( container, - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PostContingencyActivePowerGenerationLimitsConstraint, device_type, String[], Int[], Int[]; sparse = true, ) - deployment = get_variable( - container, - PostContingencyActivePowerReserveDeploymentVariable, - device_type, - ) - cons[name, uuid, t] = - JuMP.@constraint(jump_model, deployment[name, uuid, t] <= award) + power = get_variable(container, ActivePowerVariable, device_type) + limits = Dict{String, Float64}() + for ((name, uuid, t), deployed) in total.data + limit = get!(limits, name) do + PSY.get_max_active_power(PSY.get_component(device_type, sys, name), PSY.SU) + end + cons[name, uuid, t] = + JuMP.@constraint(jump_model, power[name, t] + deployed <= limit) + end end return end @@ -620,7 +603,7 @@ _post_contingency_flow_expression(::NetworkModel{AreaBalanceNetworkModel}) = # Post-contingency flow key => a representative monitored component name. Parallel # circuits share one reduced entry, so each entry is constrained once. -function _post_contingency_flow_entries( +function _flow_entries( network_model::NetworkModel{<:AbstractPTDFNetworkModel}, ::Type{T}, names::Set{String}, @@ -634,7 +617,7 @@ function _post_contingency_flow_entries( return entries end -_post_contingency_flow_entries( +_flow_entries( ::NetworkModel{AreaBalanceNetworkModel}, ::Type{PSY.AreaInterchange}, names::Set{String}, @@ -644,10 +627,9 @@ function _post_contingency_flow_limits( ::PSY.System, network_model::NetworkModel{<:AbstractPTDFNetworkModel}, ::Type{T}, - name::String, + entry_name::String, ) where {T <: PSY.ACTransmission} catalog = get_branch_catalog(network_model) - entry_name = PNM.get_component_to_reduction_name_map(catalog, T)[name] arc = PNM.get_name_to_arc_map(catalog, T)[entry_name] return _emergency_flow_limits(PNM.get_reduction_entry(catalog, arc)) end @@ -718,10 +700,8 @@ function _constrain_post_contingency_flow!( _G1_META, ) end - for (entry_name, name) in - _post_contingency_flow_entries(network_model, component_type, names) - lims = - _post_contingency_flow_limits(sys, network_model, component_type, name) + for entry_name in _flow_entries(network_model, component_type, names) + lims = _post_contingency_flow_limits(sys, network_model, component_type, entry_name) for t in time_steps f = flow[entry_name, uuid, t] if has_slacks && haskey(slack_ub.data, (entry_name, uuid, t)) diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index dc73e6ac..cc8b6201 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -1,9 +1,10 @@ # One `ServiceModel` per service TYPE (like `DeviceModel`). `construct_service!` runs once # per type: it gets all services of the type via `get_available_components(model, sys)`, # reads each service's contributing devices from the nested per-service map -# (`get_contributing_devices(model, service_name)`), and builds. Reserve variable and -# constraint containers are shared per `(entry type, service type)`, with each service -# filling its own slice. Group formulations are deferred to last (their members must exist). +# (`get_contributing_devices_map(model, service_name)`), and builds. Reserve award containers +# are shared per `(device type, service type)` and constraint containers per +# `(entry type, service type)`, with each service filling its own slice. Group formulations +# are deferred to last (their members must exist). # # TODO(services stability): See issue #216. @@ -139,13 +140,8 @@ function construct_services!( ) end - contributing_devices, outaged_generators, monitored_components = + _, monitored_components, use_slacks = _security_constrained_outages(sys, services_template, network_model) - _create_post_contingency_reserve_variables!( - container, - contributing_devices, - outaged_generators, - ) _create_post_contingency_interchange_variables!( container, monitored_components, @@ -153,9 +149,8 @@ function construct_services!( ) _create_post_contingency_flow_slacks!( container, - sys, - services_template, monitored_components, + use_slacks, network_model, ) return @@ -201,12 +196,11 @@ function construct_services!( ) end - contributing_devices, outaged_generators, monitored_components = + outaged_generators, monitored_components, _ = _security_constrained_outages(sys, services_template, network_model) _build_post_contingency_locational_power!( container, sys, - contributing_devices, outaged_generators, network_model, ) @@ -214,22 +208,10 @@ function construct_services!( _constrain_post_contingency_balance!( container, sys, - contributing_devices, outaged_generators, network_model, ) - _constrain_post_contingency_generation!( - container, - sys, - outaged_generators, - contributing_devices, - ) - _constrain_post_contingency_reserve!( - container, - sys, - services_template, - outaged_generators, - ) + _constrain_post_contingency_generation!(container, sys) _constrain_post_contingency_flow!(container, sys, monitored_components, network_model) return end @@ -254,15 +236,14 @@ function construct_service!( ts_services = [s for s in demand_services if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) + add_service_variables!( + container, + ActivePowerReserveVariable, + services, + model, + RangeReserve, + ) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) - add_service_variables!( - container, - ActivePowerReserveVariable, - service, - contributing_devices, - RangeReserve, - ) add_to_expression!( container, ActivePowerReserveVariable, @@ -304,7 +285,7 @@ function construct_service!( get_use_slacks(model) && add_reserve_slacks!(container, SR, demand_names) end for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) if _has_reserve_demand(model, service) add_constraints!( container, @@ -361,15 +342,14 @@ function construct_service!( # Slope/breakpoint PWL cost params for the time-series-backed ORDCs (no-op otherwise). process_stepwise_cost_reserve_parameters!(container, model, demand_services) end + add_service_variables!( + container, + ActivePowerReserveVariable, + services, + model, + StepwiseCostReserve, + ) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) - add_service_variables!( - container, - ActivePowerReserveVariable, - service, - contributing_devices, - StepwiseCostReserve, - ) add_to_expression!( container, ActivePowerReserveVariable, @@ -404,7 +384,7 @@ function construct_service!( ) end for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) if _has_reserve_demand(model, service) add_constraints!( container, @@ -644,7 +624,7 @@ function construct_service!( devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {SR <: PSY.Reserve, F <: Union{RampReserve, SecurityConstrainedRampReserve}} +) where {SR <: PSY.Reserve, F <: RampReserve} services = _services_with_contributors(model, sys) isempty(services) && return # Only services carrying a requirement series get the parameter (a curve-only ORDC of the @@ -652,15 +632,14 @@ function construct_service!( ts_services = [s for s in services if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) + add_service_variables!( + container, + ActivePowerReserveVariable, + services, + model, + RampReserve, + ) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) - add_service_variables!( - container, - ActivePowerReserveVariable, - service, - contributing_devices, - RampReserve, - ) add_to_expression!( container, ActivePowerReserveVariable, @@ -681,7 +660,7 @@ function construct_service!( devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {SR <: PSY.Reserve, F <: Union{RampReserve, SecurityConstrainedRampReserve}} +) where {SR <: PSY.Reserve, F <: RampReserve} services = _services_with_contributors(model, sys) isempty(services) && return service_names = PSY.get_name.(services) @@ -695,7 +674,7 @@ function construct_service!( ) get_use_slacks(model) && add_reserve_slacks!(container, SR, service_names) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) add_constraints!( container, RequirementConstraint, @@ -734,15 +713,14 @@ function construct_service!( ts_services = [s for s in services if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) + add_service_variables!( + container, + ActivePowerReserveVariable, + services, + model, + NonSpinningReserve, + ) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) - add_service_variables!( - container, - ActivePowerReserveVariable, - service, - contributing_devices, - NonSpinningReserve, - ) add_feedforward_arguments!(container, model, service) end return @@ -770,7 +748,7 @@ function construct_service!( ) get_use_slacks(model) && add_reserve_slacks!(container, SR, service_names) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) add_constraints!( container, RequirementConstraint, @@ -1148,25 +1126,25 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ArgumentConstructStage, - model::ServiceModel{R, SecurityConstrainedContingencyReserve}, + model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, devices_template::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {R <: PSY.AbstractReserve} +) where {R <: _SECURITY_CONSTRAINED_RESERVE} services = _services_with_contributors(model, sys) isempty(services) && return ts_services = [s for s in services if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) + add_service_variables!( + container, + ActivePowerReserveVariable, + services, + model, + RampReserve, + ) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) - add_service_variables!( - container, - ActivePowerReserveVariable, - service, - contributing_devices, - RampReserve, - ) + _add_post_contingency_deployment!(container, sys, service, model) add_to_expression!( container, ActivePowerReserveVariable, @@ -1183,11 +1161,11 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ModelConstructStage, - model::ServiceModel{R, SecurityConstrainedContingencyReserve}, + model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, ::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {R <: PSY.AbstractReserve} +) where {R <: _SECURITY_CONSTRAINED_RESERVE} services = _services_with_contributors(model, sys) isempty(services) && return service_names = PSY.get_name.(services) @@ -1200,7 +1178,7 @@ function construct_service!( ) get_use_slacks(model) && add_reserve_slacks!(container, R, service_names) for service in services - contributing_devices = get_contributing_devices(model, PSY.get_name(service)) + contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) add_constraints!( container, RequirementConstraint, @@ -1208,6 +1186,12 @@ function construct_service!( contributing_devices, model, ) + _add_security_constrained_ramp_constraints!( + container, + service, + contributing_devices, + model, + ) add_constraints!( container, ParticipationFractionConstraint, @@ -1215,9 +1199,38 @@ function construct_service!( contributing_devices, model, ) + _constrain_post_contingency_reserve!(container, service, model) add_to_objective_function!(container, service, model) add_feedforward_constraints!(container, model, service) end add_constraint_dual!(container, sys, model) return end + +_add_security_constrained_ramp_constraints!( + container::OptimizationContainer, + service::PSY.Reserve, + contributing_devices::AbstractDict, + model::ServiceModel{<:PSY.Reserve, SecurityConstrainedRampReserve}, +) = add_constraints!(container, RampConstraint, service, contributing_devices, model) + +_add_security_constrained_ramp_constraints!( + ::OptimizationContainer, + ::PSY.AbstractReserve, + ::AbstractDict, + ::ServiceModel, +) = nothing + +construct_service!( + ::OptimizationContainer, + ::PSY.System, + ::Union{ArgumentConstructStage, ModelConstructStage}, + ::ServiceModel{<:PSY.AbstractReserve, <:AbstractSecurityConstrainedReservesFormulation}, + ::Dict{Symbol, DeviceModel}, + ::Set{<:DataType}, + ::NetworkModel{<:AbstractNetworkModel}, +) = throw( + IS.ConflictingInputsError( + "Security-constrained formulations currently only support reserve-up services.", + ), +) diff --git a/src/static_injector_models/hydro_generation.jl b/src/static_injector_models/hydro_generation.jl index f785bd1c..034b1c18 100644 --- a/src/static_injector_models/hydro_generation.jl +++ b/src/static_injector_models/hydro_generation.jl @@ -2723,7 +2723,7 @@ function add_to_expression!( isa(service, PSY.Reserve{PSY.ReserveUp}) || continue service_name = PSY.get_name(service) deployed_fraction = PSY.get_deployed_fraction(service) - variable = get_variable(container, U, typeof(service)) + variable = get_variable(container, U, IOM.ComponentPairKey{V, typeof(service)}) for t in get_time_steps(container) add_proportional_to_jump_expression!( expression[name, t], @@ -2762,7 +2762,7 @@ function add_to_expression!( isa(service, PSY.Reserve{PSY.ReserveDown}) || continue service_name = PSY.get_name(service) deployed_fraction = PSY.get_deployed_fraction(service) - variable = get_variable(container, U, typeof(service)) + variable = get_variable(container, U, IOM.ComponentPairKey{V, typeof(service)}) for t in get_time_steps(container) add_proportional_to_jump_expression!( expression[name, t], @@ -2831,7 +2831,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) - variable = get_variable(container, U, X) + variable = get_variable(container, U, IOM.ComponentPairKey{V, X}) if !has_container_key(container, T, V) add_expressions!(container, T, devices, model) end @@ -2862,7 +2862,7 @@ function add_to_expression!( W <: AbstractReservesFormulation, } service_name = PSY.get_name(service) - variable = get_variable(container, U, X) + variable = get_variable(container, U, IOM.ComponentPairKey{V, X}) if !has_container_key(container, T, V) add_expressions!(container, T, devices, model) end diff --git a/src/static_injector_models/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index 0406951a..49ea249a 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -1783,12 +1783,12 @@ function add_constraints!( # to the device model; the ServiceModel's contributing map carries both. offline = Tuple{String, IOM.JuMPArray, Set{String}}[] for sm in get_services(model) - _is_offline_reserve(get_component_type(sm)) || continue - variable = - get_variable(container, ActivePowerReserveVariable, get_component_type(sm)) + S = get_component_type(sm) + _is_offline_reserve(S) || continue for (service_name, dev_map) in get_contributing_devices_map(sm) members = get(dev_map, V, nothing) isnothing(members) && continue + variable = _reserve_variable(container, V, S) push!(offline, (service_name, variable, Set(PSY.get_name.(members)))) end end diff --git a/test/Project.toml b/test/Project.toml index 8fa666a7..15656bb9 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -7,8 +7,10 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" InfraStore = "6aee0376-896a-4a59-96ff-12f221c99746" +InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" InfrastructureOptimizationModels = "bed98974-b02a-5e2f-9ee0-a103f5c45069" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" +InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" @@ -19,11 +21,9 @@ MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" -InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" -InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" PowerDynamicsOpenAPIModels = "044a0b22-31f8-4ef6-8282-c9a61b3013f6" PowerFlowFileParser = "bed98974-b02e-5e2f-9ee0-a103f5c450dd" -PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" +#PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" PowerInvestmentsOpenAPIModels = "33cb4396-f4d9-4f59-8585-787ebb56cb1b" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" @@ -42,30 +42,23 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" -# InfraStore is not registered yet, so it cannot resolve from a registry. Pkg -# only honors the [sources] section of the root project, so this environment -# needs its own entry (and InfraStore must be a direct dependency for the source -# to apply). Drop the dep + entry once InfraStore is registered. [sources] -PowerOperationsModels = {path = ".."} -InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git", rev = "IS4"} -InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} -PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} -PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} -# Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} -PowerFlows = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} -PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} -# TEMPORARY: pinned to the arc-keyed-catalog branch (PNM #356 + #357) until they merge to -# psy6. Revert to `rev = "psy6"` once they land. +InfrastructureCoreOpenAPIModels = {rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +InfrastructureOptimizationModels = {path = "/Users/acostare/Sienna/InfrastructureOptimizationModels.jl/service-containers/"} +InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} +InfrastructureTimeSeriesOpenAPIModels = {rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerCoreOpenAPIModels = {rev = "main", subdir = "PowerCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerDynamicsOpenAPIModels = {rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerFlowFileParser = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git"} +#PowerFlows = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} +PowerInvestmentsOpenAPIModels = {rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} -PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} -PowerTableDataParser = {url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git", rev = "psy6"} +PowerOpenAPIModels = {rev = "main", subdir = "PowerOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerOperationsModels = {path = ".."} +PowerOperationsOpenAPIModels = {rev = "main", subdir = "PowerOperationsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +PowerSystemCaseBuilder = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git"} +PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl.git"} +PowerTableDataParser = {rev = "psy6", url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git"} [compat] HiGHS = "1" diff --git a/test/test_device_reserve_offers.jl b/test/test_device_reserve_offers.jl index 59d53f8f..d3e34650 100644 --- a/test/test_device_reserve_offers.jl +++ b/test/test_device_reserve_offers.jl @@ -11,6 +11,18 @@ # (instead of the flat `DEFAULT_RESERVE_COST`). These tests pin the DATA MODEL and assert the # consumer builds the 4D block variable, the award-linking constraint, and the offer-slope cost. +# Awards are stored per (device type, service type): join every device type's WIDE frame for +# `service_type` (the encoded service type, e.g. "OnlineReserve__ReserveUp"). +function _read_awards(res, service_type::String) + frames = [ + read_variable(res, key; table_format = TableFormat.WIDE) for + key in list_variable_names(res) if + startswith(key, "ActivePowerReserveVariable__") && + endswith(key, "__" * service_type) + ] + return reduce((a, b) -> innerjoin(a, b; on = :DateTime), frames) +end + # Give every contributing thermal device of `reserve` a MarketBidCost with an energy offer and a # per-device reserve OFFER curve (PiecewiseStepData, NaturalUnit) named after the service. function add_device_reserve_offers!( @@ -86,9 +98,12 @@ end @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED container = get_optimization_container(model) - # The reserve award exists, keyed by service type. + # The reserve award exists, keyed by (device type, service type). @test IOM.has_container_key( - container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) # The per-device reserve OFFER is now consumed: a 4D block variable keyed # (service, device, segment, time) exists for the contributing device type, plus the @@ -99,8 +114,11 @@ end container, POM.ReserveOfferLinkingConstraint, ThermalStandard) blk = IOM.get_variable(container, POM.PiecewiseLinearBlockReserveOffer, ThermalStandard) cons = IOM.get_constraint(container, POM.ReserveOfferLinkingConstraint, ThermalStandard) - award = - IOM.get_variable(container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + award = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) @test !isempty(blk) sname = PSY.get_name(reserve) @@ -221,9 +239,7 @@ end @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE) + awards = _read_awards(res, "OnlineReserve__ReserveUp") # WIDE columns are "__"; values are per-hour reserve awards in MW. col = "$(PSY.get_name(ordc))__$(PSY.get_name(g1))" # The linking constraint caps g1's award at the offered MW each hour; in a dummy hour that cap @@ -277,9 +293,7 @@ end @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE) + awards = _read_awards(res, "OnlineReserve__ReserveUp") sname = PSY.get_name(ordc) order = sort(collect(keys(base_slope)); by = n -> base_slope[n]) cheapest, priciest = first(order), last(order) @@ -466,10 +480,7 @@ end res, "ServiceRequirementVariable__GroupReserve__ReserveUp"; table_format = TableFormat.WIDE, ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OnlineReserve__ReserveUp") sub_cols = [c for c in names(awards) if startswith(c, "GROUP_SUB_")] load_col = "GROUP_SUB_A__$(_MKT_LOAD)" @test load_col in names(awards) @@ -526,10 +537,7 @@ end res, "ServiceRequirementVariable__OfflineReserve"; table_format = TableFormat.WIDE, ) - awards = read_variable( - res, "ActivePowerReserveVariable__OfflineReserve"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OfflineReserve") load_col = "NSPIN__$(_MKT_LOAD)" @test load_col in names(awards) for t in 1:24 @@ -626,7 +634,11 @@ function _check_offline_band( ) con = IOM.get_constraint(container, POM.OfflineReserveBandConstraint, device_type) varbin = IOM.get_variable(container, POM.OnVariable, device_type) - awards = IOM.get_variable(container, POM.ActivePowerReserveVariable, OfflineReserve) + awards = IOM.get_variable( + container, + POM.ActivePowerReserveVariable, + IOM.ComponentPairKey{device_type, OfflineReserve}, + ) checked = 0 for (idx, c) in con.data name, t = idx @@ -671,14 +683,8 @@ end res = IOM.OptimizationProblemOutputs(model) on = read_variable(res, OnVariable, ThermalStandard; table_format = TableFormat.WIDE) - nspin_awards = read_variable( - res, "ActivePowerReserveVariable__OfflineReserve"; - table_format = TableFormat.WIDE, - ) - spin_awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) + nspin_awards = _read_awards(res, "OfflineReserve") + spin_awards = _read_awards(res, "OnlineReserve__ReserveUp") # Standard UC: the band row is `p + online + offline <= pmax` for every t, so the # coefficient on `u` is exactly zero. This gates the row's existence and its RHS - # the surviving solve assertions below do not, since each award is separately capped @@ -769,14 +775,8 @@ end res, PowerAboveMinimumVariable, ThermalStandard; table_format = TableFormat.WIDE, ) - nspin_awards = read_variable( - res, "ActivePowerReserveVariable__OfflineReserve"; - table_format = TableFormat.WIDE, - ) - spin_awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) + nspin_awards = _read_awards(res, "OfflineReserve") + spin_awards = _read_awards(res, "OnlineReserve__ReserveUp") off_name = PSY.get_name(offunit) total_off_award = 0.0 @@ -884,10 +884,7 @@ end res, PowerAboveMinimumVariable, PSY.ThermalMultiStart; table_format = TableFormat.WIDE, ) - awards = read_variable( - res, "ActivePowerReserveVariable__OfflineReserve"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OfflineReserve") # Committed multistart units honour the compact band in the solved solution. committed = 0 for d in multistarts @@ -965,10 +962,7 @@ end res, "ActivePowerVariable__InterruptiblePowerLoad"; table_format = TableFormat.WIDE, ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OnlineReserve__ReserveUp") total_award = 0.0 for t in 1:24 awarded = sum(awards[t, c] for c in _il_cols(awards)) @@ -989,10 +983,7 @@ end res, "ActivePowerVariable__InterruptiblePowerLoad"; table_format = TableFormat.WIDE, ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveDown"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OnlineReserve__ReserveDown") hsl = read_parameter( res, "ActivePowerTimeSeriesParameter__InterruptiblePowerLoad"; table_format = TableFormat.WIDE, @@ -1026,14 +1017,8 @@ end res, "ActivePowerVariable__InterruptiblePowerLoad"; table_format = TableFormat.WIDE, ) - up = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) - dn = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveDown"; - table_format = TableFormat.WIDE, - ) + up = _read_awards(res, "OnlineReserve__ReserveUp") + dn = _read_awards(res, "OnlineReserve__ReserveDown") hsl = read_parameter( res, "ActivePowerTimeSeriesParameter__InterruptiblePowerLoad"; table_format = TableFormat.WIDE, @@ -1101,10 +1086,7 @@ end res, "ActivePowerVariable__InterruptiblePowerLoad"; table_format = TableFormat.WIDE, ) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OnlineReserve__ReserveUp") combined_total = 0.0 for t in 1:24 # One shared LB expression: a per-service headroom bug would allow up to 2*P. @@ -1154,10 +1136,7 @@ end container, POM.PiecewiseLinearBlockReserveOffer, PSY.InterruptiblePowerLoad, ) res = IOM.OptimizationProblemOutputs(model) - awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; - table_format = TableFormat.WIDE, - ) + awards = _read_awards(res, "OnlineReserve__ReserveUp") col = "$(PSY.get_name(ordc))__$(_IL_NAME)" total = 0.0 for t in 1:24 diff --git a/test/test_model_decision.jl b/test/test_model_decision.jl index 335ae2a0..d92f106e 100644 --- a/test/test_model_decision.jl +++ b/test/test_model_decision.jl @@ -412,21 +412,27 @@ end # This test needs to be reviewed # @test isapprox(get_objective_value(res), 256937.0; atol = 10000.0) vars = res.variable_values - # Reserve variables of a type share one container keyed + # Reserve variables share one container per (device type, service type), keyed # `(service_name, device_name, time)`. - service_key = IOM.VariableKey( - ActivePowerReserveVariable, - PSY.OfflineReserve, - ) - @test service_key in keys(vars) - # That container flattens to `"service_name__device_name"` result columns + S = IOM.get_component_type(IOM.VariableKey(ActivePowerReserveVariable, PSY.OfflineReserve)) + service_keys = [ + k for k in keys(vars) if + IOM.get_entry_type(k) === ActivePowerReserveVariable && + IOM.get_component_type(k) <: IOM.ComponentPairKey{<:PSY.Component, S} + ] + @test !isempty(service_keys) + # Each container flattens to `"service_name__device_name"` result columns # (WIDE format one column per flattened pair). - result = read_variable( - res, - "ActivePowerReserveVariable__OfflineReserve"; - table_format = TableFormat.WIDE, - ) - @test any(startswith(string(n), "NonSpinningReserve__") for n in names(result)) + result_columns = [ + n for k in service_keys for n in names( + read_variable( + res, + IOM.encode_key_as_string(k); + table_format = TableFormat.WIDE, + ), + ) + ] + @test any(startswith(string(n), "NonSpinningReserve__") for n in result_columns) end @testset "Test serialization/deserialization of DecisionModel outputs" begin diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index d40b0b4c..3c71e44b 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -112,7 +112,7 @@ end end @testset "Per-type reserve container isolates services of the same type" begin - # Two OnlineReserve{ReserveUp} services share one + # Two OnlineReserve{ReserveUp} services share one per-device-type # `(service, device, time)` ActivePowerReserveVariable container. Verify (a) each # service's requirement constraint sums only its own device variables (no # cross-service leakage) and (b) the proportional reserve cost prices each variable @@ -129,7 +129,11 @@ end IOM.ModelBuildStatus.BUILT container = get_optimization_container(model) - rv = IOM.get_variable(container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + rv = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) con = IOM.get_constraint( container, RequirementConstraint, @@ -373,7 +377,11 @@ end @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED container = get_optimization_container(model) - rv = IOM.get_variable(container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + rv = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) con = IOM.get_constraint( container, RequirementConstraint, @@ -1317,7 +1325,11 @@ end IOM.ModelBuildStatus.BUILT container = get_optimization_container(model) - rv = IOM.get_variable(container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + rv = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) con = IOM.get_constraint( container, RequirementConstraint, @@ -1368,7 +1380,11 @@ end IOM.ModelBuildStatus.BUILT container = get_optimization_container(model) - rv = IOM.get_variable(container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + rv = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) con = IOM.get_constraint( container, RequirementConstraint, @@ -1446,7 +1462,11 @@ end @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED container = get_optimization_container(model) - rv = IOM.get_variable(container, ActivePowerReserveVariable, OnlineReserve{ReserveUp}) + rv = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, + ) for t in IOM.get_time_steps(container) provided = sum( @@ -1747,7 +1767,7 @@ end table_format = TableFormat.WIDE, ) awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + res, "ActivePowerReserveVariable__ThermalStandard__OnlineReserve__ReserveUp"; table_format = TableFormat.WIDE, ) sub_cols = _sub_cols(awards, "GROUP_SUB_") @@ -1762,7 +1782,7 @@ end model = _solve_group_model(sys) res = IOM.OptimizationProblemOutputs(model) awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + res, "ActivePowerReserveVariable__ThermalStandard__OnlineReserve__ReserveUp"; table_format = TableFormat.WIDE, ) sub_a_total = sum(awards[1, c] for c in _sub_cols(awards, "GROUP_SUB_A")) @@ -1808,7 +1828,7 @@ end model = _solve_group_model(sys; include_group = false) res = IOM.OptimizationProblemOutputs(model) awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + res, "ActivePowerReserveVariable__ThermalStandard__OnlineReserve__ReserveUp"; table_format = TableFormat.WIDE, ) for t in 1:24, c in _sub_cols(awards, "GROUP_SUB_") @@ -1864,7 +1884,7 @@ end table_format = TableFormat.WIDE, ) awards = read_variable( - res, "ActivePowerReserveVariable__OnlineReserve__ReserveUp"; + res, "ActivePowerReserveVariable__ThermalStandard__OnlineReserve__ReserveUp"; table_format = TableFormat.WIDE, ) sub_cols = _sub_cols(awards, "GROUP_SUB_") @@ -2157,7 +2177,7 @@ end reserve = IOM.get_variable( container, ActivePowerReserveVariable, - PSY.OnlineReserve{PSY.ReserveUp}, + IOM.ComponentPairKey{PSY.InterruptiblePowerLoad, PSY.OnlineReserve{PSY.ReserveUp}}, ) for service_name in ("R1", "R2") devices = if nested @@ -2232,16 +2252,15 @@ end container = IOM.get_optimization_container(model) jump_model = IOM.get_jump_model(container) on = IOM.get_variable(container, OnVariable, PSY.InterruptiblePowerLoad) - r_up = - IOM.get_variable( - container, - ActivePowerReserveVariable, - PSY.OnlineReserve{PSY.ReserveUp}, - ) + r_up = IOM.get_variable( + container, + ActivePowerReserveVariable, + IOM.ComponentPairKey{PSY.InterruptiblePowerLoad, PSY.OnlineReserve{PSY.ReserveUp}}, + ) r_dn = IOM.get_variable( container, ActivePowerReserveVariable, - PSY.OnlineReserve{PSY.ReserveDown}, + IOM.ComponentPairKey{PSY.InterruptiblePowerLoad, PSY.OnlineReserve{PSY.ReserveDown}}, ) for t in IOM.get_time_steps(container) diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index b37db41c..ebc7304b 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -1394,23 +1394,28 @@ end built_template = IOM.get_template(ps_model) # --- Unit-level: only Reserve1_1's contributing devices respond to the outage --- - contributing_devices, _, _ = POM._security_constrained_outages( + outaged_generators, _, _ = POM._security_constrained_outages( sys, IOM.get_service_models(built_template), IOM.get_network_model(built_template), ) - @test collect(keys(contributing_devices)) == [outage_id] - reserve1_devices = Set( - PSY.get_name(d) for - d in PSY.get_contributing_devices(sys, reserve1) if d isa ThermalStandard - ) - @test contributing_devices[outage_id][ThermalStandard] == reserve1_devices + @test collect(keys(outaged_generators)) == [outage_id] # --- Build-level: one outage, one balance row per time step --- @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(ps_model) + reserve1_devices = Set( + PSY.get_name(d) for + d in PSY.get_contributing_devices(sys, reserve1) if d isa ThermalStandard + ) + total = IOM.get_expression( + container, + IOM.ExpressionKey(PostContingencyTotalReserveDeployment, ThermalStandard), + ) + @test Set(name for (name, uuid, _) in keys(total.data) if uuid == outage_id) == + setdiff(reserve1_devices, ["Alta_1"]) cons_resp = IOM.get_constraint( container, IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), @@ -1440,12 +1445,11 @@ end ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) built_template = IOM.get_template(ps_model) - contributing_devices, outaged_generators, _ = POM._security_constrained_outages( + outaged_generators, _, _ = POM._security_constrained_outages( sys, IOM.get_service_models(built_template), IOM.get_network_model(built_template), ) - @test haskey(contributing_devices, outage_id) @test outaged_generators[outage_id] == Dict{DataType, Set{String}}(ThermalStandard => Set(["Alta"])) end From 838bc485c78042db7d179194ad7f7f0a7decbec5 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 21:58:28 -0400 Subject: [PATCH 18/22] pom breaking currently, going to pause and open pr --- src/operation/decision_model.jl | 2 +- src/services_models/security_constrained_injectors.jl | 10 ++-------- src/services_models/services_constructor.jl | 3 +++ test/includes.jl | 4 ++-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/operation/decision_model.jl b/src/operation/decision_model.jl index 3a7899fa..cfd31621 100644 --- a/src/operation/decision_model.jl +++ b/src/operation/decision_model.jl @@ -232,7 +232,7 @@ function solve!( # write needs no unit conversion and no round-trip ledger — a model's # system carries one only when it was built from a document. !ispath(sys_dir) && - PSY.to_file(sys, sys_dir; power_units = :component_base) + PSY.to_file(sys, sys_dir; units = :component_base) end end @info "\n$(RUN_OPERATION_MODEL_TIMER)\n" diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 7efbe89b..6d069c76 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -3,9 +3,6 @@ const _OUTAGE_MAP = Dict{Int, _PER_TYPE} const _G1_META = "G1" -const _SECURITY_CONSTRAINED_RESERVE = - Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve} - _validate_reserve_formulation(::ServiceModel) = false _validate_reserve_formulation( ::ServiceModel{ @@ -340,7 +337,7 @@ function _build_post_contingency_locational_power!( locations = Dict{String, String}() for ((name, uuid, t), deployed) in total.data key = get!(locations, name) do - _location_key(PSY.get_component(D, sys, name), network_model) + _location_key(PSY.get_component(device_type, sys, name), network_model) end JuMP.add_to_expression!(get!(JuMP.AffExpr, expr.data, (key, uuid, t)), deployed) end @@ -611,10 +608,7 @@ function _flow_entries( reduction_name_map = PNM.get_component_to_reduction_name_map(get_branch_catalog(network_model), T) entries = Dict{String, String}() - for name in names - get!(entries, reduction_name_map[name], name) - end - return entries + return [reduction_name_map[name] for name in names] end _flow_entries( diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index cc8b6201..c81ec6a9 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -1122,6 +1122,9 @@ function construct_service!( return end +const _SECURITY_CONSTRAINED_RESERVE = + Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve} + function construct_service!( container::OptimizationContainer, sys::PSY.System, diff --git a/test/includes.jl b/test/includes.jl index 3942f465..58fd79fb 100644 --- a/test/includes.jl +++ b/test/includes.jl @@ -7,7 +7,7 @@ using InfrastructureSystems import InfrastructureSystems: TableFormat using PowerNetworkMatrices import PowerSystemCaseBuilder: PSITestSystems -using PowerFlows +#using PowerFlows using DataFramesMeta # Test Packages @@ -34,7 +34,7 @@ import LinearAlgebra const PSY = PowerSystems const POM = PowerOperationsModels const IOM = InfrastructureOptimizationModels -const PFS = PowerFlows +#const PFS = PowerFlows const PSB = PowerSystemCaseBuilder const PNM = PowerNetworkMatrices const ISOPT = InfrastructureSystems.Optimization From 1b4468c7e92f096942189e5a54ed159a1c28fb7d Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 21:58:47 -0400 Subject: [PATCH 19/22] formatting --- .../security_constrained_injectors.jl | 42 ++++++++++++------- .../hydro_generation.jl | 6 ++- test/test_model_decision.jl | 4 +- test/test_services_constructor.jl | 10 ++++- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 6d069c76..2cecdafd 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -157,7 +157,8 @@ function _add_post_contingency_deployment!( end _total_deployments(container::OptimizationContainer) = [ - (get_component_type(key), expr) for (key, expr) in IOM.get_expressions(container) if + (get_component_type(key), expr) for + (key, expr) in IOM.get_expressions(container) if IOM.get_entry_type(key) === PostContingencyTotalReserveDeployment ] @@ -181,7 +182,11 @@ function _constrain_post_contingency_reserve!( ::Type{D}, ) where {R <: PSY.Service, D <: PSY.Component} key = IOM.ComponentPairKey{D, R} - has_container_key(container, PostContingencyActivePowerReserveDeploymentVariable, key) || + has_container_key( + container, + PostContingencyActivePowerReserveDeploymentVariable, + key, + ) || return jump_model = get_jump_model(container) service_name = PSY.get_name(service) @@ -272,18 +277,20 @@ function _create_post_contingency_flow_slacks!( sparse = true, meta = _G1_META, ) - for entry_name in _flow_entries(network_model, component_type, names), t in time_steps - - ub = slack_ub[entry_name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostContingencyFlowActivePowerSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", - lower_bound = 0.0, - ) - lb = slack_lb[entry_name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostContingencyFlowActivePowerSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", - lower_bound = 0.0, - ) + for entry_name in _flow_entries(network_model, component_type, names), + t in time_steps + ub = + slack_ub[entry_name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyFlowActivePowerSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + lower_bound = 0.0, + ) + lb = + slack_lb[entry_name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyFlowActivePowerSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + lower_bound = 0.0, + ) add_to_objective_invariant_expression!( container, (ub + lb) * CONSTRAINT_VIOLATION_SLACK_COST, @@ -695,7 +702,12 @@ function _constrain_post_contingency_flow!( ) end for entry_name in _flow_entries(network_model, component_type, names) - lims = _post_contingency_flow_limits(sys, network_model, component_type, entry_name) + lims = _post_contingency_flow_limits( + sys, + network_model, + component_type, + entry_name, + ) for t in time_steps f = flow[entry_name, uuid, t] if has_slacks && haskey(slack_ub.data, (entry_name, uuid, t)) diff --git a/src/static_injector_models/hydro_generation.jl b/src/static_injector_models/hydro_generation.jl index 034b1c18..5724a5bc 100644 --- a/src/static_injector_models/hydro_generation.jl +++ b/src/static_injector_models/hydro_generation.jl @@ -2723,7 +2723,8 @@ function add_to_expression!( isa(service, PSY.Reserve{PSY.ReserveUp}) || continue service_name = PSY.get_name(service) deployed_fraction = PSY.get_deployed_fraction(service) - variable = get_variable(container, U, IOM.ComponentPairKey{V, typeof(service)}) + variable = + get_variable(container, U, IOM.ComponentPairKey{V, typeof(service)}) for t in get_time_steps(container) add_proportional_to_jump_expression!( expression[name, t], @@ -2762,7 +2763,8 @@ function add_to_expression!( isa(service, PSY.Reserve{PSY.ReserveDown}) || continue service_name = PSY.get_name(service) deployed_fraction = PSY.get_deployed_fraction(service) - variable = get_variable(container, U, IOM.ComponentPairKey{V, typeof(service)}) + variable = + get_variable(container, U, IOM.ComponentPairKey{V, typeof(service)}) for t in get_time_steps(container) add_proportional_to_jump_expression!( expression[name, t], diff --git a/test/test_model_decision.jl b/test/test_model_decision.jl index d92f106e..f8c0865f 100644 --- a/test/test_model_decision.jl +++ b/test/test_model_decision.jl @@ -414,7 +414,9 @@ end vars = res.variable_values # Reserve variables share one container per (device type, service type), keyed # `(service_name, device_name, time)`. - S = IOM.get_component_type(IOM.VariableKey(ActivePowerReserveVariable, PSY.OfflineReserve)) + S = IOM.get_component_type( + IOM.VariableKey(ActivePowerReserveVariable, PSY.OfflineReserve), + ) service_keys = [ k for k in keys(vars) if IOM.get_entry_type(k) === ActivePowerReserveVariable && diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index 3c71e44b..98225f24 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -2177,7 +2177,10 @@ end reserve = IOM.get_variable( container, ActivePowerReserveVariable, - IOM.ComponentPairKey{PSY.InterruptiblePowerLoad, PSY.OnlineReserve{PSY.ReserveUp}}, + IOM.ComponentPairKey{ + PSY.InterruptiblePowerLoad, + PSY.OnlineReserve{PSY.ReserveUp}, + }, ) for service_name in ("R1", "R2") devices = if nested @@ -2260,7 +2263,10 @@ end r_dn = IOM.get_variable( container, ActivePowerReserveVariable, - IOM.ComponentPairKey{PSY.InterruptiblePowerLoad, PSY.OnlineReserve{PSY.ReserveDown}}, + IOM.ComponentPairKey{ + PSY.InterruptiblePowerLoad, + PSY.OnlineReserve{PSY.ReserveDown}, + }, ) for t in IOM.get_time_steps(container) From fd0b6afe10a9f7d6b020ddc83e9ed0071d4d0609 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Tue, 22 Sep 2026 23:28:02 -0400 Subject: [PATCH 20/22] claude fix bugs --- src/PowerOperationsModels.jl | 2 + src/core/variables.jl | 16 ++ src/operation/decision_model.jl | 4 +- .../security_constrained_injectors.jl | 84 ++++++---- src/services_models/services_constructor.jl | 48 ++++-- ...c_injection_security_constrained_models.jl | 155 +++++++++--------- 6 files changed, 186 insertions(+), 123 deletions(-) diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index ede2b6e3..ffa12864 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -681,6 +681,8 @@ export ShiftDownActivePowerVariable # G-1 Variables export PostContingencyActivePowerReserveDeploymentVariable export PostContingencyAreaInterchangeFlowDeviationVariable +export PostGeneratorContingencyFlowActivePowerSlackUpperBound +export PostGeneratorContingencyFlowActivePowerSlackLowerBound ######## Hydro Formulations ######## export HydroDispatchRunOfRiver diff --git a/src/core/variables.jl b/src/core/variables.jl index 99c6ec2b..5a85a06c 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -855,6 +855,18 @@ Post-contingency power exchange over an area interchange from security-constrain """ struct PostContingencyAreaInterchangeFlowDeviationVariable <: VariableType end +""" +Relaxes the upper bound of the post-generator-contingency flow-rate constraint of +security-constrained reserves when `use_slacks = true`. +""" +struct PostGeneratorContingencyFlowActivePowerSlackUpperBound <: VariableType end + +""" +Relaxes the lower bound of the post-generator-contingency flow-rate constraint of +security-constrained reserves when `use_slacks = true`. +""" +struct PostGeneratorContingencyFlowActivePowerSlackLowerBound <: VariableType end + const MULTI_START_VARIABLES = (HotStartVariable, WarmStartVariable, ColdStartVariable) should_write_resulting_value(::Type{PiecewiseLinearCostVariable}) = false @@ -899,3 +911,7 @@ convert_output_to_natural_units( ::Type{PostContingencyActivePowerReserveDeploymentVariable}, ) = true +convert_output_to_natural_units( + ::Type{PostContingencyAreaInterchangeFlowDeviationVariable}, +) = + true diff --git a/src/operation/decision_model.jl b/src/operation/decision_model.jl index cfd31621..724ff8d4 100644 --- a/src/operation/decision_model.jl +++ b/src/operation/decision_model.jl @@ -231,8 +231,8 @@ function solve!( # `power_units = :component_base` is what PSY stores internally, so the # write needs no unit conversion and no round-trip ledger — a model's # system carries one only when it was built from a document. - !ispath(sys_dir) && - PSY.to_file(sys, sys_dir; units = :component_base) + #!ispath(sys_dir) && + # PSY.to_file(sys, sys_dir; units = :component_base) end end @info "\n$(RUN_OPERATION_MODEL_TIMER)\n" diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index 2cecdafd..d8411130 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -162,14 +162,13 @@ _total_deployments(container::OptimizationContainer) = [ IOM.get_entry_type(key) === PostContingencyTotalReserveDeployment ] -# Deployment can only draw on procured reserve. Reserves without a requirement series have no -# meaningful award, so their deployment is limited by generation headroom alone. +# Deployment can only draw on procured reserve. Unprocured reserves are limited by +# generation headroom alone. function _constrain_post_contingency_reserve!( container::OptimizationContainer, service::R, model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, ) where {R <: _SECURITY_CONSTRAINED_RESERVE} - _has_ts_requirement(model, service) || return for device_type in keys(get_contributing_devices_map(model, PSY.get_name(service))) _constrain_post_contingency_reserve!(container, service, device_type) end @@ -213,9 +212,11 @@ end function _create_post_contingency_interchange_variables!( container::OptimizationContainer, - monitored_components::_OUTAGE_MAP, + outaged_generators::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) + has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) || return + flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) jump_model = get_jump_model(container) time_steps = get_time_steps(container) var = add_variable_container!( @@ -227,14 +228,12 @@ function _create_post_contingency_interchange_variables!( Int[]; sparse = true, ) - for (uuid, per_type) in monitored_components - for name in get(Set{String}, per_type, PSY.AreaInterchange), t in time_steps - var[name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", - start = 0.0, - ) - end + for uuid in keys(outaged_generators), name in axes(flow, 1), t in time_steps + var[name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", + start = 0.0, + ) end return end @@ -256,39 +255,37 @@ function _create_post_contingency_flow_slacks!( for (uuid, per_type) in monitored_components use_slacks[uuid] || continue for (component_type, names) in per_type - # Meta keeps these apart from the branch-side MODF slacks of the same type. slack_ub = lazy_container_addition!( container, - PostContingencyFlowActivePowerSlackUpperBound, + PostGeneratorContingencyFlowActivePowerSlackUpperBound, component_type, String[], Int[], Int[]; sparse = true, - meta = _G1_META, ) slack_lb = lazy_container_addition!( container, - PostContingencyFlowActivePowerSlackLowerBound, + PostGeneratorContingencyFlowActivePowerSlackLowerBound, component_type, String[], Int[], Int[]; sparse = true, - meta = _G1_META, ) for entry_name in _flow_entries(network_model, component_type, names), t in time_steps + ub = slack_ub[entry_name, uuid, t] = JuMP.@variable( jump_model, - base_name = "PostContingencyFlowActivePowerSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + base_name = "PostGeneratorContingencyFlowActivePowerSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", lower_bound = 0.0, ) lb = slack_lb[entry_name, uuid, t] = JuMP.@variable( jump_model, - base_name = "PostContingencyFlowActivePowerSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + base_name = "PostGeneratorContingencyFlowActivePowerSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", lower_bound = 0.0, ) add_to_objective_invariant_expression!( @@ -327,7 +324,7 @@ function _build_post_contingency_locational_power!( container::OptimizationContainer, sys::PSY.System, outaged_generators::_OUTAGE_MAP, - network_model::NetworkModel, + network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) totals = _total_deployments(container) expr = lazy_container_addition!( @@ -371,6 +368,13 @@ function _build_post_contingency_locational_power!( return end +_build_post_contingency_locational_power!( + ::OptimizationContainer, + ::PSY.System, + ::_OUTAGE_MAP, + ::NetworkModel, +) = nothing + # Monitored names resolve to their reduction entry, the row the pre-contingency # `PTDFBranchFlow` and the post-contingency expression are keyed by. function _build_post_contingency_flow!( @@ -431,12 +435,21 @@ function _build_post_contingency_flow!( return end -# TODO: Only monitored interchanges can take post-contingency flow, is that sensible? function _build_post_contingency_flow!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) + if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) + @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = + LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 + return + end + has_container_key( + container, + PostContingencyAreaInterchangeFlowDeviationVariable, + PSY.AreaInterchange, + ) || return time_steps = get_time_steps(container) expr = add_expression_container!( container, @@ -463,7 +476,7 @@ function _build_post_contingency_flow!( ), ) for t in time_steps - ex = expr[name, uuid, t] + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) JuMP.add_to_expression!(ex, flow[name, t]) JuMP.add_to_expression!(ex, deviation[name, uuid, t]) end @@ -534,7 +547,7 @@ function _constrain_post_contingency_balance!( PostContingencyAreaInterchangeFlowDeviationVariable, PSY.AreaInterchange, ) - modeled = Set{String}(axes(deviation, 1)) + modeled = Set{String}(k[1] for k in keys(deviation.data)) for interchange in PSY.get_components(PSY.AreaInterchange, sys) name = PSY.get_name(interchange) name in modeled || continue @@ -559,9 +572,13 @@ function _constrain_post_contingency_balance!( uuids, time_steps, ) - for (area_name, uuid, t) in keys(deployment.data) + # Every area needs a row, or deviations into an area without deployment are unconstrained. + for area_name in area_names, uuid in uuids, t in time_steps balance = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(balance, deployment[area_name, uuid, t]) + JuMP.add_to_expression!( + balance, + get(deployment.data, (area_name, uuid, t), zero(JuMP.AffExpr)), + ) for (sign, interchange_name) in get(interchanges, area_name, ()) JuMP.add_to_expression!(balance, sign, deviation[interchange_name, uuid, t]) end @@ -605,8 +622,7 @@ _post_contingency_flow_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = _post_contingency_flow_expression(::NetworkModel{AreaBalanceNetworkModel}) = PostContingencyAreaInterchangeFlow -# Post-contingency flow key => a representative monitored component name. Parallel -# circuits share one reduced entry, so each entry is constrained once. +# Parallel circuits share one reduced entry, so each entry is constrained once. function _flow_entries( network_model::NetworkModel{<:AbstractPTDFNetworkModel}, ::Type{T}, @@ -614,15 +630,14 @@ function _flow_entries( ) where {T <: PSY.ACTransmission} reduction_name_map = PNM.get_component_to_reduction_name_map(get_branch_catalog(network_model), T) - entries = Dict{String, String}() - return [reduction_name_map[name] for name in names] + return Set{String}(reduction_name_map[name] for name in names) end _flow_entries( ::NetworkModel{AreaBalanceNetworkModel}, ::Type{PSY.AreaInterchange}, names::Set{String}, -) = Dict{String, String}(name => name for name in names) +) = names function _post_contingency_flow_limits( ::PSY.System, @@ -683,22 +698,19 @@ function _constrain_post_contingency_flow!( ) has_slacks = has_container_key( container, - PostContingencyFlowActivePowerSlackUpperBound, + PostGeneratorContingencyFlowActivePowerSlackUpperBound, component_type, - _G1_META, ) if has_slacks slack_ub = get_variable( container, - PostContingencyFlowActivePowerSlackUpperBound, + PostGeneratorContingencyFlowActivePowerSlackUpperBound, component_type, - _G1_META, ) slack_lb = get_variable( container, - PostContingencyFlowActivePowerSlackLowerBound, + PostGeneratorContingencyFlowActivePowerSlackLowerBound, component_type, - _G1_META, ) end for entry_name in _flow_entries(network_model, component_type, names) diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index c81ec6a9..b60724fd 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -142,11 +142,6 @@ function construct_services!( _, monitored_components, use_slacks = _security_constrained_outages(sys, services_template, network_model) - _create_post_contingency_interchange_variables!( - container, - monitored_components, - network_model, - ) _create_post_contingency_flow_slacks!( container, monitored_components, @@ -198,6 +193,12 @@ function construct_services!( outaged_generators, monitored_components, _ = _security_constrained_outages(sys, services_template, network_model) + # Tie flow variables are built with the branches, after the services argument stage. + _create_post_contingency_interchange_variables!( + container, + outaged_generators, + network_model, + ) _build_post_contingency_locational_power!( container, sys, @@ -1125,6 +1126,17 @@ end const _SECURITY_CONSTRAINED_RESERVE = Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve} +_requires_requirement_ts(::Type{SecurityConstrainedContingencyReserve}) = false +_requires_requirement_ts(::Type{SecurityConstrainedRampReserve}) = true + +# Whether `service` is procured pre-contingency (reserve variable, requirement, ramp, +# participation, objective); otherwise it only deploys post-contingency. +_service_requires_requirement_ts( + model::ServiceModel{<:PSY.AbstractReserve, F}, + service::PSY.AbstractReserve, +) where {F <: AbstractSecurityConstrainedReservesFormulation} = + _requires_requirement_ts(F) || _has_ts_requirement(model, service) + function construct_service!( container::OptimizationContainer, sys::PSY.System, @@ -1139,15 +1151,16 @@ function construct_service!( ts_services = [s for s in services if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) - add_service_variables!( + requirement_services = + [s for s in services if _service_requires_requirement_ts(model, s)] + isempty(requirement_services) || add_service_variables!( container, ActivePowerReserveVariable, - services, + requirement_services, model, RampReserve, ) - for service in services - _add_post_contingency_deployment!(container, sys, service, model) + for service in requirement_services add_to_expression!( container, ActivePowerReserveVariable, @@ -1155,6 +1168,9 @@ function construct_service!( model, devices_template, ) + end + for service in services + _add_post_contingency_deployment!(container, sys, service, model) add_feedforward_arguments!(container, model, service) end return @@ -1171,7 +1187,16 @@ function construct_service!( ) where {R <: _SECURITY_CONSTRAINED_RESERVE} services = _services_with_contributors(model, sys) isempty(services) && return - service_names = PSY.get_name.(services) + for service in services + add_feedforward_constraints!(container, model, service) + end + requirement_services = + [s for s in services if _service_requires_requirement_ts(model, s)] + if isempty(requirement_services) + add_constraint_dual!(container, sys, model) + return + end + service_names = PSY.get_name.(requirement_services) add_constraints_container!( container, RequirementConstraint, @@ -1180,7 +1205,7 @@ function construct_service!( get_time_steps(container), ) get_use_slacks(model) && add_reserve_slacks!(container, R, service_names) - for service in services + for service in requirement_services contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) add_constraints!( container, @@ -1204,7 +1229,6 @@ function construct_service!( ) _constrain_post_contingency_reserve!(container, service, model) add_to_objective_function!(container, service, model) - add_feedforward_constraints!(container, model, service) end add_constraint_dual!(container, sys, model) return diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index ebc7304b..dac0f09a 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -103,6 +103,7 @@ end function get_reserve_total_power_by_step_dict( variables::Dict{String, DataFrame}, var_name::String, + service_name::String, associated_outages::Vector{PSY.UnplannedOutage}, contributing_devices::Union{ IS.FlattenIteratorWrapper{<:PSY.Generator}, @@ -116,8 +117,13 @@ function get_reserve_total_power_by_step_dict( outage_name = IS.get_id(outage) outage_power_v = Vector{Float64}() for device in contributing_devices - # Sparse (device, outage, t) outputs are flattened to a single "device__outage" column. - column = IOM.encode_tuple_to_column((PSY.get_name(device), string(outage_name))) + # Sparse (service, device, outage, t) outputs are flattened to a single + # "service__device__outage" column. + column = IOM.encode_tuple_to_column(( + service_name, + PSY.get_name(device), + string(outage_name), + )) current_v = filter(x -> x[col_name] == column, required_variables)[!, "value"] # The outaged generator has no deployment rows. @@ -163,7 +169,13 @@ function compare_outage_power_and_deployed_reserves( contributing_devices = PSY.get_contributing_devices(sys, service) reserve_dict = get_reserve_total_power_by_step_dict( variablesdict, - "PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard", + IOM.encode_key_as_string( + IOM.VariableKey( + PostContingencyActivePowerReserveDeploymentVariable, + IOM.ComponentPairKey{PSY.ThermalStandard, typeof(service)}, + ), + ), + PSY.get_name(service), associated_outages, contributing_devices; col_name = "name", @@ -337,7 +349,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] gen = get_component(ThermalStandard, sys, "Solitude") @@ -404,7 +416,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] # Counts are smaller than the all-lines baseline `[576, 0, 888, 480, 48]` @@ -474,7 +486,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") @@ -496,7 +508,7 @@ end ps_model, sys, constraint_keys, - [576, 0, 600, 480, 48], + [456, 0, 600, 456, 48], false, GAEVF, 329000.0, @@ -567,7 +579,7 @@ end ps_model, sys, constraint_keys, - [576, 0, 504, 480, 48], + [336, 0, 504, 432, 48], false, GAEVF, 329000.0, @@ -708,7 +720,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] component = get_component(ThermalStandard, sys, "Alta") @@ -738,7 +750,7 @@ end ps_model, sys, constraint_keys, - [936, 0, 1248, 480, 168], + [1032, 0, 1344, 480, 168], true, GAEVF, 254242.0, @@ -760,7 +772,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] components_outages_names, reserve_names = @@ -789,7 +801,7 @@ end ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) if reserve_slack - moi_counts = [2040, 0, 1536, 1200, 120] + moi_counts = [2568, 0, 2112, 1296, 120] else moi_counts = [1176, 0, 2112, 1296, 120] end @@ -827,10 +839,10 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] - # Counts are smaller than the all-lines baseline `[744, 0, 1536, 1200, 120]` + # Counts are smaller than the all-lines baseline `[1176, 0, 2112, 1296, 120]` # because each service monitors only two AC lines instead of all 13. components_outages_names, reserve_names = (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) @@ -868,7 +880,7 @@ end ps_model, sys, constraint_keys, - [744, 0, 1008, 672, 120], + [1176, 0, 1584, 768, 120], false, GAEVF, 497000.0, @@ -889,7 +901,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] components_outages_names, reserve_names = @@ -916,9 +928,9 @@ end ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) if reserve_slack - moi_counts = [2040, 0, 1344, 1200, 120] + moi_counts = [2280, 0, 1536, 1248, 120] else - moi_counts = [744, 0, 1344, 1200, 120] + moi_counts = [936, 0, 1536, 1248, 120] end run_sc_reserve_case!( ps_model, @@ -997,7 +1009,7 @@ end ps_model, sys, constraint_keys, - [504, 0, 1344, 1152, 168], + [456, 0, 1344, 1152, 120], false, GAEVF, 497000.0, @@ -1016,7 +1028,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] components_outages_names, reserve_names = @@ -1042,7 +1054,7 @@ end ps_model, sys, constraint_keys, - [720, 0, 624, 288, 72], + [1152, 0, 1200, 384, 72], false, GAEVF, 497494.48, @@ -1060,7 +1072,7 @@ end IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), ] components_outages_names, reserve_names = @@ -1086,7 +1098,7 @@ end ps_model, sys, constraint_keys, - [720, 0, 432, 288, 72], + [912, 0, 624, 336, 72], false, GAEVF, 482055.92, @@ -1131,7 +1143,7 @@ end ps_model, sys, constraint_keys, - [240, 0, 216, 120, 72], + [336, 0, 216, 144, 48], false, GAEVF, 329000.0, @@ -1147,7 +1159,7 @@ end IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), @@ -1193,7 +1205,7 @@ end @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 792, 0, 696, 360, 144, false) + moi_tests(ps_model, 1224, 0, 1272, 456, 144, false) opt_container = IOM.get_optimization_container(ps_model) copper_plate_constraints = @@ -1215,7 +1227,7 @@ end IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), IOM.ConstraintKey( PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - PSY.ThermalStandard, + IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, ), IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), @@ -1261,7 +1273,7 @@ end @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 792, 0, 504, 360, 144, false) + moi_tests(ps_model, 984, 0, 696, 408, 144, false) opt_container = IOM.get_optimization_container(ps_model) copper_plate_constraints = @@ -1344,7 +1356,7 @@ end @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - moi_tests(ps_model, 552, 0, 504, 312, 192, false) + moi_tests(ps_model, 744, 0, 504, 360, 144, false) opt_container = IOM.get_optimization_container(ps_model) copper_plate_constraints = @@ -1467,16 +1479,16 @@ end # do not hold). # ---------------------------------------------------------------------------- -# `PostContingencyAreaInterchangeFlowDeviationVariable` is dense over -# (area_interchange_name, outage_id, t), so `read_variable` returns separate -# "name"/"name2" columns (tie, outage) like the reserve deployment variable. +# `PostContingencyAreaInterchangeFlowDeviationVariable` is sparse over +# (area_interchange_name, outage_id, t), flattened to a "tie__outage" column. # Filter to one (outage, tie) pair and return the value series sorted by time. function _sc_flow_deviation_series( df::DataFrame, outage_id::Int, monitored_name::String, ) - rows = filter(x -> x["name2"] == outage_id && x["name"] == monitored_name, df) + column = IOM.encode_tuple_to_column((monitored_name, string(outage_id))) + rows = filter(x -> x["name"] == column, df) sorted = sort(rows, :DateTime) return sorted[!, "value"] end @@ -1485,7 +1497,8 @@ end # contributing devices for one outage, sorted by time. Shared by the # cross-area deployment/Δf identity checks below. function total_deployment_series(df::DataFrame, outage_id::Int) - outage_rows = filter(x -> x["name2"] == outage_id, df) + suffix = IOM.PSI_NAME_DELIMITER * string(outage_id) + outage_rows = filter(x -> endswith(x["name"], suffix), df) total_by_t = combine(groupby(outage_rows, :DateTime), :value => sum => :dep) sort!(total_by_t, :DateTime) return total_by_t.dep @@ -1598,7 +1611,7 @@ end outage_id = IS.get_id(only(resolved_outages)) deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard"] + variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard__OnlineReserve__ReserveUp"] total_deployment = total_deployment_series(deployment, outage_id) flow_dev = @@ -1632,7 +1645,7 @@ end outage_id = IS.get_id(only(resolved_outages)) deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard"] + variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard__OnlineReserve__ReserveUp"] total_deployment = total_deployment_series(deployment, outage_id) flow_dev = @@ -1663,10 +1676,10 @@ end container = IOM.get_optimization_container(ps_model) flow_dev = IOM.get_variable( container, - PostContingencyAreaInterchangeFlowDeviationVariable(), + PostContingencyAreaInterchangeFlowDeviationVariable, AreaInterchange, ) - @test Set(axes(flow_dev, 1)) == Set(["1_2", "1_2_b"]) + @test Set(k[1] for k in keys(flow_dev.data)) == Set(["1_2", "1_2_b"]) for meta in ("G1_lb", "G1_ub") # `SparseAxisArray` does not forward `Base.keys` to the container @@ -1692,7 +1705,7 @@ end # Tighten the "1_2" tie well below the cross-area response so the # post-contingency flow-rate constraint binds. tie = get_component(AreaInterchange, sys, "1_2") - set_flow_limits!(tie, (from_to = 0.1, to_from = 0.1)) + set_flow_limits!(tie, (from_to = 0.1 * PSY.SU, to_from = 0.1 * PSY.SU)) @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT @@ -1707,7 +1720,7 @@ end compare_outage_power_and_deployed_reserves(sys, res, reserve_up) slack_lb = - variables["PostContingencyFlowActivePowerSlackLowerBound__AreaInterchange"] + variables["PostGeneratorContingencyFlowActivePowerSlackLowerBound__AreaInterchange"] @test maximum(slack_lb[!, "value"]) > 1e-3 end @@ -1731,10 +1744,10 @@ end container = IOM.get_optimization_container(ps_model) flow_deviation_var = IOM.get_variable( container, - PostContingencyAreaInterchangeFlowDeviationVariable(), + PostContingencyAreaInterchangeFlowDeviationVariable, AreaInterchange, ) - interchange_axis = axes(flow_deviation_var, 1) + interchange_axis = Set(k[1] for k in keys(flow_deviation_var.data)) @test "1_2" in interchange_axis @test !("1_2_b" in interchange_axis) @@ -1802,15 +1815,15 @@ _exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_dev container = IOM.get_optimization_container(ps_model) flow_var = - IOM.get_variable(container, IOM.FlowActivePowerVariable(), AreaInterchange) + IOM.get_variable(container, FlowActivePowerVariable, AreaInterchange) flow_deviation_var = IOM.get_variable( container, - PostContingencyAreaInterchangeFlowDeviationVariable(), + PostContingencyAreaInterchangeFlowDeviationVariable, AreaInterchange, ) # Δf spans exactly the pre-contingency flow variable's ties. @test Set(axes(flow_var, 1)) == Set(["1_2"]) - @test Set(axes(flow_deviation_var, 1)) == Set(["1_2"]) + @test Set(k[1] for k in keys(flow_deviation_var.data)) == Set(["1_2"]) @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = OptimizationProblemOutputs(ps_model) @@ -1821,7 +1834,7 @@ _exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_dev only(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up))), ) total_deployment = total_deployment_series( - variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard"], + variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard__OnlineReserve__ReserveUp"], outage_id, ) tie_flow_dev = _sc_flow_deviation_series( @@ -1864,7 +1877,7 @@ end container = IOM.get_optimization_container(ps_model) @test !IOM.has_container_key( container, - IOM.FlowActivePowerVariable, + FlowActivePowerVariable, AreaInterchange, ) # No modeled tie, so no Δf container at all: the per-area balance reduces @@ -1948,12 +1961,10 @@ end @test !haskey(per_type, AreaInterchange) end -@testset "PostContingencyBranchFlow applies PTDF orientation sign for series-reduced monitored branch" begin - # Regression for M4: `RADIAL1-RADIAL2-i_1` is a `:ToFrom`-oriented member - # of a degree-two series reduction (orientation_sign == -1.0) — mirrors - # the `case10_radial_series_reductions` fixture pattern used for the - # equivalent device-side check in - # test_ac_transmission_security_constrained_models.jl. +@testset "PostContingencyBranchFlow uses reduced-arc PTDF for series-reduced monitored branch" begin + # `RADIAL1-RADIAL2-i_1` is a `:ToFrom`-oriented member of a degree-two series + # reduction (orientation_sign == -1.0). Pre- and post-contingency flows are both + # keyed by the reduced arc, so the arc's PTDF column applies unflipped. sys_red = PSB.build_system(PSITestSystems, "case10_radial_series_reductions") dummy_forecast = Deterministic( @@ -2017,11 +2028,11 @@ end time_steps = IOM.get_time_steps(container) outage_id = IS.get_id(outage) actual_container = - IOM.get_expression(container, PostContingencyBranchFlow(), Line, "G1") - pre_flow = IOM.get_expression(container, PTDFBranchFlow(), Line) + IOM.get_expression(container, PostContingencyBranchFlow, Line, "G1") + pre_flow = IOM.get_expression(container, PTDFBranchFlow, Line) nodal_deployment = IOM.get_expression( container, - PostContingencyNodalActivePowerDeployment(), + PostContingencyNodalActivePowerDeployment, ACBus, ) # The series member resolves to its reduction entry, which keys both the @@ -2041,23 +2052,23 @@ end for t in time_steps actual = actual_container[entry_name, outage_id, t] - correctly_signed = copy(pre_flow[entry_name, t]) - naively_signed = copy(pre_flow[entry_name, t]) + arc_oriented = copy(pre_flow[entry_name, t]) + member_oriented = copy(pre_flow[entry_name, t]) for b in eachindex(ptdf_col) coef = ptdf_col[b] abs(coef) < IOM.PTDF_ZERO_TOL && continue bus_key = string(full_bus_axis[b]) bus_key in relevant_buses || continue JuMP.add_to_expression!( - correctly_signed, orientation_sign * coef, - nodal_deployment[bus_key, outage_id, t], + arc_oriented, coef, nodal_deployment[bus_key, outage_id, t], ) JuMP.add_to_expression!( - naively_signed, coef, nodal_deployment[bus_key, outage_id, t], + member_oriented, orientation_sign * coef, + nodal_deployment[bus_key, outage_id, t], ) end - @test aff_exprs_approx_equal(actual, correctly_signed; atol = 1e-8) - @test !aff_exprs_approx_equal(actual, naively_signed; atol = 1e-8) + @test aff_exprs_approx_equal(actual, arc_oriented; atol = 1e-8) + @test !aff_exprs_approx_equal(actual, member_oriented; atol = 1e-8) end end @@ -2076,7 +2087,7 @@ end outaged_gen = get_component(ThermalStandard, sys, "Alta") offline_gen = get_component(ThermalStandard, sys, "Park City") - @test PSY.get_active_power_limits(offline_gen).min > 0.0 # guard: fix-to-zero must bind the old lb + @test PSY.get_active_power_limits(offline_gen, PSY.SU).min > 0.0 # guard: fix-to-zero must bind the old lb outage = attach_fixed_outage!( sys, @@ -2100,16 +2111,16 @@ end IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(ps_model) - on_var = IOM.get_variable(container, OnVariable(), ThermalStandard) + on_var = IOM.get_variable(container, OnVariable, ThermalStandard) deployment_var = IOM.get_variable( container, - PostContingencyActivePowerReserveDeploymentVariable(), - ThermalStandard, + PostContingencyActivePowerReserveDeploymentVariable, + IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, ) outage_id = IS.get_id(outage) for t in IOM.get_time_steps(container) JuMP.fix(on_var["Park City", t], 0.0; force = true) - JuMP.fix(deployment_var["Park City", outage_id, t], 0.0; force = true) + JuMP.fix(deployment_var["Reserve1", "Park City", outage_id, t], 0.0; force = true) end @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -2153,9 +2164,7 @@ end IOM.ModelBuildStatus.BUILT container = IOM.get_optimization_container(ps_model) - @test !IOM.has_container_key( - container, - RequirementConstraint, - OnlineReserve{ReserveUp}, - ) + requirement = + IOM.get_constraint(container, RequirementConstraint, OnlineReserve{ReserveUp}) + @test !("Reserve1" in axes(requirement, 1)) end From 791a2df5afd6fb4f0f92e9ee1e67e384df0dabfc Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 23 Sep 2026 15:24:16 -0400 Subject: [PATCH 21/22] claude address review comments --- Project.toml | 37 +- src/PowerOperationsModels.jl | 23 +- src/core/constraints.jl | 25 +- src/core/expressions.jl | 35 +- src/core/formulations.jl | 76 +- src/core/variables.jl | 46 +- src/operation/decision_model.jl | 3 +- src/operation/template_validation.jl | 85 +- src/services_models/reserve_group.jl | 5 +- src/services_models/reserves.jl | 341 +-- .../security_constrained_injectors.jl | 644 +++-- src/services_models/services_constructor.jl | 185 +- test/Project.toml | 44 +- test/includes.jl | 4 +- ...c_injection_security_constrained_models.jl | 2489 +++-------------- 15 files changed, 1244 insertions(+), 2798 deletions(-) diff --git a/Project.toml b/Project.toml index f4d662b7..d9527342 100644 --- a/Project.toml +++ b/Project.toml @@ -7,16 +7,16 @@ authors = ["Sienna-Platform"] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" InfraStore = "6aee0376-896a-4a59-96ff-12f221c99746" -InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" InfrastructureOptimizationModels = "bed98974-b02a-5e2f-9ee0-a103f5c45069" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" -InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" +InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" +InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" PowerDynamicsOpenAPIModels = "044a0b22-31f8-4ef6-8282-c9a61b3013f6" PowerInvestmentsOpenAPIModels = "33cb4396-f4d9-4f59-8585-787ebb56cb1b" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" @@ -24,8 +24,8 @@ PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" @@ -34,17 +34,24 @@ TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" [sources] -InfrastructureCoreOpenAPIModels = {rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -InfrastructureOptimizationModels = {path = "/Users/acostare/Sienna/InfrastructureOptimizationModels.jl/service-containers/"} -InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} -InfrastructureTimeSeriesOpenAPIModels = {rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerCoreOpenAPIModels = {rev = "main", subdir = "PowerCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerDynamicsOpenAPIModels = {rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerInvestmentsOpenAPIModels = {rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} +InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git", rev = "IS4"} +# TEMPORARY: repin to `rev = "main"` once IOM #171 (ac/service-containers) merges. +InfrastructureOptimizationModels = {rev = "ac/service-containers", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} -PowerOpenAPIModels = {rev = "main", subdir = "PowerOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerOperationsOpenAPIModels = {rev = "main", subdir = "PowerOperationsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl.git"} +# PSY psy6 depends on these unregistered packages; [sources] of non-root projects are +# ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as +# PSY's own sources). POM imports none of them: they sit in [deps] only because Pkg +# rejects a [sources] entry for a package absent from [deps]. Mirrors PSY's own +# Project.toml. +# Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. +PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} +PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} [extensions] PowerFlowsExt = "PowerFlows" @@ -55,11 +62,11 @@ DocStringExtensions = "~0.8, ~0.9" InfrastructureSystems = "3" InteractiveUtils = "1.11.0" JuMP = "^1.28" -PowerFlows = "^0.24" +PowerFlows = "0.25" PowerNetworkMatrices = "^0.24" PowerSystems = "^5.10" PrettyTables = "3" -ProgressMeter = "1.11.0" Random = "1" +ProgressMeter = "1.11.0" TimerOutputs = "1" julia = "^1.11" diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index ffa12864..f6f5b73b 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -679,10 +679,10 @@ export ShiftUpActivePowerVariable export ShiftDownActivePowerVariable # G-1 Variables -export PostContingencyActivePowerReserveDeploymentVariable -export PostContingencyAreaInterchangeFlowDeviationVariable -export PostGeneratorContingencyFlowActivePowerSlackUpperBound -export PostGeneratorContingencyFlowActivePowerSlackLowerBound +export PostContingencyDeploymentVariable +export PostContingencyDeviationVariable +export PostGeneratorContingencyFlowSlackUpperBound +export PostGeneratorContingencyFlowSlackLowerBound ######## Hydro Formulations ######## export HydroDispatchRunOfRiver @@ -874,9 +874,10 @@ export ShiftUpActivePowerVariableLimitsConstraint export ShiftDownActivePowerVariableLimitsConstraint export RealizedShiftedLoadMinimumBoundConstraint export NonAnticipativityConstraint -export PostContingencyGenerationBalanceConstraint -export PostContingencyActivePowerGenerationLimitsConstraint -export PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint +export HVDCDCControlConstraint +export PostContingencyBalanceConstraint +export PostContingencyGenerationConstraint +export PostContingencyDeploymentConstraint ################################################################################# # Exports - Expression Types (defined in core/expressions.jl) @@ -909,10 +910,10 @@ export ComponentReserveDownBalanceExpression export InterfaceTotalFlow export PTDFBranchFlow export BThetaBranchFlow -export PostContingencyNodalActivePowerDeployment -export PostContingencyAreaActivePowerDeployment -export PostContingencyAreaInterchangeFlow -export PostContingencyTotalReserveDeployment +export PostContingencyNodalDeployment +export PostContingencyAreaDeployment +export PostContingencyInterchangeFlow +export PostContingencyTotalDeployment ################################################################################# # Exports - Parameter Types (defined in core/parameters.jl) diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 1f4951f7..3643a8e4 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -1280,18 +1280,27 @@ struct OfflineReserveBandConstraint <: ConstraintType end ################################################################################# """ -Deployed reserves from devices contributing to security-constrained reserves must match outaged -power. +Post-contingency power balance under outage ``o``. Copper plate and PTDF networks balance the +system: ``\\sum_d \\Delta_{d,o,t} = \\sum_{g \\in G_o} p_{g,t}``. Area balance networks +balance each area ``a`` over the interchanges ``i`` into and out of it: + +``\\Delta P_{a,o,t} + \\sum_{i \\to a} \\Delta f_{i,o,t} - \\sum_{i \\leftarrow a} \\Delta f_{i,o,t} = 0``. + +See [`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyGenerationBalanceConstraint <: ConstraintType end +struct PostContingencyBalanceConstraint <: ConstraintType end """ -Devices contributing to security-constrained reserves cannot reserve more than their personal -limit. +A contributing device's post-contingency output stays within its maximum: +``p_{d,t} + \\Delta_{d,o,t} \\le P^{max}_d``. + +See [`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyActivePowerGenerationLimitsConstraint <: ConstraintType end +struct PostContingencyGenerationConstraint <: ConstraintType end """ -TODO: complete +A procured reserve deploys at most its award: ``\\delta_{s,d,o,t} \\le r_{s,d,t}``. + +See [`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint <: ConstraintType end +struct PostContingencyDeploymentConstraint <: ConstraintType end diff --git a/src/core/expressions.jl b/src/core/expressions.jl index 88b811de..45aafb08 100644 --- a/src/core/expressions.jl +++ b/src/core/expressions.jl @@ -15,7 +15,15 @@ variable); `FlowRateConstraint` rows are written directly on it. Reportable as a mirroring `PTDFBranchFlow`. """ struct BThetaBranchFlow <: ExpressionType end -struct PostContingencyNodalActivePowerDeployment <: PostContingencyExpressions end + +""" +Post-contingency change in injection at bus ``n`` under outage ``o``: +``\\Delta P_{n,o,t} = \\sum_{d \\in n} \\Delta_{d,o,t} - \\sum_{g \\in G_o \\cap n} p_{g,t}``. + +Sparse, indexed `(bus number, outage, time)`. See +[`SecurityConstrainedContingencyReserve`](@ref). +""" +struct PostContingencyNodalDeployment <: PostContingencyExpressions end struct RealizedShiftedLoad <: ExpressionType end ################################################################################# @@ -129,20 +137,31 @@ struct StorageReserveBalanceExpression{D, S, Sd} <: ################################################################################# """ -Per-area power considering post-contingency reserve deployment. +Post-contingency change in injection in area ``a`` under outage ``o``: +``\\Delta P_{a,o,t} = \\sum_{d \\in a} \\Delta_{d,o,t} - \\sum_{g \\in G_o \\cap a} p_{g,t}``. + +Sparse, indexed `(area, outage, time)`. See [`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyAreaActivePowerDeployment <: ExpressionType end +struct PostContingencyAreaDeployment <: ExpressionType end """ -Per-interchange flow considering post-contingency reserve deployment. +Post-contingency flow over monitored area interchange ``i`` under outage ``o``: +``f^o_{i,t} = f_{i,t} + \\Delta f_{i,o,t}``. + +Sparse, indexed `(interchange, outage, time)`, meta `"G1"`. See +[`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyAreaInterchangeFlow <: ExpressionType end +struct PostContingencyInterchangeFlow <: ExpressionType end """ -Post-contingency reserve deployed by a contributing device, summed across every -security-constrained reserve responding to the outage. +Post-contingency deployment of contributing device ``d`` under outage ``o``, summed across the +security-constrained reserves responding to it: +``\\Delta_{d,o,t} = \\sum_s \\delta_{s,d,o,t}``. + +Sparse, indexed `(device, outage, time)`. Stored because the balance, generation-limit, and +locational-deployment terms all read it. See [`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyTotalReserveDeployment <: ExpressionType end +struct PostContingencyTotalDeployment <: ExpressionType end # Method extensions for output writing should_write_resulting_value(::Type{InterfaceTotalFlow}) = true diff --git a/src/core/formulations.jl b/src/core/formulations.jl index d599b4ee..cd4c4bca 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -827,30 +827,66 @@ struct HybridDispatchWithReserves <: AbstractHybridFormulationWithReserves end abstract type AbstractSecurityConstrainedReservesFormulation <: AbstractReservesFormulation end """ -Security-constrained contingency reserve formulation: deploys reserves -under each G-1 outage scoped to the reserve `PSY.Service`. The set of -contingencies a service responds to is the `PSY.Outage` supplemental -attributes attached to that service via -`add_supplemental_attribute!(sys, service, outage)`; template validation -mirrors those attachments into `service_model.outages`. Post-contingency -branch-flow constraints are added only for the monitored components -listed on each outage's `monitored_components`. - -A `RequirementTimeSeriesParameter` is optional: if no requirement time -series is configured on the service, the formulation falls back to the -per-(outage, generator) post-contingency active power expression. - -See also `SecurityConstrainedRampReserve`. +Security-constrained (G-1) contingency reserve for `PSY.OnlineReserve{PSY.ReserveUp}` and +`PSY.OfflineReserve`: reserves must be deliverable after the loss of any generator outage +they respond to. + +**Sets.** Each reserve ``s`` responds to the `PSY.Outage`s ``O_s`` attached to it with +`add_supplemental_attribute!(sys, service, outage)`. Outage ``o`` takes offline the +generators ``G_o`` associated with it and monitors the components listed in its +`monitored_components`. ``D_s`` are the contributing devices of ``s``. Outaged generators +that are not modeled are skipped with a warning. + +**Procurement.** A reserve with a requirement time series is procured as under +[`RampReserve`](@ref) without the ramp limit: awards ``r_{s,d,t}``, the requirement, +participation-fraction limits, and the reserve cost. A reserve without one is not procured +and only deploys post-contingency. + +**Deployment.** Under each outage ``o \\in O_s``, every contributor ``d \\in D_s \\setminus G_o`` +deploys ``\\delta_{s,d,o,t} \\ge 0`` ([`PostContingencyDeploymentVariable`](@ref)), capped by +its award when ``s`` is procured ([`PostContingencyDeploymentConstraint`](@ref)). A device's +deployment across reserves, ``\\Delta_{d,o,t}`` +([`PostContingencyTotalDeployment`](@ref)), keeps it within its maximum +([`PostContingencyGenerationConstraint`](@ref)). + +**Balance.** Deployment replaces the outaged generation +([`PostContingencyBalanceConstraint`](@ref)): + + - `CopperPlateNetworkModel` and PTDF networks balance the system: + ``\\sum_d \\Delta_{d,o,t} = \\sum_{g \\in G_o} p_{g,t}``. + - `AreaBalanceNetworkModel` balances each area. Every modeled area interchange carries a + deviation ``\\Delta f_{i,o,t}`` ([`PostContingencyDeviationVariable`](@ref)) that moves + deployment between areas. Without an `AreaInterchange` device model each area covers its + own outages. + +**Flow limits.** Monitored components get post-contingency flow limits at their emergency +rating (`PostContingencyFlowRateConstraint`, metas `"G1_lb"`/`"G1_ub"`): + + - PTDF networks: monitored branches carry + ``f^o_{\\ell,t} = f_{\\ell,t} + \\sum_n PTDF_{\\ell,n} \\Delta P_{n,o,t}`` + (`PostContingencyBranchFlow`, meta `"G1"`), with ``\\Delta P_{n,o,t}`` the nodal change + in injection ([`PostContingencyNodalDeployment`](@ref)). Parallel circuits and reduced + branches are limited once, on their reduced entry. + - `AreaBalanceNetworkModel`: monitored interchanges carry + ``f^o_{i,t} = f_{i,t} + \\Delta f_{i,o,t}`` + ([`PostContingencyInterchangeFlow`](@ref)). Deviations exist on every modeled + interchange and enter every area balance; only monitored interchanges are limited. + +Monitored components must be modeled (including by the branch model's `filter_function`), or +template validation fails. With `use_slacks = true` the flow limits are relaxed by +[`PostGeneratorContingencyFlowSlackUpperBound`](@ref) and +[`PostGeneratorContingencyFlowSlackLowerBound`](@ref). Flow limits are shared by every +reserve responding to an outage, so their service models must agree on `use_slacks`. + +See also [`SecurityConstrainedRampReserve`](@ref). """ 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`. +Same as [`SecurityConstrainedContingencyReserve`](@ref), except every reserve is procured: +a requirement time series is required, and spinning reserves' awards are also limited by +the contributing devices' ramp rates over the reserve time frame (`RampConstraint`), as in +[`RampReserve`](@ref). """ struct SecurityConstrainedRampReserve <: AbstractSecurityConstrainedReservesFormulation end diff --git a/src/core/variables.jl b/src/core/variables.jl index 5a85a06c..00defcea 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -846,26 +846,42 @@ struct HybridStorageSubcomponentReserveVariable{Sd <: ReserveSide} <: ################################################################################# """ -Reserve deployed by a contributing device on a security-constrained reserve. +Reserve deployed by contributing device ``d`` on security-constrained reserve ``s`` under +outage ``o``: ``\\delta_{s,d,o,t} \\ge 0``. Devices taken offline by ``o`` get none. + +Keyed on `ComponentPairKey{D, S}`, indexed `(service, device, outage, time)`. See +[`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyActivePowerReserveDeploymentVariable <: VariableType end +struct PostContingencyDeploymentVariable <: VariableType end """ -Post-contingency power exchange over an area interchange from security-constrained reserves. +Post-contingency change in flow ``\\Delta f_{i,o,t}`` over modeled area interchange ``i`` +under outage ``o``. Free. + +Indexed `(interchange, outage, time)` over every modeled interchange. See +[`SecurityConstrainedContingencyReserve`](@ref). """ -struct PostContingencyAreaInterchangeFlowDeviationVariable <: VariableType end +struct PostContingencyDeviationVariable <: VariableType end """ -Relaxes the upper bound of the post-generator-contingency flow-rate constraint of -security-constrained reserves when `use_slacks = true`. +Relaxes the upper post-contingency flow limit of security-constrained reserves when +`use_slacks = true`: ``f^o_{\\ell,t} - \\sigma^+_{\\ell,o,t} \\le R^{max}_\\ell``, +``\\sigma^+ \\ge 0``. + +A separate type from [`PostContingencyFlowActivePowerSlackUpperBound`](@ref) because variable +containers take no `meta`, and branch security-constrained models key that one by the same +branch type. """ -struct PostGeneratorContingencyFlowActivePowerSlackUpperBound <: VariableType end +struct PostGeneratorContingencyFlowSlackUpperBound <: VariableType end """ -Relaxes the lower bound of the post-generator-contingency flow-rate constraint of -security-constrained reserves when `use_slacks = true`. +Relaxes the lower post-contingency flow limit of security-constrained reserves when +`use_slacks = true`: ``f^o_{\\ell,t} + \\sigma^-_{\\ell,o,t} \\ge R^{min}_\\ell``, +``\\sigma^- \\ge 0``. + +See [`PostGeneratorContingencyFlowSlackUpperBound`](@ref). """ -struct PostGeneratorContingencyFlowActivePowerSlackLowerBound <: VariableType end +struct PostGeneratorContingencyFlowSlackLowerBound <: VariableType end const MULTI_START_VARIABLES = (HotStartVariable, WarmStartVariable, ColdStartVariable) @@ -907,11 +923,5 @@ convert_output_to_natural_units(::Type{HVDCLosses}) = true convert_output_to_natural_units(::Type{InterfaceFlowSlackUp}) = true convert_output_to_natural_units(::Type{InterfaceFlowSlackDown}) = true convert_output_to_natural_units(::Type{ActivePowerPumpVariable}) = true -convert_output_to_natural_units( - ::Type{PostContingencyActivePowerReserveDeploymentVariable}, -) = - true -convert_output_to_natural_units( - ::Type{PostContingencyAreaInterchangeFlowDeviationVariable}, -) = - true +convert_output_to_natural_units(::Type{PostContingencyDeploymentVariable}) = true +convert_output_to_natural_units(::Type{PostContingencyDeviationVariable}) = true diff --git a/src/operation/decision_model.jl b/src/operation/decision_model.jl index 724ff8d4..19f72b56 100644 --- a/src/operation/decision_model.jl +++ b/src/operation/decision_model.jl @@ -231,8 +231,9 @@ function solve!( # `power_units = :component_base` is what PSY stores internally, so the # write needs no unit conversion and no round-trip ledger — a model's # system carries one only when it was built from a document. + # TODO: re-enable before merging, once PSY.to_file is fixed. #!ispath(sys_dir) && - # PSY.to_file(sys, sys_dir; units = :component_base) + # PSY.to_file(sys, sys_dir; power_units = :component_base) end end @info "\n$(RUN_OPERATION_MODEL_TIMER)\n" diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 92f5db98..4744de21 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -109,6 +109,7 @@ function validate_template_impl!(model::IOM.AbstractOptimizationModel) delete!(template.branches, k) end _check_interface_branches(template, system, network_model) + _check_security_constrained_reserve_monitors(template, system, network_model) _check_security_constrained_three_winding_transformer(template.branches) _check_security_constrained_network(template.branches, network_model) _check_security_constrained_phase_control(template.branches, network_model) @@ -220,7 +221,7 @@ function _interface_branch_problems!( end ################################################################################# -# Security-constrained branch validation +# Security-constrained branch validation and outage population ################################################################################# function _any_component_has_branch_rating_ts( @@ -500,9 +501,45 @@ function _check_security_constrained_network( return end -################################################################################# -# Voltage control -################################################################################# +function _assert_transformer_outages( + transformer::T, + branch_models::IOM.BranchModelContainer, +) where {T <: _TRANSFORMERS} + model = get(branch_models, nameof(T), nothing) + _has_unsupported_phase(transformer, model) && throw( + IS.ConflictingInputsError( + "Phase-shifting transformers and transformers with non-zero angle may not be outages.", + ), + ) + return +end + +_assert_transformer_outages(::PSY.Device, ::IOM.BranchModelContainer) = + nothing + +# Monitored components exist; no controlled transformer outages +function _check_monitored_components( + branch_models::IOM.BranchModelContainer, + sys::PSY.System, +) + for branch_model in values(branch_models) + IOM.supports_outages(IOM.get_formulation(branch_model)) || continue + for outage_id in keys(get_outages(branch_model)) + outage = PSY.get_supplemental_attribute(sys, outage_id) + for uuid in PSY.get_monitored_components(outage) + isnothing(IS.get_component(sys, uuid)) && throw( + IS.ConflictingInputsError( + "Monitored component with UUID $uuid on outage $outage_id is not found in the system.", + ), + ) + end + for component in PSY.get_associated_components(sys, outage) + _assert_transformer_outages(component, branch_models) + end + end + end + return +end # Under ACP a VOLTAGE-control device pins the shared network VoltageMagnitude at its # regulated bus via JuMP.fix(force=true); two devices on one bus silently override @@ -847,46 +884,6 @@ function _warn_unmatched_user_outages( return end -function _assert_transformer_outages( - transformer::T, - branch_models::IOM.BranchModelContainer, -) where {T <: _TRANSFORMERS} - model = get(branch_models, nameof(T), nothing) - _has_unsupported_phase(transformer, model) && throw( - IS.ConflictingInputsError( - "Phase-shifting transformers and transformers with non-zero angle may not be outages.", - ), - ) - return -end - -_assert_transformer_outages(::PSY.Device, ::IOM.BranchModelContainer) = - nothing - -# Monitored components exist; no controlled transformer outages -function _check_monitored_components( - branch_models::IOM.BranchModelContainer, - sys::PSY.System, -) - for branch_model in values(branch_models) - IOM.supports_outages(IOM.get_formulation(branch_model)) || continue - for outage_id in keys(get_outages(branch_model)) - outage = PSY.get_supplemental_attribute(sys, outage_id) - for uuid in PSY.get_monitored_components(outage) - isnothing(IS.get_component(sys, uuid)) && throw( - IS.ConflictingInputsError( - "Monitored component with UUID $uuid on outage $outage_id is not found in the system.", - ), - ) - end - for component in PSY.get_associated_components(sys, outage) - _assert_transformer_outages(component, branch_models) - end - end - end - return -end - ################################################################################# # Outage-event discovery and validation (time-series outage events; distinct # from the security-constrained `_build_device_model_outages!` above) diff --git a/src/services_models/reserve_group.jl b/src/services_models/reserve_group.jl index 588d825d..991f68d2 100644 --- a/src/services_models/reserve_group.jl +++ b/src/services_models/reserve_group.jl @@ -224,12 +224,13 @@ function _group_member_variables( end # Award containers of every contributing device type for service type `SR`. Groups only know -# their member services, not the members' contributing device types. +# their member services, not the members' contributing device types. Keys store the service +# type with its unit parameter stripped, so match on that form. function _reserve_variables( container::OptimizationContainer, ::Type{SR}, ) where {SR <: PSY.Service} - S = get_component_type(VariableKey(ActivePowerReserveVariable, SR)) + S = IOM.canonical_component_type(SR) return [ variable for (key, variable) in IOM.get_variables(container) if IOM.get_entry_type(key) === ActivePowerReserveVariable && diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index fe2eaec0..9508c22a 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -204,35 +204,38 @@ _reserve_variable( ) where {D <: PSY.Component, SR <: PSY.Service} = get_variable(container, ActivePowerReserveVariable, IOM.ComponentPairKey{D, SR}) -# Sum the reserve provision of one service across all its contributing device types at time `t`. +# Per time step, the sum of one service's awards across all its contributing device types. function _sum_service_reserves( container::OptimizationContainer, ::Type{SR}, service_name::String, contributing_devices::AbstractDict, - t::Int, extra::Int, ) where {SR <: PSY.Service} - acc = IOM.get_hinted_aff_expr( - sum(length, values(contributing_devices); init = 0) + extra, - ) - for devices in values(contributing_devices) - _add_service_reserves!(acc, container, SR, service_name, devices, t) + n_terms = sum(length, values(contributing_devices); init = 0) + extra + acc = [IOM.get_hinted_aff_expr(n_terms) for _ in get_time_steps(container)] + for (device_type, devices) in contributing_devices + _sum_service_reserves!( + acc, + _reserve_variable(container, device_type, SR), + service_name, + devices, + ) end return acc end -function _add_service_reserves!( - acc::JuMP.AffExpr, - container::OptimizationContainer, - ::Type{SR}, +function _sum_service_reserves!( + acc::Vector{JuMP.AffExpr}, + reserve_variable::SparseAxisArray, service_name::String, devices::Vector{D}, - t::Int, -) where {SR <: PSY.Service, D <: PSY.Component} - reserve_variable = _reserve_variable(container, D, SR) - for d in devices - JuMP.add_to_expression!(acc, reserve_variable[(service_name, PSY.get_name(d), t)]) +) where {D <: PSY.Component} + for d in devices, t in eachindex(acc) + JuMP.add_to_expression!( + acc[t], + reserve_variable[(service_name, PSY.get_name(d), t)], + ) end return end @@ -250,10 +253,21 @@ function add_constraints!( # Dense container keyed `[service_name, time]`, built per type; fill this service's row. constraint = get_constraint(container, T, SR) use_slacks = get_use_slacks(model) - use_slacks && (slack_vars = get_variable(container, ReserveRequirementSlack, SR)) requirement = _get_requirement(service) jump_model = get_jump_model(container) - extra = use_slacks ? 1 : 0 + resource_expression = _sum_service_reserves( + container, + SR, + service_name, + contributing_devices, + use_slacks ? 1 : 0, + ) + if use_slacks + slack_vars = get_variable(container, ReserveRequirementSlack, SR) + for t in time_steps + JuMP.add_to_expression!(resource_expression[t], slack_vars[service_name, t]) + end + end # A static reserve gets a scalar requirement RHS; a time-varying one scales it by an # attached requirement series (resolved by the model-configured name). @@ -263,20 +277,10 @@ function add_constraints!( get_parameter(container, RequirementTimeSeriesParameter, SR) param = get_parameter_column_refs(param_container, service_name) for t in time_steps - resource_expression = - _sum_service_reserves(container, SR, service_name, - contributing_devices, - t, extra) - use_slacks && - JuMP.add_to_expression!( - resource_expression, - slack_vars[service_name, t], - ) - constraint[service_name, t] = - JuMP.@constraint( - jump_model, - resource_expression >= param[t] * requirement - ) + constraint[service_name, t] = JuMP.@constraint( + jump_model, + resource_expression[t] >= param[t] * requirement + ) end else ts_vector = IOM.get_time_series( @@ -286,31 +290,16 @@ function add_constraints!( interval = get_interval(get_settings(container)), ) for t in time_steps - resource_expression = - _sum_service_reserves(container, SR, service_name, - contributing_devices, - t, extra) - use_slacks && - JuMP.add_to_expression!( - resource_expression, - slack_vars[service_name, t], - ) constraint[service_name, t] = JuMP.@constraint( jump_model, - resource_expression >= ts_vector[t] * requirement + resource_expression[t] >= ts_vector[t] * requirement ) end end else for t in time_steps - resource_expression = - _sum_service_reserves(container, SR, service_name, contributing_devices, - t, - extra) - use_slacks && - JuMP.add_to_expression!(resource_expression, slack_vars[service_name, t]) constraint[service_name, t] = - JuMP.@constraint(jump_model, resource_expression >= requirement) + JuMP.@constraint(jump_model, resource_expression[t] >= requirement) end end return @@ -320,9 +309,9 @@ function add_constraints!( container::OptimizationContainer, T::Type{ParticipationFractionConstraint}, service::SR, - contributing_devices::AbstractDict, + contributing_devices::Vector{D}, model::ServiceModel{SR, V}, -) where {SR <: PSY.AbstractReserve, V <: AbstractReservesFormulation} +) where {SR <: PSY.AbstractReserve, V <: AbstractReservesFormulation, D <: PSY.Component} max_participation_factor = PSY.get_max_participation_factor(service) if max_participation_factor >= 1.0 @@ -332,12 +321,17 @@ function add_constraints!( time_steps = get_time_steps(container) service_name = PSY.get_name(service) # Sparse constraint container keyed `(service_name, device_name, time)`. - cons = lazy_container_addition!(container, T, SR, - [service_name], - [PSY.get_name(d) for devices in values(contributing_devices) for d in devices], - time_steps; + cons = lazy_container_addition!( + container, + T, + IOM.ComponentPairKey{D, SR}, + String[], + String[], + Int[]; sparse = true, ) + var_r = _reserve_variable(container, D, SR) + jump_model = get_jump_model(container) requirement = _get_requirement(service) cap = requirement * max_participation_factor # Static reserve: constant participation cap. Time-varying reserve: scale by the requirement @@ -347,7 +341,13 @@ function add_constraints!( param_container = get_parameter(container, RequirementTimeSeriesParameter, SR) param = get_parameter_column_refs(param_container, service_name) - limits = [cap * param[t] for t in time_steps] + for t in time_steps, d in contributing_devices + name = PSY.get_name(d) + cons[(service_name, name, t)] = JuMP.@constraint( + jump_model, + var_r[(service_name, name, t)] <= cap * param[t] + ) + end else ts_vector = IOM.get_time_series( container, @@ -355,35 +355,24 @@ function add_constraints!( get_time_series_names(model)[RequirementTimeSeriesParameter]; interval = get_interval(get_settings(container)), ) - limits = [cap * ts_vector[t] for t in time_steps] + for t in time_steps, d in contributing_devices + name = PSY.get_name(d) + cons[(service_name, name, t)] = JuMP.@constraint( + jump_model, + var_r[(service_name, name, t)] <= cap * ts_vector[t] + ) + end end else - limits = fill(cap, length(time_steps)) - end - for devices in values(contributing_devices) - _add_participation_fraction_constraints!( - cons, container, SR, service_name, devices, limits) + for t in time_steps, d in contributing_devices + name = PSY.get_name(d) + cons[(service_name, name, t)] = JuMP.@constraint( + jump_model, + var_r[(service_name, name, t)] <= cap + ) + end end - return -end -function _add_participation_fraction_constraints!( - cons::SparseAxisArray, - container::OptimizationContainer, - ::Type{SR}, - service_name::String, - devices::Vector{D}, - limits::Vector, -) where {SR <: PSY.AbstractReserve, D <: PSY.Component} - var_r = _reserve_variable(container, D, SR) - jump_model = get_jump_model(container) - for t in get_time_steps(container), d in devices - name = PSY.get_name(d) - cons[(service_name, name, t)] = JuMP.@constraint( - jump_model, - var_r[(service_name, name, t)] <= limits[t] - ) - end return end @@ -395,10 +384,11 @@ function add_to_objective_function!( # Devices that submitted a reserve OFFER are priced by their offer curve; the rest keep the # flat DEFAULT_RESERVE_COST. offered = add_reserve_offer_costs!(container, service, model) - add_reserves_proportional_cost!( - container, ActivePowerReserveVariable, service, T, - get_contributing_devices_map(model, PSY.get_name(service)); - skip_devices = offered) + for devices in values(get_contributing_devices_map(model, PSY.get_name(service))) + add_reserves_proportional_cost!( + container, ActivePowerReserveVariable, service, T, devices; + skip_devices = offered) + end return end @@ -416,19 +406,12 @@ function add_constraints!( requirement_variable = get_variable(container, ServiceRequirementVariable, SR) jump_model = get_jump_model(container) + resource_expression = + _sum_service_reserves(container, SR, service_name, contributing_devices, 0) for t in time_steps - resource_expression = - _sum_service_reserves( - container, - SR, - service_name, - contributing_devices, - t, - 0, - ) constraint[service_name, t] = JuMP.@constraint( jump_model, - resource_expression >= requirement_variable[service_name, t] + resource_expression[t] >= requirement_variable[service_name, t] ) end @@ -470,47 +453,33 @@ function add_constraints!( container::OptimizationContainer, T::Type{RampConstraint}, service::SR, - contributing_devices::AbstractDict, + contributing_devices::Vector{D}, ::ServiceModel{SR, V}, ) where { SR <: Union{PSY.Reserve{PSY.ReserveUp}, PSY.Reserve{PSY.ReserveDown}}, V <: AbstractReservesFormulation, + D <: PSY.Component, } + ramp_devices = _get_ramp_constraint_contributing_devices(service, contributing_devices) service_name = PSY.get_name(service) - ramp_devices = [ - _get_ramp_constraint_contributing_devices(service, devices) for - devices in values(contributing_devices) - ] - if all(isempty, ramp_devices) - @warn "Data doesn't contain contributing devices with ramp limits for service $service_name, consider adjusting your formulation" + if isempty(ramp_devices) + @warn "Contributing $(D) devices on service $service_name have no binding ramp limits; no ramp constraints are added for them." return end + jump_model = get_jump_model(container) time_steps = get_time_steps(container) - cons = lazy_container_addition!(container, T, - SR, - [service_name], - [PSY.get_name(d) for devices in ramp_devices for d in devices], - time_steps; + time_frame = PSY.get_time_frame(service) + variable = _reserve_variable(container, D, SR) + cons = lazy_container_addition!( + container, + T, + IOM.ComponentPairKey{D, SR}, + String[], + String[], + Int[]; sparse = true, ) - for devices in ramp_devices - isempty(devices) && continue - _add_reserve_ramp_constraints!(cons, container, service, devices) - end - return -end - -function _add_reserve_ramp_constraints!( - cons::SparseAxisArray, - container::OptimizationContainer, - service::SR, - devices::Vector{D}, -) where {SR <: PSY.Reserve, D <: PSY.Component} - variable = _reserve_variable(container, D, SR) - jump_model = get_jump_model(container) - service_name = PSY.get_name(service) - time_frame = PSY.get_time_frame(service) - for d in devices, t in get_time_steps(container) + for d in ramp_devices, t in time_steps name = PSY.get_name(d) limit = _directional_ramp_limit(PSY.get_ramp_limits(d, PSY.SU / u"minute"), SR) cons[(service_name, name, t)] = JuMP.@constraint( @@ -525,9 +494,9 @@ function add_constraints!( container::OptimizationContainer, T::Type{ReservePowerConstraint}, service::SR, - contributing_devices::AbstractDict, + contributing_devices::Vector{D}, ::ServiceModel{SR, V}, -) where {SR <: PSY.OfflineReserve, V <: AbstractReservesFormulation} +) where {SR <: PSY.OfflineReserve, V <: AbstractReservesFormulation, D <: PSY.Component} time_steps = get_time_steps(container) resolution = get_resolution(container) if resolution > Dates.Minute(1) @@ -537,83 +506,37 @@ function add_constraints!( minutes_per_period = Dates.value(Dates.Second(resolution)) / 60 end service_name = PSY.get_name(service) - cons = lazy_container_addition!(container, T, - SR, - [service_name], - [PSY.get_name(d) for devices in values(contributing_devices) for d in devices], - time_steps; + cons = lazy_container_addition!( + container, + T, + IOM.ComponentPairKey{D, SR}, + String[], + String[], + Int[]; sparse = true, ) - reserve_response_time = PSY.get_time_frame(service) - for devices in values(contributing_devices) - _add_reserve_power_constraints!( - cons, - container, - SR, - devices, - service_name, - reserve_response_time, - minutes_per_period, - ) - end - return -end - -function _add_reserve_power_constraints!( - cons::SparseAxisArray, - container::OptimizationContainer, - ::Type{SR}, - devices::Vector{D}, - service_name::String, - reserve_response_time, - minutes_per_period, -) where {SR <: PSY.OfflineReserve, D <: PSY.Component} var_r = _reserve_variable(container, D, SR) varstatus = get_variable(container, OnVariable, D) + reserve_response_time = PSY.get_time_frame(service) jump_model = get_jump_model(container) - time_steps = get_time_steps(container) - for d in devices - _add_reserve_power_constraint_device!( - cons, - var_r, - varstatus, - d, - service_name, - reserve_response_time, - minutes_per_period, - jump_model, - time_steps, - ) - end - return -end - -function _add_reserve_power_constraint_device!( - cons, - var_r, - varstatus, - d::D, - service_name::String, - reserve_response_time, - minutes_per_period, - jump_model, - time_steps, -) where {D <: PSY.Component} - name = PSY.get_name(d) - startup_time = PSY.get_time_limits(d).up - ramp_limits = _get_ramp_limits(d) - if reserve_response_time > startup_time - reserve_limit = - PSY.get_active_power_limits(d, PSY.SU).min + - (reserve_response_time - startup_time) * minutes_per_period * ramp_limits.up - else - reserve_limit = 0.0 - end - for t in time_steps - cons[(service_name, name, t)] = JuMP.@constraint( - jump_model, - var_r[(service_name, name, t)] <= (1 - varstatus[name, t]) * reserve_limit - ) + for d in contributing_devices + name = PSY.get_name(d) + startup_time = PSY.get_time_limits(d).up + ramp_limits = _get_ramp_limits(d) + if reserve_response_time > startup_time + reserve_limit = + PSY.get_active_power_limits(d, PSY.SU).min + + (reserve_response_time - startup_time) * minutes_per_period * + ramp_limits.up + else + reserve_limit = 0.0 + end + for t in time_steps + cons[(service_name, name, t)] = JuMP.@constraint( + jump_model, + var_r[(service_name, name, t)] <= (1 - varstatus[name, t]) * reserve_limit + ) + end end return end @@ -739,36 +662,24 @@ function process_stepwise_cost_reserve_parameters!( return end +# `skip_devices` are priced by their offer curve in `add_reserve_offer_costs!` instead. function add_reserves_proportional_cost!( container::OptimizationContainer, ::Type{U}, service::T, ::Type{V}, - contributing_devices::AbstractDict; + contributing_devices::Vector{D}; skip_devices = Set{Tuple{DataType, String}}(), ) where { T <: PSY.AbstractReserve, U <: ActivePowerReserveVariable, V <: AbstractReservesFormulation, + D <: PSY.Component, } - for devices in values(contributing_devices) - _add_reserves_proportional_cost!(container, U, service, devices, skip_devices) - end - return -end - -# `skip_devices` are priced by their offer curve in `add_reserve_offer_costs!` instead. -function _add_reserves_proportional_cost!( - container::OptimizationContainer, - ::Type{U}, - service::T, - devices::Vector{D}, - skip_devices::Set{Tuple{DataType, String}}, -) where {T <: PSY.AbstractReserve, U <: ActivePowerReserveVariable, D <: PSY.Component} service_name = PSY.get_name(service) reserve_variable = get_variable(container, U, IOM.ComponentPairKey{D, T}) cost = DEFAULT_RESERVE_COST / get_model_base_power(container) - for d in devices + for d in contributing_devices name = PSY.get_name(d) (D, name) in skip_devices && continue for t in get_time_steps(container) diff --git a/src/services_models/security_constrained_injectors.jl b/src/services_models/security_constrained_injectors.jl index d8411130..3bab9e67 100644 --- a/src/services_models/security_constrained_injectors.jl +++ b/src/services_models/security_constrained_injectors.jl @@ -5,10 +5,7 @@ const _G1_META = "G1" _validate_reserve_formulation(::ServiceModel) = false _validate_reserve_formulation( - ::ServiceModel{ - <:_SECURITY_CONSTRAINED_RESERVE, - <:AbstractSecurityConstrainedReservesFormulation, - }, + ::ServiceModel{<:_RESERVE_UP, <:AbstractSecurityConstrainedReservesFormulation}, ) = true _validate_reserve_formulation( ::ServiceModel{<:PSY.AbstractReserve, <:AbstractSecurityConstrainedReservesFormulation}, @@ -23,34 +20,9 @@ _valid_component_type(::PSY.ACTransmission, ::NetworkModel{<:AbstractPTDFNetwork _valid_component_type(::PSY.AreaInterchange, ::NetworkModel{AreaBalanceNetworkModel}) = true _valid_component_type(::PSY.Component, ::NetworkModel) = false -function _outaged_generators(sys::PSY.System, outage::PSY.Outage) - outaged = _PER_TYPE() - for generator in - PSY.get_associated_components(sys, outage; component_type = PSY.Generator) - push!(get!(Set{String}, outaged, typeof(generator)), PSY.get_name(generator)) - end - return outaged -end - -function _monitored_components( - sys::PSY.System, - outage::PSY.Outage, - network_model::NetworkModel, -) - monitored = _PER_TYPE() - for component_uuid in PSY.get_monitored_components(outage) - component = IS.get_component(sys, component_uuid) - _valid_component_type(component, network_model) || continue - PSY.get_available(component) || continue - typeof(component) in network_model.modeled_branch_types || continue - push!(get!(Set{String}, monitored, typeof(component)), PSY.get_name(component)) - end - return monitored -end - """ -Per outage attached to a security-constrained reserve: the outaged generators, the available, -modeled monitored components, and whether its post-contingency flow limits are relaxed. +Outages attached to security-constrained reserves, by UUID, and whether each one's +post-contingency flow limits are relaxed. Flow constraints are shared by every reserve responding to an outage, so the service models carrying that outage must agree on `use_slacks`. @@ -58,9 +30,8 @@ carrying that outage must agree on `use_slacks`. function _security_constrained_outages( sys::PSY.System, services_template::ServicesModelContainer, - network_model::NetworkModel, ) - outaged_generators, monitored_components = _OUTAGE_MAP(), _OUTAGE_MAP() + outages = Dict{Int, PSY.Outage}() use_slacks = Dict{Int, Bool}() for model in values(services_template) _validate_reserve_formulation(model) || continue @@ -77,53 +48,220 @@ function _security_constrained_outages( ), ) end - haskey(outaged_generators, uuid) && continue - outaged_generators[uuid] = _outaged_generators(sys, outage) - monitored_components[uuid] = - _monitored_components(sys, outage, network_model) + outages[uuid] = outage end end end - return outaged_generators, monitored_components, use_slacks + return outages, use_slacks end -# Each service deploys its own contributors under every outage it responds to; the per-device -# sum across services is what the outage-level constraints see. -function _add_post_contingency_deployment!( +""" +Reject monitored components of security-constrained reserve outages that the template does +not model, including those excluded by a branch model's `filter_function`. Post-contingency +flows are built only on modeled components, so monitored components must be a subset of the +modeled ones. +""" +function _check_security_constrained_reserve_monitors( + template::PowerOperationsProblemTemplate, + sys::PSY.System, + network_model::NetworkModel, +) + problems = String[] + checked = Set{Int}() + for model in values(get_service_models(template)) + _validate_reserve_formulation(model) || continue + for service in get_available_components(model, sys), + outage in PSY.get_supplemental_attributes(PSY.Outage, service) + + uuid = IS.get_id(outage) + uuid in checked && continue + push!(checked, uuid) + for component_uuid in PSY.get_monitored_components(outage) + component = IS.get_component(sys, component_uuid) + _valid_component_type(component, network_model) || continue + PSY.get_available(component) || continue + branch_model = get_model(template, typeof(component)) + name = PSY.get_name(component) + if isnothing(branch_model) || + !any(c -> PSY.get_name(c) == name, get_device_cache(branch_model)) + push!( + problems, + "Outage $uuid monitors $(typeof(component)) $name, which the \ + template does not model (no branch model, or excluded by its \ + filter_function).", + ) + end + end + end + end + isempty(problems) || throw(IS.ConflictingInputsError(join(problems, "\n"))) + return +end + +# Outaged generators whose power the model can remove; the rest are skipped with a warning. +function _outaged_generators( container::OptimizationContainer, sys::PSY.System, - service::R, - model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, -) where {R <: _SECURITY_CONSTRAINED_RESERVE} - per_type = get_contributing_devices_map(model, PSY.get_name(service)) - for outage in PSY.get_supplemental_attributes(PSY.Outage, service) - uuid = IS.get_id(outage) - outaged = _outaged_generators(sys, outage) - for (device_type, devices) in per_type - _add_post_contingency_deployment!( - container, - service, - devices, - uuid, - get(Set{String}, outaged, device_type), - ) + outage::PSY.Outage, +) + outaged = _PER_TYPE() + for generator in + PSY.get_associated_components(sys, outage; component_type = PSY.Generator) + T = typeof(generator) + name = PSY.get_name(generator) + if has_container_key(container, ActivePowerVariable, T) && + name in axes(get_variable(container, ActivePowerVariable, T), 1) + push!(get!(Set{String}, outaged, T), name) + else + @warn "Generator $name ($T) outaged by outage $(IS.get_id(outage)) is not \ + modeled; it is left out of the post-contingency balance." _group = + LOG_GROUP_SERVICE_CONSTUCTORS end end + return outaged +end + +function _monitored_components( + sys::PSY.System, + outages::Dict{Int, PSY.Outage}, + network_model::NetworkModel, +) + monitored_components = _OUTAGE_MAP() + for (uuid, outage) in outages + monitored = _PER_TYPE() + for component_uuid in PSY.get_monitored_components(outage) + component = IS.get_component(sys, component_uuid) + _valid_component_type(component, network_model) || continue + PSY.get_available(component) || continue + typeof(component) in network_model.modeled_branch_types || continue + push!(get!(Set{String}, monitored, typeof(component)), PSY.get_name(component)) + end + monitored_components[uuid] = monitored + end + return monitored_components +end + +# Parallel circuits share one reduced entry, so each entry is constrained once. +function _flow_entries( + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, + ::Type{T}, + names::Set{String}, +) where {T <: PSY.ACTransmission} + reduction_name_map = + PNM.get_component_to_reduction_name_map(get_branch_catalog(network_model), T) + return Set{String}(reduction_name_map[name] for name in names) +end + +_flow_entries( + ::NetworkModel{AreaBalanceNetworkModel}, + ::Type{PSY.AreaInterchange}, + names::Set{String}, +) = names + +function _post_contingency_flow_limits( + ::PSY.System, + network_model::NetworkModel{<:AbstractPTDFNetworkModel}, + ::Type{T}, + entry_name::String, +) where {T <: PSY.ACTransmission} + catalog = get_branch_catalog(network_model) + arc = PNM.get_name_to_arc_map(catalog, T)[entry_name] + return _emergency_flow_limits(PNM.get_reduction_entry(catalog, arc)) +end + +function _post_contingency_flow_limits( + sys::PSY.System, + ::NetworkModel{AreaBalanceNetworkModel}, + ::Type{PSY.AreaInterchange}, + name::String, +) + limits = PSY.get_flow_limits(PSY.get_component(PSY.AreaInterchange, sys, name), PSY.SU) + return (min = -limits.to_from, max = limits.from_to) +end + +################################## Construction ########################################### + +function _construct_post_contingency!( + container::OptimizationContainer, + sys::PSY.System, + ::ArgumentConstructStage, + services_template::ServicesModelContainer, + network_model::NetworkModel, +) + outages, use_slacks = _security_constrained_outages(sys, services_template) + isempty(outages) && return + _add_post_contingency_flow_slacks!( + container, + _monitored_components(sys, outages, network_model), + use_slacks, + network_model, + ) return end +# Every outage gets a balance row per network region. Modeled interchanges carry a deviation +# variable per outage, balanced across areas; monitored interchanges (a subset of the modeled +# ones) and monitored branches additionally get post-contingency flow limits. +function _construct_post_contingency!( + container::OptimizationContainer, + sys::PSY.System, + ::ModelConstructStage, + services_template::ServicesModelContainer, + network_model::NetworkModel, +) + outages, use_slacks = _security_constrained_outages(sys, services_template) + isempty(outages) && return + outaged_generators = _OUTAGE_MAP( + uuid => _outaged_generators(container, sys, outage) for (uuid, outage) in outages + ) + monitored_components = _monitored_components(sys, outages, network_model) + uuids = sort!(collect(keys(outages))) + # AreaInterchange flow variables are created by the branch constructors, which run after the + # services argument stage. + _add_post_contingency_deviation_variables!(container, uuids, network_model) + _add_post_contingency_locational_deployment!( + container, + sys, + outaged_generators, + network_model, + ) + _add_post_contingency_flow!(container, monitored_components, network_model) + _add_post_contingency_balance_constraints!( + container, + sys, + outaged_generators, + uuids, + network_model, + ) + _add_post_contingency_generation_constraints!(container, sys) + _add_post_contingency_flow_constraints!( + container, + sys, + monitored_components, + use_slacks, + network_model, + ) + _add_post_contingency_slack_costs!(container, monitored_components) + return +end + +################################## Deployment ############################################# + +# Each service deploys its own contributors under every outage it responds to, except the +# contributors that outage takes offline; the per-device sum across services is what the +# outage-level constraints see. function _add_post_contingency_deployment!( container::OptimizationContainer, + sys::PSY.System, service::R, devices::Vector{D}, - uuid::Int, - outaged::Set{String}, ) where {R <: PSY.Service, D <: PSY.Component} jump_model = get_jump_model(container) service_name = PSY.get_name(service) + # Lazy: every service of type `R` that `D` contributes to shares this container. deployment = lazy_container_addition!( container, - PostContingencyActivePowerReserveDeploymentVariable, + PostContingencyDeploymentVariable, IOM.ComponentPairKey{D, R}, String[], String[], @@ -131,70 +269,59 @@ function _add_post_contingency_deployment!( Int[]; sparse = true, ) + # Lazy: shared by every service `D` contributes to. Keyed `(device name, outage, time)`. total = lazy_container_addition!( container, - PostContingencyTotalReserveDeployment, + PostContingencyTotalDeployment, D, String[], Int[], Int[]; sparse = true, ) - for d in devices - name = PSY.get_name(d) - name in outaged && continue - for t in get_time_steps(container) - var = - deployment[service_name, name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostContingencyActivePowerReserveDeploymentVariable_$(D)_$(R)_{$(service_name), $(name), $(uuid), $(t)}", - lower_bound = 0.0, + for outage in PSY.get_supplemental_attributes(PSY.Outage, service) + uuid = IS.get_id(outage) + outaged = Set{String}( + PSY.get_name(c) for + c in PSY.get_associated_components(sys, outage; component_type = D) + ) + for d in devices + name = PSY.get_name(d) + name in outaged && continue + for t in get_time_steps(container) + var = + deployment[service_name, name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostContingencyDeploymentVariable_$(D)_$(R)_{$(service_name), $(name), $(uuid), $(t)}", + lower_bound = 0.0, + ) + JuMP.add_to_expression!( + get!(JuMP.AffExpr, total.data, (name, uuid, t)), + var, ) - JuMP.add_to_expression!(get!(JuMP.AffExpr, total.data, (name, uuid, t)), var) + end end end return end -_total_deployments(container::OptimizationContainer) = [ - (get_component_type(key), expr) for - (key, expr) in IOM.get_expressions(container) if - IOM.get_entry_type(key) === PostContingencyTotalReserveDeployment -] - -# Deployment can only draw on procured reserve. Unprocured reserves are limited by -# generation headroom alone. -function _constrain_post_contingency_reserve!( - container::OptimizationContainer, - service::R, - model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, -) where {R <: _SECURITY_CONSTRAINED_RESERVE} - for device_type in keys(get_contributing_devices_map(model, PSY.get_name(service))) - _constrain_post_contingency_reserve!(container, service, device_type) - end - return -end - -function _constrain_post_contingency_reserve!( +# Deployment can only draw on procured reserve. Unprocured reserves are limited by generation +# headroom alone. +function add_constraints!( container::OptimizationContainer, + ::Type{PostContingencyDeploymentConstraint}, service::R, - ::Type{D}, -) where {R <: PSY.Service, D <: PSY.Component} + devices::Vector{D}, + ::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, +) where {R <: _RESERVE_UP, D <: PSY.Component} key = IOM.ComponentPairKey{D, R} - has_container_key( - container, - PostContingencyActivePowerReserveDeploymentVariable, - key, - ) || - return jump_model = get_jump_model(container) service_name = PSY.get_name(service) - deployment = - get_variable(container, PostContingencyActivePowerReserveDeploymentVariable, key) - award = get_variable(container, ActivePowerReserveVariable, key) + deployment = get_variable(container, PostContingencyDeploymentVariable, key) + award = _reserve_variable(container, D, R) cons = lazy_container_addition!( container, - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, + PostContingencyDeploymentConstraint, key, String[], String[], @@ -202,49 +329,66 @@ function _constrain_post_contingency_reserve!( Int[]; sparse = true, ) - for ((s, name, uuid, t), r) in deployment.data - s == service_name || continue - cons[s, name, uuid, t] = + uuids = [IS.get_id(o) for o in PSY.get_supplemental_attributes(PSY.Outage, service)] + for d in devices, uuid in uuids, t in get_time_steps(container) + name = PSY.get_name(d) + # Devices the outage takes offline have no deployment. + r = get(deployment.data, (service_name, name, uuid, t), nothing) + isnothing(r) && continue + cons[service_name, name, uuid, t] = JuMP.@constraint(jump_model, r <= award[service_name, name, t]) end return end -function _create_post_contingency_interchange_variables!( +# Device types are only known from the container keys: services register a total-deployment +# container per contributing device type. +_total_deployments(container::OptimizationContainer) = [ + (get_component_type(key), expr) for + (key, expr) in IOM.get_expressions(container) if + IOM.get_entry_type(key) === PostContingencyTotalDeployment +] + +################################## Variables ############################################## + +function _add_post_contingency_deviation_variables!( container::OptimizationContainer, - outaged_generators::_OUTAGE_MAP, + uuids::Vector{Int}, ::NetworkModel{AreaBalanceNetworkModel}, ) - has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) || return + if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) + @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = + LOG_GROUP_SERVICE_CONSTUCTORS + return + end flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) jump_model = get_jump_model(container) time_steps = get_time_steps(container) + names = axes(flow, 1) var = add_variable_container!( container, - PostContingencyAreaInterchangeFlowDeviationVariable, + PostContingencyDeviationVariable, PSY.AreaInterchange, - String[], - Int[], - Int[]; - sparse = true, + names, + uuids, + time_steps, ) - for uuid in keys(outaged_generators), name in axes(flow, 1), t in time_steps + for name in names, uuid in uuids, t in time_steps var[name, uuid, t] = JuMP.@variable( jump_model, - base_name = "PostContingencyAreaInterchangeFlowDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", - start = 0.0, + base_name = "PostContingencyDeviationVariable_AreaInterchange_{$(name), $(uuid), $(t)}", ) end return end -_create_post_contingency_interchange_variables!( +_add_post_contingency_deviation_variables!( ::OptimizationContainer, - ::_OUTAGE_MAP, + ::Vector{Int}, ::NetworkModel, ) = nothing -function _create_post_contingency_flow_slacks!( +function _add_post_contingency_flow_slacks!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, use_slacks::Dict{Int, Bool}, @@ -255,9 +399,11 @@ function _create_post_contingency_flow_slacks!( for (uuid, per_type) in monitored_components use_slacks[uuid] || continue for (component_type, names) in per_type + # Lazy: slack containers are per component type and shared across outages. + # Keyed `(flow entry, outage, time)`, sparse since outages monitor different entries. slack_ub = lazy_container_addition!( container, - PostGeneratorContingencyFlowActivePowerSlackUpperBound, + PostGeneratorContingencyFlowSlackUpperBound, component_type, String[], Int[], @@ -266,7 +412,7 @@ function _create_post_contingency_flow_slacks!( ) slack_lb = lazy_container_addition!( container, - PostGeneratorContingencyFlowActivePowerSlackLowerBound, + PostGeneratorContingencyFlowSlackLowerBound, component_type, String[], Int[], @@ -276,21 +422,15 @@ function _create_post_contingency_flow_slacks!( for entry_name in _flow_entries(network_model, component_type, names), t in time_steps - ub = - slack_ub[entry_name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostGeneratorContingencyFlowActivePowerSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", - lower_bound = 0.0, - ) - lb = - slack_lb[entry_name, uuid, t] = JuMP.@variable( - jump_model, - base_name = "PostGeneratorContingencyFlowActivePowerSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", - lower_bound = 0.0, - ) - add_to_objective_invariant_expression!( - container, - (ub + lb) * CONSTRAINT_VIOLATION_SLACK_COST, + slack_ub[entry_name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostGeneratorContingencyFlowSlackUpperBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + lower_bound = 0.0, + ) + slack_lb[entry_name, uuid, t] = JuMP.@variable( + jump_model, + base_name = "PostGeneratorContingencyFlowSlackLowerBound_$(component_type)_{$(entry_name), $(uuid), $(t)}", + lower_bound = 0.0, ) end end @@ -298,20 +438,41 @@ function _create_post_contingency_flow_slacks!( return end -_create_post_contingency_flow_slacks!( +_add_post_contingency_flow_slacks!( ::OptimizationContainer, ::_OUTAGE_MAP, ::Dict{Int, Bool}, ::NetworkModel, ) = nothing -_deployment_expression_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = - PostContingencyNodalActivePowerDeployment -_deployment_expression_type(::NetworkModel{AreaBalanceNetworkModel}) = - PostContingencyAreaActivePowerDeployment +################################## Expressions ############################################ -_deployment_component_type(::NetworkModel{<:AbstractPTDFNetworkModel}) = PSY.ACBus -_deployment_component_type(::NetworkModel{AreaBalanceNetworkModel}) = PSY.Area +# Keyed `(bus number or area name, outage, time)`, sparse since an outage only touches the +# locations of its deployments and outaged generators. +_add_locational_deployment_container!( + container::OptimizationContainer, + ::NetworkModel{<:AbstractPTDFNetworkModel}, +) = add_expression_container!( + container, + PostContingencyNodalDeployment, + PSY.ACBus, + String[], + Int[], + Int[]; + sparse = true, +) +_add_locational_deployment_container!( + container::OptimizationContainer, + ::NetworkModel{AreaBalanceNetworkModel}, +) = add_expression_container!( + container, + PostContingencyAreaDeployment, + PSY.Area, + String[], + Int[], + Int[]; + sparse = true, +) _location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel}) = string( PNM.get_mapped_bus_number(get_network_reduction(network_model), PSY.get_bus(component)), @@ -319,25 +480,16 @@ _location_key(component, network_model::NetworkModel{<:AbstractPTDFNetworkModel} _location_key(component, ::NetworkModel{AreaBalanceNetworkModel}) = PSY.get_name(PSY.get_area(PSY.get_bus(component))) -# [contributing device reserve] minus [outaged generator power] per bus or area -function _build_post_contingency_locational_power!( +# [contributing device deployment] minus [outaged generator power] per bus or area +function _add_post_contingency_locational_deployment!( container::OptimizationContainer, sys::PSY.System, outaged_generators::_OUTAGE_MAP, network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) - totals = _total_deployments(container) - expr = lazy_container_addition!( - container, - _deployment_expression_type(network_model), - _deployment_component_type(network_model), - String[], - Int[], - Int[]; - sparse = true, - ) + expr = _add_locational_deployment_container!(container, network_model) - for (device_type, total) in totals + for (device_type, total) in _total_deployments(container) locations = Dict{String, String}() for ((name, uuid, t), deployed) in total.data key = get!(locations, name) do @@ -368,7 +520,7 @@ function _build_post_contingency_locational_power!( return end -_build_post_contingency_locational_power!( +_add_post_contingency_locational_deployment!( ::OptimizationContainer, ::PSY.System, ::_OUTAGE_MAP, @@ -377,7 +529,7 @@ _build_post_contingency_locational_power!( # Monitored names resolve to their reduction entry, the row the pre-contingency # `PTDFBranchFlow` and the post-contingency expression are keyed by. -function _build_post_contingency_flow!( +function _add_post_contingency_flow!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, network_model::NetworkModel{<:AbstractPTDFNetworkModel}, @@ -387,16 +539,19 @@ function _build_post_contingency_flow!( ptdf = get_network_matrix(network_model) bus_axis = PNM.get_bus_axis(ptdf) - nodal = get_expression(container, PostContingencyNodalActivePowerDeployment, PSY.ACBus) + nodal = get_expression(container, PostContingencyNodalDeployment, PSY.ACBus) buses = Dict{Int, Set{String}}() for (bus, uuid, t) in keys(nodal.data) push!(get!(Set{String}, buses, uuid), bus) end - # Arc distribution factors per reduced entry, keyed by the nodal bus key. - dfs = Dict{Tuple{DataType, String}, Dict{String, Float64}}() + # Per reduced entry, its PTDF row keyed by the nodal bus key, dropping entries below + # PTDF_ZERO_TOL. + nonzero_factors = Dict{Tuple{DataType, String}, Dict{String, Float64}}() for (uuid, per_type) in monitored_components + outage_buses = get(buses, uuid, Set{String}()) for (line_type, names) in per_type + # Keyed `(flow entry, outage, time)`, sparse since outages monitor different entries. expr = lazy_container_addition!( container, PostContingencyBranchFlow, @@ -410,7 +565,7 @@ function _build_post_contingency_flow!( pre_flow = get_expression(container, PTDFBranchFlow, line_type) arc_map = PNM.get_name_to_arc_map(catalog, line_type) for entry_name in _flow_entries(network_model, line_type, names) - df = get!(dfs, (line_type, entry_name)) do + factors = get!(nonzero_factors, (line_type, entry_name)) do ptdf_col = ptdf[arc_map[entry_name], :] Dict{String, Float64}( string(bus_axis[i]) => ptdf_col[i] for @@ -421,12 +576,12 @@ function _build_post_contingency_flow!( ex = expr[entry_name, uuid, t] = IOM.get_hinted_aff_expr( length(JuMP.linear_terms(pre_flow[entry_name, t])) + - length(buses[uuid]), + length(outage_buses), ) JuMP.add_to_expression!(ex, pre_flow[entry_name, t]) - for bus in buses[uuid] - haskey(df, bus) || continue - JuMP.add_to_expression!(ex, df[bus], nodal[bus, uuid, t]) + for bus in outage_buses + haskey(factors, bus) || continue + JuMP.add_to_expression!(ex, factors[bus], nodal[bus, uuid, t]) end end end @@ -435,25 +590,21 @@ function _build_post_contingency_flow!( return end -function _build_post_contingency_flow!( +function _add_post_contingency_flow!( container::OptimizationContainer, monitored_components::_OUTAGE_MAP, ::NetworkModel{AreaBalanceNetworkModel}, ) - if !has_container_key(container, FlowActivePowerVariable, PSY.AreaInterchange) - @warn "An AreaBalanceNetworkModel with security-constrained reserves needs PSY.AreaInterchange(s) and DeviceModel{PSY.AreaInterchange} for reserve deployment to cross area boundaries. Otherwise, each area must cover its own outages." _group = - LOG_GROUP_SERVICE_CONSTUCTORS maxlog = 1 - return - end has_container_key( container, - PostContingencyAreaInterchangeFlowDeviationVariable, + PostContingencyDeviationVariable, PSY.AreaInterchange, ) || return time_steps = get_time_steps(container) + # Keyed `(interchange, outage, time)`, sparse since outages monitor different interchanges. expr = add_expression_container!( container, - PostContingencyAreaInterchangeFlow, + PostContingencyInterchangeFlow, PSY.AreaInterchange, String[], Int[], @@ -464,42 +615,36 @@ function _build_post_contingency_flow!( flow = get_variable(container, FlowActivePowerVariable, PSY.AreaInterchange) deviation = get_variable( container, - PostContingencyAreaInterchangeFlowDeviationVariable, + PostContingencyDeviationVariable, PSY.AreaInterchange, ) - modeled = Set{String}(axes(flow, 1)) for (uuid, per_type) in monitored_components - for name in get(per_type, PSY.AreaInterchange, Set{String}()) - name in modeled || throw( - IS.ConflictingInputsError( - "Monitored AreaInterchange $(name) is not modeled.", - ), - ) - for t in time_steps - ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) - JuMP.add_to_expression!(ex, flow[name, t]) - JuMP.add_to_expression!(ex, deviation[name, uuid, t]) - end + for name in get(per_type, PSY.AreaInterchange, Set{String}()), t in time_steps + ex = expr[name, uuid, t] = JuMP.AffExpr(0.0) + JuMP.add_to_expression!(ex, flow[name, t]) + JuMP.add_to_expression!(ex, deviation[name, uuid, t]) end end return end -_build_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkModel) = +_add_post_contingency_flow!(::OptimizationContainer, ::_OUTAGE_MAP, ::NetworkModel) = nothing -function _constrain_post_contingency_balance!( +################################## Constraints ############################################ + +function _add_post_contingency_balance_constraints!( container::OptimizationContainer, ::PSY.System, outaged_generators::_OUTAGE_MAP, + uuids::Vector{Int}, ::NetworkModel, ) - uuids = collect(keys(outaged_generators)) time_steps = get_time_steps(container) jump_model = get_jump_model(container) cons = add_constraints_container!( container, - PostContingencyGenerationBalanceConstraint, + PostContingencyBalanceConstraint, PSY.System, uuids, time_steps, @@ -525,32 +670,31 @@ function _constrain_post_contingency_balance!( return end -function _constrain_post_contingency_balance!( +function _add_post_contingency_balance_constraints!( container::OptimizationContainer, sys::PSY.System, - outaged_generators::_OUTAGE_MAP, + ::_OUTAGE_MAP, + uuids::Vector{Int}, ::NetworkModel{AreaBalanceNetworkModel}, ) - uuids = sort!(collect(keys(outaged_generators))) time_steps = get_time_steps(container) jump_model = get_jump_model(container) - # Area name => (sign, interchange name) + # Area name => (sign, interchange name). Without an AreaInterchange model there are no + # deviations, so each area covers its own outages. interchanges = Dict{String, Vector{Tuple{Float64, String}}}() if has_container_key( container, - PostContingencyAreaInterchangeFlowDeviationVariable, + PostContingencyDeviationVariable, PSY.AreaInterchange, ) deviation = get_variable( container, - PostContingencyAreaInterchangeFlowDeviationVariable, + PostContingencyDeviationVariable, PSY.AreaInterchange, ) - modeled = Set{String}(k[1] for k in keys(deviation.data)) - for interchange in PSY.get_components(PSY.AreaInterchange, sys) - name = PSY.get_name(interchange) - name in modeled || continue + for name in axes(deviation, 1) + interchange = PSY.get_component(PSY.AreaInterchange, sys, name) from_area = PSY.get_name(PSY.get_from_area(interchange)) to_area = PSY.get_name(PSY.get_to_area(interchange)) push!( @@ -560,13 +704,12 @@ function _constrain_post_contingency_balance!( push!(get!(Vector{Tuple{Float64, String}}, interchanges, to_area), (1.0, name)) end end - deployment = - get_expression(container, PostContingencyAreaActivePowerDeployment, PSY.Area) + deployment = get_expression(container, PostContingencyAreaDeployment, PSY.Area) area_names = PSY.get_name.(PSY.get_components(PSY.Area, sys)) cons = add_constraints_container!( container, - PostContingencyGenerationBalanceConstraint, + PostContingencyBalanceConstraint, PSY.Area, area_names, uuids, @@ -587,17 +730,17 @@ function _constrain_post_contingency_balance!( return end -# Redundant for devices bounded by `_constrain_post_contingency_reserve!`, since their awards +# Redundant for devices bounded by `PostContingencyDeploymentConstraint`, since their awards # already sit in the device headroom; kept unconditional so no device can deploy past pmax. -function _constrain_post_contingency_generation!( +function _add_post_contingency_generation_constraints!( container::OptimizationContainer, sys::PSY.System, ) jump_model = get_jump_model(container) for (device_type, total) in _total_deployments(container) - cons = lazy_container_addition!( + cons = add_constraints_container!( container, - PostContingencyActivePowerGenerationLimitsConstraint, + PostContingencyGenerationConstraint, device_type, String[], Int[], @@ -620,55 +763,19 @@ end _post_contingency_flow_expression(::NetworkModel{<:AbstractPTDFNetworkModel}) = PostContingencyBranchFlow _post_contingency_flow_expression(::NetworkModel{AreaBalanceNetworkModel}) = - PostContingencyAreaInterchangeFlow - -# Parallel circuits share one reduced entry, so each entry is constrained once. -function _flow_entries( - network_model::NetworkModel{<:AbstractPTDFNetworkModel}, - ::Type{T}, - names::Set{String}, -) where {T <: PSY.ACTransmission} - reduction_name_map = - PNM.get_component_to_reduction_name_map(get_branch_catalog(network_model), T) - return Set{String}(reduction_name_map[name] for name in names) -end - -_flow_entries( - ::NetworkModel{AreaBalanceNetworkModel}, - ::Type{PSY.AreaInterchange}, - names::Set{String}, -) = names - -function _post_contingency_flow_limits( - ::PSY.System, - network_model::NetworkModel{<:AbstractPTDFNetworkModel}, - ::Type{T}, - entry_name::String, -) where {T <: PSY.ACTransmission} - catalog = get_branch_catalog(network_model) - arc = PNM.get_name_to_arc_map(catalog, T)[entry_name] - return _emergency_flow_limits(PNM.get_reduction_entry(catalog, arc)) -end - -function _post_contingency_flow_limits( - sys::PSY.System, - ::NetworkModel{AreaBalanceNetworkModel}, - ::Type{PSY.AreaInterchange}, - name::String, -) - limits = PSY.get_flow_limits(PSY.get_component(PSY.AreaInterchange, sys, name), PSY.SU) - return (min = -limits.to_from, max = limits.from_to) -end + PostContingencyInterchangeFlow -function _constrain_post_contingency_flow!( +function _add_post_contingency_flow_constraints!( container::OptimizationContainer, sys::PSY.System, monitored_components::_OUTAGE_MAP, + use_slacks::Dict{Int, Bool}, network_model::NetworkModel{<:Union{AbstractPTDFNetworkModel, AreaBalanceNetworkModel}}, ) jump_model = get_jump_model(container) time_steps = get_time_steps(container) for (uuid, per_type) in monitored_components + slacked = use_slacks[uuid] for (component_type, names) in per_type flow = get_expression( container, @@ -696,20 +803,15 @@ function _constrain_post_contingency_flow!( sparse = true, meta = "$(_G1_META)_ub", ) - has_slacks = has_container_key( - container, - PostGeneratorContingencyFlowActivePowerSlackUpperBound, - component_type, - ) - if has_slacks + if slacked slack_ub = get_variable( container, - PostGeneratorContingencyFlowActivePowerSlackUpperBound, + PostGeneratorContingencyFlowSlackUpperBound, component_type, ) slack_lb = get_variable( container, - PostGeneratorContingencyFlowActivePowerSlackLowerBound, + PostGeneratorContingencyFlowSlackLowerBound, component_type, ) end @@ -722,7 +824,7 @@ function _constrain_post_contingency_flow!( ) for t in time_steps f = flow[entry_name, uuid, t] - if has_slacks && haskey(slack_ub.data, (entry_name, uuid, t)) + if slacked cons_ub[entry_name, uuid, t] = JuMP.@constraint( jump_model, f - slack_ub[entry_name, uuid, t] <= lims.max @@ -744,9 +846,35 @@ function _constrain_post_contingency_flow!( return end -_constrain_post_contingency_flow!( +_add_post_contingency_flow_constraints!( ::OptimizationContainer, ::PSY.System, ::_OUTAGE_MAP, + ::Dict{Int, Bool}, ::NetworkModel, ) = nothing + +function _add_post_contingency_slack_costs!( + container::OptimizationContainer, + monitored_components::_OUTAGE_MAP, +) + component_types = Set{DataType}() + for per_type in values(monitored_components) + union!(component_types, keys(per_type)) + end + for component_type in component_types, + slack_type in ( + PostGeneratorContingencyFlowSlackUpperBound, + PostGeneratorContingencyFlowSlackLowerBound, + ) + + has_container_key(container, slack_type, component_type) || continue + for slack in values(get_variable(container, slack_type, component_type).data) + add_to_objective_invariant_expression!( + container, + slack * CONSTRAINT_VIOLATION_SLACK_COST, + ) + end + end + return +end diff --git a/src/services_models/services_constructor.jl b/src/services_models/services_constructor.jl index b60724fd..d5983389 100644 --- a/src/services_models/services_constructor.jl +++ b/src/services_models/services_constructor.jl @@ -140,14 +140,7 @@ function construct_services!( ) end - _, monitored_components, use_slacks = - _security_constrained_outages(sys, services_template, network_model) - _create_post_contingency_flow_slacks!( - container, - monitored_components, - use_slacks, - network_model, - ) + _construct_post_contingency!(container, sys, stage, services_template, network_model) return end @@ -191,29 +184,7 @@ function construct_services!( ) end - outaged_generators, monitored_components, _ = - _security_constrained_outages(sys, services_template, network_model) - # Tie flow variables are built with the branches, after the services argument stage. - _create_post_contingency_interchange_variables!( - container, - outaged_generators, - network_model, - ) - _build_post_contingency_locational_power!( - container, - sys, - outaged_generators, - network_model, - ) - _build_post_contingency_flow!(container, monitored_components, network_model) - _constrain_post_contingency_balance!( - container, - sys, - outaged_generators, - network_model, - ) - _constrain_post_contingency_generation!(container, sys) - _constrain_post_contingency_flow!(container, sys, monitored_components, network_model) + _construct_post_contingency!(container, sys, stage, services_template, network_model) return end @@ -295,13 +266,15 @@ function construct_service!( contributing_devices, model, ) - add_constraints!( - container, - ParticipationFractionConstraint, - service, - contributing_devices, - model, - ) + for devices in values(contributing_devices) + add_constraints!( + container, + ParticipationFractionConstraint, + service, + devices, + model, + ) + end add_to_objective_function!(container, service, model) else # Supply-only: no requirement of its own (it may serve a GroupReserve). Price any @@ -621,11 +594,11 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ArgumentConstructStage, - model::ServiceModel{SR, F}, + model::ServiceModel{SR, RampReserve}, devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {SR <: PSY.Reserve, F <: RampReserve} +) where {SR <: PSY.Reserve} services = _services_with_contributors(model, sys) isempty(services) && return # Only services carrying a requirement series get the parameter (a curve-only ORDC of the @@ -657,11 +630,11 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ModelConstructStage, - model::ServiceModel{SR, F}, + model::ServiceModel{SR, RampReserve}, devices_template::Dict{Symbol, DeviceModel}, incompatible_device_types::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {SR <: PSY.Reserve, F <: RampReserve} +) where {SR <: PSY.Reserve} services = _services_with_contributors(model, sys) isempty(services) && return service_names = PSY.get_name.(services) @@ -683,14 +656,16 @@ function construct_service!( contributing_devices, model, ) - add_constraints!(container, RampConstraint, service, contributing_devices, model) - add_constraints!( - container, - ParticipationFractionConstraint, - service, - contributing_devices, - model, - ) + for devices in values(contributing_devices) + add_constraints!(container, RampConstraint, service, devices, model) + add_constraints!( + container, + ParticipationFractionConstraint, + service, + devices, + model, + ) + end add_to_objective_function!(container, service, model) add_feedforward_constraints!(container, model, service) end @@ -757,20 +732,16 @@ function construct_service!( contributing_devices, model, ) - add_constraints!( - container, - ReservePowerConstraint, - service, - contributing_devices, - model, - ) - add_constraints!( - container, - ParticipationFractionConstraint, - service, - contributing_devices, - model, - ) + for devices in values(contributing_devices) + add_constraints!(container, ReservePowerConstraint, service, devices, model) + add_constraints!( + container, + ParticipationFractionConstraint, + service, + devices, + model, + ) + end add_to_objective_function!(container, service, model) add_feedforward_constraints!(container, model, service) end @@ -1123,19 +1094,18 @@ function construct_service!( return end -const _SECURITY_CONSTRAINED_RESERVE = - Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve} +const _RESERVE_UP = Union{PSY.OnlineReserve{PSY.ReserveUp}, PSY.OfflineReserve} -_requires_requirement_ts(::Type{SecurityConstrainedContingencyReserve}) = false -_requires_requirement_ts(::Type{SecurityConstrainedRampReserve}) = true +_is_ramp_formulation(::Type{SecurityConstrainedContingencyReserve}) = false +_is_ramp_formulation(::Type{SecurityConstrainedRampReserve}) = true # Whether `service` is procured pre-contingency (reserve variable, requirement, ramp, # participation, objective); otherwise it only deploys post-contingency. -_service_requires_requirement_ts( +_is_procured( model::ServiceModel{<:PSY.AbstractReserve, F}, service::PSY.AbstractReserve, ) where {F <: AbstractSecurityConstrainedReservesFormulation} = - _requires_requirement_ts(F) || _has_ts_requirement(model, service) + _is_ramp_formulation(F) || _has_ts_requirement(model, service) function construct_service!( container::OptimizationContainer, @@ -1145,22 +1115,21 @@ function construct_service!( devices_template::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {R <: _SECURITY_CONSTRAINED_RESERVE} +) where {R <: _RESERVE_UP} services = _services_with_contributors(model, sys) isempty(services) && return ts_services = [s for s in services if _has_ts_requirement(model, s)] isempty(ts_services) || add_parameters!(container, RequirementTimeSeriesParameter, ts_services, model) - requirement_services = - [s for s in services if _service_requires_requirement_ts(model, s)] - isempty(requirement_services) || add_service_variables!( + procured = [s for s in services if _is_procured(model, s)] + isempty(procured) || add_service_variables!( container, ActivePowerReserveVariable, - requirement_services, + procured, model, RampReserve, ) - for service in requirement_services + for service in procured add_to_expression!( container, ActivePowerReserveVariable, @@ -1170,7 +1139,9 @@ function construct_service!( ) end for service in services - _add_post_contingency_deployment!(container, sys, service, model) + for devices in values(get_contributing_devices_map(model, PSY.get_name(service))) + _add_post_contingency_deployment!(container, sys, service, devices) + end add_feedforward_arguments!(container, model, service) end return @@ -1180,23 +1151,22 @@ function construct_service!( container::OptimizationContainer, sys::PSY.System, ::ModelConstructStage, - model::ServiceModel{R, <:AbstractSecurityConstrainedReservesFormulation}, + model::ServiceModel{R, F}, ::Dict{Symbol, DeviceModel}, ::Set{<:DataType}, ::NetworkModel{<:AbstractNetworkModel}, -) where {R <: _SECURITY_CONSTRAINED_RESERVE} +) where {R <: _RESERVE_UP, F <: AbstractSecurityConstrainedReservesFormulation} services = _services_with_contributors(model, sys) isempty(services) && return for service in services add_feedforward_constraints!(container, model, service) end - requirement_services = - [s for s in services if _service_requires_requirement_ts(model, s)] - if isempty(requirement_services) + procured = [s for s in services if _is_procured(model, s)] + if isempty(procured) add_constraint_dual!(container, sys, model) return end - service_names = PSY.get_name.(requirement_services) + service_names = PSY.get_name.(procured) add_constraints_container!( container, RequirementConstraint, @@ -1205,7 +1175,7 @@ function construct_service!( get_time_steps(container), ) get_use_slacks(model) && add_reserve_slacks!(container, R, service_names) - for service in requirement_services + for service in procured contributing_devices = get_contributing_devices_map(model, PSY.get_name(service)) add_constraints!( container, @@ -1214,40 +1184,31 @@ function construct_service!( contributing_devices, model, ) - _add_security_constrained_ramp_constraints!( - container, - service, - contributing_devices, - model, - ) - add_constraints!( - container, - ParticipationFractionConstraint, - service, - contributing_devices, - model, - ) - _constrain_post_contingency_reserve!(container, service, model) + for devices in values(contributing_devices) + # Ramp limits bind spinning reserves only. + _is_ramp_formulation(F) && R <: PSY.Reserve && + add_constraints!(container, RampConstraint, service, devices, model) + add_constraints!( + container, + ParticipationFractionConstraint, + service, + devices, + model, + ) + add_constraints!( + container, + PostContingencyDeploymentConstraint, + service, + devices, + model, + ) + end add_to_objective_function!(container, service, model) end add_constraint_dual!(container, sys, model) return end -_add_security_constrained_ramp_constraints!( - container::OptimizationContainer, - service::PSY.Reserve, - contributing_devices::AbstractDict, - model::ServiceModel{<:PSY.Reserve, SecurityConstrainedRampReserve}, -) = add_constraints!(container, RampConstraint, service, contributing_devices, model) - -_add_security_constrained_ramp_constraints!( - ::OptimizationContainer, - ::PSY.AbstractReserve, - ::AbstractDict, - ::ServiceModel, -) = nothing - construct_service!( ::OptimizationContainer, ::PSY.System, diff --git a/test/Project.toml b/test/Project.toml index 15656bb9..2b934631 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -7,10 +7,8 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" InfraStore = "6aee0376-896a-4a59-96ff-12f221c99746" -InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" InfrastructureOptimizationModels = "bed98974-b02a-5e2f-9ee0-a103f5c45069" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" -InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" @@ -21,9 +19,11 @@ MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" +InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" +InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" PowerDynamicsOpenAPIModels = "044a0b22-31f8-4ef6-8282-c9a61b3013f6" PowerFlowFileParser = "bed98974-b02e-5e2f-9ee0-a103f5c450dd" -#PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" +PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" PowerInvestmentsOpenAPIModels = "33cb4396-f4d9-4f59-8585-787ebb56cb1b" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" @@ -42,23 +42,31 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +# InfraStore is not registered yet, so it cannot resolve from a registry. Pkg +# only honors the [sources] section of the root project, so this environment +# needs its own entry (and InfraStore must be a direct dependency for the source +# to apply). Drop the dep + entry once InfraStore is registered. [sources] -InfrastructureCoreOpenAPIModels = {rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -InfrastructureOptimizationModels = {path = "/Users/acostare/Sienna/InfrastructureOptimizationModels.jl/service-containers/"} -InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} -InfrastructureTimeSeriesOpenAPIModels = {rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerCoreOpenAPIModels = {rev = "main", subdir = "PowerCoreOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerDynamicsOpenAPIModels = {rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerFlowFileParser = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git"} -#PowerFlows = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} -PowerInvestmentsOpenAPIModels = {rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} -PowerOpenAPIModels = {rev = "main", subdir = "PowerOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} PowerOperationsModels = {path = ".."} -PowerOperationsOpenAPIModels = {rev = "main", subdir = "PowerOperationsOpenAPIModels.jl", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git"} -PowerSystemCaseBuilder = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git"} -PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl.git"} -PowerTableDataParser = {rev = "psy6", url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git"} +InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git", rev = "IS4"} +# TEMPORARY: repin to `rev = "main"` once IOM #171 (ac/service-containers) merges. +InfrastructureOptimizationModels = {rev = "ac/service-containers", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} +PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} +# Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. +PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} +PowerFlows = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} +PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} +# TEMPORARY: pinned to the arc-keyed-catalog branch (PNM #356 + #357) until they merge to +# psy6. Revert to `rev = "psy6"` once they land. +PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} +PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerTableDataParser = {url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git", rev = "psy6"} [compat] HiGHS = "1" diff --git a/test/includes.jl b/test/includes.jl index 58fd79fb..3942f465 100644 --- a/test/includes.jl +++ b/test/includes.jl @@ -7,7 +7,7 @@ using InfrastructureSystems import InfrastructureSystems: TableFormat using PowerNetworkMatrices import PowerSystemCaseBuilder: PSITestSystems -#using PowerFlows +using PowerFlows using DataFramesMeta # Test Packages @@ -34,7 +34,7 @@ import LinearAlgebra const PSY = PowerSystems const POM = PowerOperationsModels const IOM = InfrastructureOptimizationModels -#const PFS = PowerFlows +const PFS = PowerFlows const PSB = PowerSystemCaseBuilder const PNM = PowerNetworkMatrices const ISOPT = InfrastructureSystems.Optimization diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index dac0f09a..71c074c2 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -1,2170 +1,527 @@ -# Shared skeleton for the SC reserve deliverability test cases: attach a -# `GeometricDistributionForcedOutage` to `device` and every service in -# `services` in one shot, instead of repeating the construct-and-attach block -# per testset. Device-side (line/transformer) callers pass `PSY.Service[]`. -function attach_geometric_outage!( - sys::PSY.System, - device::PSY.Device, - services::Vector{<:PSY.Service}; - monitored_components::Vector{<:PSY.Component} = collect( - PSY.get_components(PSY.ACTransmission, sys), - ), - mean_time_to_recovery::Float64 = 10.0, - outage_transition_probability::Float64 = 0.9999, -) - transition_data = GeometricDistributionForcedOutage(; - mean_time_to_recovery = mean_time_to_recovery, - outage_transition_probability = outage_transition_probability, - monitored_components = monitored_components, - ) - add_supplemental_attribute!(sys, device, transition_data) - for service in services - add_supplemental_attribute!(sys, service, transition_data) - end - return transition_data -end - -# Same as `attach_geometric_outage!` but for the `PSY.FixedForcedOutage` -# fixture used by the deterministic (non-probabilistic) regression tests. -function attach_fixed_outage!( - sys::PSY.System, - device::PSY.Device, - services::Vector{<:PSY.Service}; - monitored_components::Vector{<:PSY.Component}, -) +# G-1 security-constrained reserves (`SecurityConstrainedContingencyReserve`). +# +# One fixture on `two_area_pjm_DA` covers the cases the formulation must keep apart: +# - an OnlineReserve and an OfflineReserve both named "Reserve1_2"; the online one is procured +# (requirement series), the offline one only deploys; +# - a RenewableDispatch named "Brighton_2", like a thermal, contributing to the online reserve; +# - three outages overlapping on "Alta_1": A (online only), B (both reserves), C (offline only); +# - a parallel line and a parallel interchange, monitored or modeled-but-unmonitored. +# The main test builds and solves it under copper plate, PTDF, and area balance, and checks +# the solution against quantities recomputed from the system data. + +const _G1_RESERVE = "Reserve1_2" +const _G1_TOL = 1e-4 + +function _attach_outage!(sys, generators, services, monitored) outage = PSY.FixedForcedOutage(; outage_status = 1.0, - monitored_components = monitored_components, - ) - add_supplemental_attribute!(sys, device, outage) - for service in services - add_supplemental_attribute!(sys, service, outage) - end - return outage -end - -# Callers compare against a SECOND, independent VirtualMODF/VirtualPTDF. Those -# columns are solved through multithreaded BLAS, whose reduction order is not deterministic, -# so two instances agree only to machine precision (~1e-16), never bit-for-bit. Exact -# `JuMP.isequal_canonical` therefore fails or passes depending on the BLAS thread count of -# the machine. Compare coefficients to `PTDF_ZERO_TOL` instead: a variable absent from one -# side counts as a zero coefficient, so a term sitting on the sparsity threshold cannot flip -# the result, while a genuinely wrong term (sign flip, wrong arc) differs by orders of -# magnitude more and still fails. -function aff_exprs_approx_equal( - actual::JuMP.AffExpr, - expected::JuMP.AffExpr; - atol::Float64 = IOM.PTDF_ZERO_TOL, -) - if !isapprox(actual.constant, expected.constant; atol = atol) - return false - end - for v in union(keys(actual.terms), keys(expected.terms)) - a = get(actual.terms, v, 0.0) - e = get(expected.terms, v, 0.0) - if !isapprox(a, e; atol = atol) - return false - end - end - return true -end -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{Int, Vector{Float64}}() - for outage in associated_outages - outage_name = IS.get_id(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, - service_name::String, - associated_outages::Vector{PSY.UnplannedOutage}, - contributing_devices::Union{ - IS.FlattenIteratorWrapper{<:PSY.Generator}, - Vector{<:PSY.Generator}, - }; - col_name::String = "name", -) - required_variables = variables[var_name] - total_variable_dict = Dict{Int, Vector{Float64}}() - for outage in associated_outages - outage_name = IS.get_id(outage) - outage_power_v = Vector{Float64}() - for device in contributing_devices - # Sparse (service, device, outage, t) outputs are flattened to a single - # "service__device__outage" column. - column = IOM.encode_tuple_to_column(( - service_name, - PSY.get_name(device), - string(outage_name), - )) - current_v = - filter(x -> x[col_name] == column, required_variables)[!, "value"] - # The outaged generator has no deployment rows. - isempty(current_v) && continue - if isempty(outage_power_v) - 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::OptimizationProblemOutputs, - service::PSY.AbstractReserve; - tolerance::Float64 = 1e-3, - require_positive_outage::Bool = true, -) - variablesdict = read_variables(res) - associated_outages = - collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, service)) - isempty(associated_outages) && - error("service $(PSY.get_name(service)) has no attached outages") - 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) - reserve_dict = get_reserve_total_power_by_step_dict( - variablesdict, - IOM.encode_key_as_string( - IOM.VariableKey( - PostContingencyActivePowerReserveDeploymentVariable, - IOM.ComponentPairKey{PSY.ThermalStandard, typeof(service)}, - ), - ), - PSY.get_name(service), - associated_outages, - contributing_devices; - col_name = "name", - ) - for outage in associated_outages - outage_name = IS.get_id(outage) - # Guard against a vacuous `0 == 0` pass: if the outaged unit never - # dispatches, both sides of the identity are trivially zero and the - # check below verifies nothing. `require_positive_outage = false` - # opts a testset out where zero outaged dispatch is expected. - if require_positive_outage - @test maximum(outage_dict[outage_name]) > tolerance - end - 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 - -function compare_outage_power_and_deployed_reserves( - sys::PSY.System, - res::OptimizationProblemOutputs, - reserve_names::Vector{String}; - tolerance::Float64 = 1e-3, - require_positive_outage::Bool = true, -) - for reserve_name in reserve_names - reserve = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - compare_outage_power_and_deployed_reserves( - sys, - res, - reserve; - tolerance = tolerance, - require_positive_outage = require_positive_outage, - ) - end - return nothing -end - -# Shared tail for the SC reserve deliverability testsets: build, check -# constraint keys, MOI counts, objective function type, solve status/value, -# and (when `reserve_names` is non-empty) that deployed reserves match the -# outaged power. -function run_sc_reserve_case!( - ps_model::DecisionModel, - sys::PSY.System, - constraint_keys::Vector{<:IOM.ConstraintKey}, - moi_counts::Vector{Int}, - has_binaries::Bool, - objfunc::DataType, - obj_value::Float64, - reserve_names::Vector{String}; - obj_tol::Float64 = 10000.0, - require_positive_outage::Bool = true, -) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - psi_constraint_test(ps_model, constraint_keys) - moi_tests(ps_model, moi_counts..., has_binaries) - psi_checkobjfun_test(ps_model, objfunc) - psi_checksolve_test( - ps_model, - [MOI.OPTIMAL, MOI.ALMOST_OPTIMAL], - obj_value, - obj_tol, - ) - if isempty(reserve_names) - return nothing - end - res = OptimizationProblemOutputs(ps_model) - compare_outage_power_and_deployed_reserves( - sys, - res, - reserve_names; - require_positive_outage = require_positive_outage, - ) - return nothing -end - -# Shared zone-balance verification for the `AreaBalanceNetworkModel` testsets: -# the monitored "1_2" tie stays within its flow limits, and each area's -# generation plus load nets to zero against the tie flow. -function verify_zone_balance!( - results::OptimizationProblemOutputs; - flow_tol::Float64 = 1e-6, -) - interarea_flow = read_variable( - results, - "FlowActivePowerVariable__AreaInterchange"; - table_format = TableFormat.WIDE, - ) - @test all(interarea_flow[!, "1_2"] .<= 150 + flow_tol) - @test all(interarea_flow[!, "1_2"] .>= -150 - flow_tol) - - 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, - ), - ) - return nothing -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] - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - if add_parallel_line - l4 = get_component(Line, sys, "4") - add_equivalent_ac_transmission_with_parallel_circuits!(sys, l4, PSY.Line) - end - constraint_keys = [ - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "lb", - ), - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "ub", - ), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - gen = get_component(ThermalStandard, sys, "Solitude") - set_ramp_limits!( - gen, - (up = 0.4 * PSY.CU / u"minute", down = 0.4 * PSY.CU / u"minute"), - ) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - component = get_component(ThermalStandard, sys, "Alta") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [576, 0, 888, 480, 48], - false, - GAEVF, - 329000.0, - ["Reserve1"], - ) - end -end - -# Exercises the per-service line-scoping path in -# `_monitored_components_by_modeled_type` and the downstream -# `PostContingencyFlowRateConstraint` build for `PTDFNetworkModel` 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 PTDFNetworkModel with monitored line subset" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - monitored_line_names = ["1", "2"] - constraint_keys = [ - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "lb", - ), - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "ub", - ), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - # Counts are smaller than the all-lines baseline `[576, 0, 888, 480, 48]` - # because only the monitored subset contributes - # `PostContingencyFlowRateConstraint` rows per outage step. - gen = get_component(ThermalStandard, sys, "Solitude") - set_ramp_limits!(gen, (up = 0.4 * PSY.CU / u"minute", down = 0.4 * PSY.CU / u"minute")) #Increase ramp limits to make the problem feasible - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - monitored_subset = [get_component(Line, sys, n) for n in monitored_line_names] - component = get_component(ThermalStandard, sys, "Alta") - attach_geometric_outage!( - sys, - component, - [reserve_up]; - monitored_components = monitored_subset, - ) - - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [576, 0, 792, 384, 48], - false, - GAEVF, - 329000.0, - ["Reserve1"], - ) -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] - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - - if add_parallel_line - l4 = get_component(Line, sys, "4") - add_equivalent_ac_transmission_with_parallel_circuits!(sys, l4, PSY.Line) - end - constraint_keys = [ - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "lb", - ), - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "ub", - ), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - component = get_component(ThermalStandard, sys, "Alta") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [456, 0, 600, 456, 48], - false, - GAEVF, - 329000.0, - ["Reserve1"], - ) - end -end - -@testset "G-n with contingency reserves deliverability constraints including responding reserves only up, NO requirement time series, 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 - sys = c_sys5 - constraint_keys = [ - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "lb", - ), - IOM.ConstraintKey( - ActivePowerVariableLimitsConstraint, - PSY.ThermalStandard, - "ub", - ), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerGenerationLimitsConstraint, - PSY.ThermalStandard, - ), - ] - gen = get_component(ThermalStandard, sys, "Solitude") - set_ramp_limits!( - gen, - (up = 0.4 * PSY.CU / u"minute", down = 0.4 * PSY.CU / u"minute"), - ) #Increase ramp limits to make the problem feasible - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - remove_time_series!( - sys, - Deterministic, - reserve_up, - "requirement", - ) - component = get_component(ThermalStandard, sys, "Alta") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [336, 0, 504, 432, 48], - false, - GAEVF, - 329000.0, - ["Reserve1"], - ) - 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 - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - - l4 = get_component(Line, sys, "4") - add_equivalent_ac_transmission_with_parallel_circuits!( - sys, - l4, - PSY.Line, - PSY.MonitoredLine, - ) - remove_component!(sys, l4) - - component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = PowerOperationsProblemTemplate(NetworkModel(PTDFNetworkModel)) - 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, TwoWindingTransformer, StaticBranch) - set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT -end -@testset "Test SecurityConstrainedContingencyReserve with different BranchFormulations" begin - for line_formulation in [StaticBranch, StaticBranchUnbounded, StaticBranchBounds] - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - l4 = get_component(Line, sys, "4") - add_equivalent_ac_transmission_with_parallel_circuits!( - sys, - l4, - PSY.Line, - PSY.MonitoredLine, - ) - remove_component!(sys, l4) - - component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = PowerOperationsProblemTemplate(NetworkModel(PTDFNetworkModel)) - 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, TwoWindingTransformer, StaticBranch) - set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - constraints = ps_model.internal.container.constraints - flow_rate_cons = constraints[IOM.ConstraintKey( - PostContingencyFlowRateConstraint, - PSY.Line, - "G1_lb", - )] - @test length(flow_rate_cons) == 1 * 5 * 24 - end -end - -@testset "Test if G-n with Contingency reserve deliverability constraints builds when there is a device without set_device_model!()" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - - l4 = get_component(Line, sys, "4") - add_equivalent_ac_transmission_with_parallel_circuits!( - sys, - l4, - PSY.Line, - PSY.MonitoredLine, - ) - remove_component!(sys, l4) - - component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = PowerOperationsProblemTemplate(NetworkModel(PTDFNetworkModel)) - 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, TwoWindingTransformer, StaticBranch) - set_device_model!(template, TwoTerminalGenericHVDCLine, HVDCTwoTerminalLossless) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT -end - -@testset "G-n with Ramp reserve deliverability constraints UC allowing 2 reserve products to respond" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - component = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - reserve_up2 = get_component(OnlineReserve{ReserveUp}, sys, "Reserve11") - attach_geometric_outage!(sys, component, [reserve_up, reserve_up2]) - - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - - set_device_model!( - template, - ThermalStandard, - ThermalStandardUnitCommitment, - ) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [1032, 0, 1344, 480, 168], - true, - GAEVF, - 254242.0, - String[], - ) -end - -@testset "G-n with Ramp reserve deliverability constraints with AreaPTDFNetworkModel w/wo Reserve Slacks" begin - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - components_outages_names, reserve_names = - (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) - - for reserve_slack in [false, true] - sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - transform_single_time_series!(sys, Hour(24), Hour(1)) - - for (component_name, reserve_name) in - zip(components_outages_names, reserve_names) - component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - attach_geometric_outage!(sys, component, [reserve_up]) - end - - template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve; - use_slacks = reserve_slack, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - if reserve_slack - moi_counts = [2568, 0, 2112, 1296, 120] - else - moi_counts = [1176, 0, 2112, 1296, 120] - end - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - moi_counts, - false, - GAEVF, - 497000.0, - reserve_names, - ) - end -end - -# Exercises the per-service line-scoping path in -# `_monitored_components_by_modeled_type` for the `AreaPTDFNetworkModel` -# 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 AreaPTDFNetworkModel and monitored line subset" begin - monitored_line_names_per_service = (["1_1", "2_1"], ["1_2", "2_2"]) - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - # Counts are smaller than the all-lines baseline `[1176, 0, 2112, 1296, 120]` - # because each service monitors only two AC lines instead of all 13. - components_outages_names, reserve_names = - (["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)) - - 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] - component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - attach_geometric_outage!( - sys, - component, - [reserve_up]; - monitored_components = monitored_subset, - ) - end - - template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [1176, 0, 1584, 768, 120], - false, - GAEVF, - 497000.0, - reserve_names, - ) -end - -@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFNetworkModel, reserves only up, reserve requirement" begin - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - components_outages_names, reserve_names = - (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) - - for reserve_slack in [false, true] - sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - transform_single_time_series!(sys, Hour(24), Hour(1)) - for (component_name, reserve_name) in zip(components_outages_names, reserve_names) - component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - attach_geometric_outage!(sys, component, [reserve_up]) - end - - template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve; - use_slacks = reserve_slack, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - if reserve_slack - moi_counts = [2280, 0, 1536, 1248, 120] - else - moi_counts = [936, 0, 1536, 1248, 120] - end - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - moi_counts, - false, - GAEVF, - 497000.0, - reserve_names, - ) - end -end - -@testset "G-n with Contingency reserve deliverability constraints with AreaPTDFNetworkModel, reserves only up, NO reserve requirement" begin - sys = PSB.build_system(PSISystems, "two_area_pjm_DA") - transform_single_time_series!(sys, Hour(24), Hour(1)) - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "lb"), - IOM.ConstraintKey(FlowRateConstraint, PSY.Line, "ub"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.Line, "G1_ub"), - IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - #IOM.ConstraintKey(NetworkFlowConstraint, PSY.Line), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerGenerationLimitsConstraint, - PSY.ThermalStandard, - ), - ] - components_outages_names, reserve_names = - (["Alta_1", "Alta_2"], ["Reserve1_1", "Reserve1_2"]) - - 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) - component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - attach_geometric_outage!(sys, component, [reserve_up]) - end - - template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [456, 0, 1344, 1152, 120], - false, - GAEVF, - 497000.0, - reserve_names, - ) -end - -@testset "G-n with Ramp reserve deliverability constraints with CopperPlateNetworkModel" begin - sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - transform_single_time_series!(sys, Hour(24), Hour(1)) - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - 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) - component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - attach_geometric_outage!(sys, component, [reserve_up]) - end - - template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlateNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [1152, 0, 1200, 384, 72], - false, - GAEVF, - 497494.48, - reserve_names, - ) -end - -@testset "G-n with Contingency reserve deliverability constraints with CopperPlateNetworkModel with Reserve Requirement" begin - sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - transform_single_time_series!(sys, Hour(24), Hour(1)) - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - ] - 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) - component = get_component(ThermalStandard, sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, reserve_name) - attach_geometric_outage!(sys, component, [reserve_up]) - end - - template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlateNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [912, 0, 624, 336, 72], - false, - GAEVF, - 482055.92, - reserve_names, - ) -end - -@testset "G-n with Contingency reserve deliverability constraints with CopperPlateNetworkModel with NO Reserve Requirement" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.System), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), - IOM.ConstraintKey( - PostContingencyActivePowerGenerationLimitsConstraint, - PSY.ThermalStandard, - ), - ] - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - remove_time_series!( - sys, - Deterministic, - reserve_up, - "requirement", - ) - component = get_component(ThermalStandard, sys, "Alta") - attach_geometric_outage!(sys, component, [reserve_up]) - - template = get_thermal_dispatch_template_network( - NetworkModel(CopperPlateNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - - run_sc_reserve_case!( - ps_model, - sys, - constraint_keys, - [336, 0, 216, 144, 48], - false, - GAEVF, - 329000.0, - ["Reserve1"], - ) -end - -@testset "G-n with Ramp reserve deliverability constraints with AreaBalance PowerModel" begin - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey(RampConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_ub"), - ] - - 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"]) - - monitored_components = vcat( - collect(get_components(ACTransmission, c_sys)), - collect(get_components(AreaInterchange, c_sys)), - ) - for (component_name, reserve_name) in zip(components_outages_names, reserve_names) - component = get_component(ThermalStandard, c_sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, reserve_name) - attach_geometric_outage!( - c_sys, - component, - [reserve_up]; - monitored_components = monitored_components, - ) - end - - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) - set_device_model!(template, AreaInterchange, StaticBranch) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - ps_model = - DecisionModel(template, c_sys; resolution = Hour(1), optimizer = HiGHS_optimizer) - - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - psi_constraint_test(ps_model, constraint_keys) - - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - moi_tests(ps_model, 1224, 0, 1272, 456, 144, false) - - opt_container = IOM.get_optimization_container(ps_model) - copper_plate_constraints = - IOM.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) - @test size(copper_plate_constraints) == (2, 24) - - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 497494.4871638, 1) - - results = OptimizationProblemOutputs(ps_model) - verify_zone_balance!(results; flow_tol = 1e-6) - - compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) -end - -@testset "G-n with Contingency reserve deliverability constraints with AreaBalanceNetworkModel with Reserve Requirement" begin - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - IOM.ConstraintKey(RequirementConstraint, PSY.OnlineReserve{ReserveUp}), - IOM.ConstraintKey( - PostContingencyActivePowerReserveDeploymentVariableLimitsConstraint, - IOM.ComponentPairKey{PSY.ThermalStandard, PSY.OnlineReserve{ReserveUp}}, - ), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_ub"), - ] - - 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"]) - - monitored_components = vcat( - collect(get_components(ACTransmission, c_sys)), - collect(get_components(AreaInterchange, c_sys)), + monitored_components = monitored, ) - for (component_name, reserve_name) in zip(components_outages_names, reserve_names) - component = get_component(ThermalStandard, c_sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, reserve_name) - attach_geometric_outage!( - c_sys, - component, - [reserve_up]; - monitored_components = monitored_components, - ) + for generator in generators + add_supplemental_attribute!(sys, generator, outage) end - - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) - set_device_model!(template, AreaInterchange, StaticBranch) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = - DecisionModel(template, c_sys; resolution = Hour(1), optimizer = HiGHS_optimizer) - - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - psi_constraint_test(ps_model, constraint_keys) - - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - moi_tests(ps_model, 984, 0, 696, 408, 144, false) - - opt_container = IOM.get_optimization_container(ps_model) - copper_plate_constraints = - IOM.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) - @test size(copper_plate_constraints) == (2, 24) - - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 482055.9151334, 1) - - results = OptimizationProblemOutputs(ps_model) - verify_zone_balance!(results; flow_tol = 1e-6) - - compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) -end - -@testset "G-n with Contingency reserve deliverability constraints with AreaBalanceNetworkModel with NO Reserve Requirement" begin - constraint_keys = [ - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "lb"), - IOM.ConstraintKey(ActivePowerVariableLimitsConstraint, PSY.ThermalStandard, "ub"), IOM.ConstraintKey(CopperPlateBalanceConstraint, PSY.Area), - IOM.ConstraintKey( - PostContingencyActivePowerGenerationLimitsConstraint, - PSY.ThermalStandard, - ), - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.Area), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_lb"), - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, "G1_ub"), - ] - - c_sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - - reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, "Reserve1_1") - remove_time_series!( - c_sys, - SingleTimeSeries, - reserve_up, - "requirement", - ) - reserve_up = get_component(OnlineReserve{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"]) - - monitored_components = vcat( - collect(get_components(ACTransmission, c_sys)), - collect(get_components(AreaInterchange, c_sys)), - ) - for (component_name, reserve_name) in zip(components_outages_names, reserve_names) - component = get_component(ThermalStandard, c_sys, component_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, c_sys, reserve_name) - attach_geometric_outage!( - c_sys, - component, - [reserve_up]; - monitored_components = monitored_components, - ) + for service in services + add_supplemental_attribute!(sys, service, outage) end - - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) - set_device_model!(template, AreaInterchange, StaticBranch) - - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = - DecisionModel(template, c_sys; resolution = Hour(1), optimizer = HiGHS_optimizer) - - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - psi_constraint_test(ps_model, constraint_keys) - - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - moi_tests(ps_model, 744, 0, 504, 360, 144, false) - - opt_container = IOM.get_optimization_container(ps_model) - copper_plate_constraints = - IOM.get_constraint(opt_container, CopperPlateBalanceConstraint(), PSY.Area) - @test size(copper_plate_constraints) == (2, 24) - - psi_checksolve_test(ps_model, [MOI.OPTIMAL], 482055.7647083302, 1) - - results = OptimizationProblemOutputs(ps_model) - verify_zone_balance!(results; flow_tol = 0.0) - - compare_outage_power_and_deployed_reserves(c_sys, results, reserve_names) + return outage end -# 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)) - - reserve1 = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1_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 = attach_geometric_outage!(sys, alta1, [reserve1]) - outage_id = IS.get_id(transition_data) - - template = get_thermal_dispatch_template_network( - NetworkModel(AreaPTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - - # Constructing the model runs `finalize_template!`, which fills the contributing map. - ps_model = - DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) - built_template = IOM.get_template(ps_model) - - # --- Unit-level: only Reserve1_1's contributing devices respond to the outage --- - outaged_generators, _, _ = POM._security_constrained_outages( - sys, - IOM.get_service_models(built_template), - IOM.get_network_model(built_template), - ) - @test collect(keys(outaged_generators)) == [outage_id] - - # --- Build-level: one outage, one balance row per time step --- - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(ps_model) - reserve1_devices = Set( - PSY.get_name(d) for - d in PSY.get_contributing_devices(sys, reserve1) if d isa ThermalStandard - ) - total = IOM.get_expression( - container, - IOM.ExpressionKey(PostContingencyTotalReserveDeployment, ThermalStandard), - ) - @test Set(name for (name, uuid, _) in keys(total.data) if uuid == outage_id) == - setdiff(reserve1_devices, ["Alta_1"]) - cons_resp = IOM.get_constraint( - container, - IOM.ConstraintKey(PostContingencyGenerationBalanceConstraint, PSY.System), +function _add_parallel_interchange!(sys::PSY.System) + interchange = AreaInterchange(; + name = "1_2_b", + available = true, + active_power_flow = 0.0, + from_area = get_component(Area, sys, "Area1"), + to_area = get_component(Area, sys, "Area2"), + flow_limits = (from_to = 1.5, to_from = 1.5), ) - @test size(cons_resp) == (1, 24) + add_component!(sys, interchange) + return interchange end -# 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 be discovered, with the outaged generator recorded. -@testset "SC reserve outage attachment covers single-reserve case" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) +_default_g1_monitored(sys) = [ + get_component(Line, sys, "4_1"), + get_component(Line, sys, "2_2"), + get_component(AreaInterchange, sys, "1_2"), +] - alta = get_component(ThermalStandard, sys, "Alta") - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - transition_data = attach_geometric_outage!(sys, alta, [reserve_up]) - outage_id = IS.get_id(transition_data) +function g1_system(; monitored::Function = _default_g1_monitored) + sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) + online = get_component(OnlineReserve{ReserveUp}, sys, _G1_RESERVE) + offline = OfflineReserve(; name = _G1_RESERVE, available = true, time_frame = 30.0) + add_service!(sys, offline, collect(get_components(ThermalStandard, sys))) - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedRampReserve, - )) - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - built_template = IOM.get_template(ps_model) + wind = get_component(RenewableDispatch, sys, "WindBus1") + PSY.set_name!(sys, wind, "Brighton_2") + add_service!(wind, online, sys) - outaged_generators, _, _ = POM._security_constrained_outages( + add_equivalent_ac_transmission_with_parallel_circuits!( sys, - IOM.get_service_models(built_template), - IOM.get_network_model(built_template), + get_component(Line, sys, "4_1"), + Line, ) - @test outaged_generators[outage_id] == - Dict{DataType, Set{String}}(ThermalStandard => Set(["Alta"])) -end - -# ---------------------------------------------------------------------------- -# Regression tests for the AreaBalanceNetworkModel post-contingency -# reformulation (PR #1617 review remediation, task 1): the per-area balance -# no longer references the pre-contingency `ActivePowerBalance` expression, -# and inter-area rebalancing flows through a free -# `PostContingencyAreaInterchangeFlowDeviationVariable` (Δf) on every -# `AreaInterchange`. Prior to the fix, areas without the outaged unit had -# their deployment pinned to zero by construction, so these tests fail on -# unmodified code (either with a missing-variable error, since Δf did not -# exist, or because the below assertions on nonzero cross-area deployment -# do not hold). -# ---------------------------------------------------------------------------- + _add_parallel_interchange!(sys) -# `PostContingencyAreaInterchangeFlowDeviationVariable` is sparse over -# (area_interchange_name, outage_id, t), flattened to a "tie__outage" column. -# Filter to one (outage, tie) pair and return the value series sorted by time. -function _sc_flow_deviation_series( - df::DataFrame, - outage_id::Int, - monitored_name::String, -) - column = IOM.encode_tuple_to_column((monitored_name, string(outage_id))) - rows = filter(x -> x["name"] == column, df) - sorted = sort(rows, :DateTime) - return sorted[!, "value"] -end - -# Sum `PostContingencyActivePowerReserveDeploymentVariable` across -# contributing devices for one outage, sorted by time. Shared by the -# cross-area deployment/Δf identity checks below. -function total_deployment_series(df::DataFrame, outage_id::Int) - suffix = IOM.PSI_NAME_DELIMITER * string(outage_id) - outage_rows = filter(x -> endswith(x["name"], suffix), df) - total_by_t = combine(groupby(outage_rows, :DateTime), :value => sum => :dep) - sort!(total_by_t, :DateTime) - return total_by_t.dep -end - -# Adds a second, parallel `AreaInterchange` between Area1 and Area2 of a -# `two_area_pjm_DA`-derived system, named "1_2_b" to distinguish it from the -# original "1_2" tie. Shared by the multi-tie G-1 testsets below, which vary -# only in `available`. -function add_parallel_tie!(sys::PSY.System; available::Bool = true) - area1 = get_component(Area, sys, "Area1") - area2 = get_component(Area, sys, "Area2") - tie = AreaInterchange(; - name = "1_2_b", - available = available, - active_power_flow = 0.0, - from_area = area1, - to_area = area2, - flow_limits = (from_to = 1.5, to_from = 1.5), + thermal(name) = get_component(ThermalStandard, sys, name) + monitored_components = monitored(sys) + outages = ( + a = _attach_outage!( + sys, + [thermal("Alta_1"), thermal("Park City_1")], + [online], + monitored_components, + ), + b = _attach_outage!( + sys, + [thermal("Alta_1"), thermal("Sundance_2")], + [online, offline], + monitored_components, + ), + c = _attach_outage!( + sys, + [thermal("Alta_1"), thermal("Park City_2")], + [offline], + monitored_components, + ), ) - add_component!(sys, tie) - return tie + transform_single_time_series!(sys, Hour(24), Hour(1)) + return sys, outages end -# Shared setup for the AreaBalanceNetworkModel G-1 testsets below: builds -# `two_area_pjm_DA`, optionally adds the parallel "1_2_b" tie -# (`add_parallel_tie = false` skips it), attaches a `FixedForcedOutage` on -# `outaged_gen_name` to "Reserve1_2" with the caller-supplied monitored -# components (a function of `sys`, since some callers need components created -# inside this setup), and builds the AreaBalanceNetworkModel template/service -# model/decision model. `set_interchange_model!` registers the -# `AreaInterchange` DeviceModel; callers override it to attach a -# `filter_function` or to omit the DeviceModel entirely. -function _two_area_g1_setup!(; - add_parallel_tie::Bool = false, - tie_available::Bool = true, - monitored::Function, - use_slacks::Bool = false, - outaged_gen_name::String = "Brighton_1", - set_interchange_model!::Function = t -> - set_device_model!(t, AreaInterchange, StaticBranch), +function g1_template( + network::Type{<:AbstractNetworkModel}; + online_slacks::Bool = true, + offline_slacks::Bool = true, + model_interchanges::Bool = network === AreaBalanceNetworkModel, + interchange_filter = nothing, ) - sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - transform_single_time_series!(sys, Hour(24), Hour(1)) - if add_parallel_tie - add_parallel_tie!(sys; available = tie_available) + template = get_thermal_dispatch_template_network(NetworkModel(network)) + set_device_model!(template, RenewableDispatch, RenewableFullDispatch) + if model_interchanges + attributes = Dict{String, Any}() + isnothing(interchange_filter) || + (attributes["filter_function"] = interchange_filter) + set_device_model!( + template, + DeviceModel(AreaInterchange, StaticBranch; attributes = attributes), + ) end - - outaged_gen = get_component(ThermalStandard, sys, outaged_gen_name) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1_2") - attach_fixed_outage!( - sys, - outaged_gen, - [reserve_up]; - monitored_components = monitored(sys), - ) - - template = get_thermal_dispatch_template_network(NetworkModel(AreaBalanceNetworkModel)) - set_interchange_model!(template) - set_service_model!(template, + set_service_model!( + template, ServiceModel( OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve; - use_slacks = use_slacks, - )) - - ps_model = - DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) - return ps_model, sys, reserve_up -end - -@testset "G-1 AreaBalanceNetworkModel: cross-area reserve deployment covers out-of-area outage" begin - # Outage sited in Area1; the only responding service, Reserve1_2, has - # contributing devices exclusively in Area2 (see reserve setup in - # `two_area_pjm_DA`). Deployment must cross the "1_2" tie. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - monitored = s -> vcat( - collect(get_components(ACTransmission, s)), - collect(get_components(AreaInterchange, s)), + use_slacks = online_slacks, ), ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = OptimizationProblemOutputs(ps_model) - variables = read_variables(res) - - # The outaged generator must dispatch normally, not be pinned to zero by - # a stray "areas without the outage get zero deployment" side effect. - gen_power = filter( - x -> x["name"] == "Brighton_1", - variables["ActivePowerVariable__ThermalStandard"], - )[ - !, - "value", - ] - @test maximum(gen_power) > 1.0 - - # Σ Δrsv (Area2 devices) == p_out (Brighton_1), reusing the existing - # outage/deployment comparison helper. - compare_outage_power_and_deployed_reserves(sys, res, reserve_up) - - # The monitored tie's flow deviation carries exactly the deployed amount - # (opposite sign, since Area2 → Area1 support reduces the from(Area1)→ - # to(Area2) flow). - resolved_outages = - collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up)) - @test length(resolved_outages) == 1 - outage_id = IS.get_id(only(resolved_outages)) - - deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard__OnlineReserve__ReserveUp"] - total_deployment = total_deployment_series(deployment, outage_id) - - flow_dev = - variables["PostContingencyAreaInterchangeFlowDeviationVariable__AreaInterchange"] - tie_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") - - @test all(total_deployment .> 0.0) - @test maximum(abs.(total_deployment .+ tie_flow_dev)) < 1e-3 -end - -@testset "G-1 AreaBalanceNetworkModel: multi-tie area splits response without over-counting" begin - # Add a second, parallel AreaInterchange between Area1 and Area2 so the - # post-contingency response can split across two ties. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - add_parallel_tie = true, - monitored = s -> vcat( - collect(get_components(ACTransmission, s)), - collect(get_components(AreaInterchange, s)), + set_service_model!( + template, + ServiceModel( + OfflineReserve, + SecurityConstrainedContingencyReserve; + use_slacks = offline_slacks, ), ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = OptimizationProblemOutputs(ps_model) - variables = read_variables(res) - compare_outage_power_and_deployed_reserves(sys, res, reserve_up) - - resolved_outages = - collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up)) - outage_id = IS.get_id(only(resolved_outages)) - - deployment = - variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard__OnlineReserve__ReserveUp"] - total_deployment = total_deployment_series(deployment, outage_id) - - flow_dev = - variables["PostContingencyAreaInterchangeFlowDeviationVariable__AreaInterchange"] - tie1_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2") - tie2_flow_dev = _sc_flow_deviation_series(flow_dev, outage_id, "1_2_b") - - # Both ties are from(Area1)→to(Area2); the per-area balance requires the - # *sum* of their deviations to carry the response, not each tie - # independently reporting the full amount (the pre-fix over-count). - @test maximum( - abs.(total_deployment .+ tie1_flow_dev .+ tie2_flow_dev), - ) < 1e-3 + return template end -@testset "G-1 AreaBalanceNetworkModel: unmonitored tie has no rate constraint despite dense Δf axis" begin - # Add a second, available parallel tie but monitor only the original - # "1_2" tie. Δf must still span both (it is dense over every tie in the - # AreaInterchange DeviceModel's set), while the rate constraint is - # strictly opt-in and must cover only the monitored one. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - add_parallel_tie = true, - monitored = s -> [get_component(AreaInterchange, s, "1_2")], - ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT +g1_model(template, sys) = + DecisionModel(template, sys; resolution = Hour(1), optimizer = HiGHS_optimizer) - container = IOM.get_optimization_container(ps_model) - flow_dev = IOM.get_variable( - container, - PostContingencyAreaInterchangeFlowDeviationVariable, - AreaInterchange, +function aff_exprs_approx_equal(actual::JuMP.AffExpr, expected::JuMP.AffExpr) + isapprox(actual.constant, expected.constant; atol = IOM.PTDF_ZERO_TOL) || return false + return all( + isapprox( + get(actual.terms, v, 0.0), + get(expected.terms, v, 0.0); + atol = IOM.PTDF_ZERO_TOL, + ) for v in union(keys(actual.terms), keys(expected.terms)) ) - @test Set(k[1] for k in keys(flow_dev.data)) == Set(["1_2", "1_2_b"]) - - for meta in ("G1_lb", "G1_ub") - # `SparseAxisArray` does not forward `Base.keys` to the container - # axes, so index its `.data` dict directly to get the actual - # `(name, outage_id, t)` tuples that were written. - cons = IOM.get_constraint( - container, - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, meta), - ) - @test Set(k[1] for k in keys(cons.data)) == Set(["1_2"]) - end end -@testset "G-1 AreaBalanceNetworkModel: binding monitored-tie limit uses post-contingency flow slack" begin - ps_model, sys, reserve_up = _two_area_g1_setup!(; - monitored = s -> vcat( - collect(get_components(ACTransmission, s)), - collect(get_components(AreaInterchange, s)), - ), - use_slacks = true, - ) - - # Tighten the "1_2" tie well below the cross-area response so the - # post-contingency flow-rate constraint binds. - tie = get_component(AreaInterchange, sys, "1_2") - set_flow_limits!(tie, (from_to = 0.1 * PSY.SU, to_from = 0.1 * PSY.SU)) +_pair_types(::Type{IOM.ComponentPairKey{D, S}}) where {D, S} = (D, S) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - res = OptimizationProblemOutputs(ps_model) - variables = read_variables(res) - - # The balance still holds exactly even though the tie's physical limit - # cannot accommodate the full response: Δf is decoupled from the - # flow-rate constraint by the slack. - compare_outage_power_and_deployed_reserves(sys, res, reserve_up) - - slack_lb = - variables["PostGeneratorContingencyFlowActivePowerSlackLowerBound__AreaInterchange"] - @test maximum(slack_lb[!, "value"]) > 1e-3 +# Every deployment entry as (device type, service type, service, device, outage, t) => variable. +function _deployments(container) + entries = Dict{Tuple{DataType, DataType, String, String, Int, Int}, JuMP.VariableRef}() + for (key, variable) in IOM.get_variables(container) + IOM.get_entry_type(key) === PostContingencyDeploymentVariable || continue + D, S = _pair_types(IOM.get_component_type(key)) + for ((s, d, uuid, t), var) in variable.data + entries[(D, S, s, d, uuid, t)] = var + end + end + return entries end -@testset "G-1 AreaBalanceNetworkModel: unavailable AreaInterchange excluded from Δf axis" begin - # Add a second, parallel AreaInterchange between Area1 and Area2, then - # disable it. The original "1_2" tie stays available, so it remains the - # sole path for cross-area response. Monitor only available - # AreaInterchanges: a monitored-but-unavailable tie is covered by the - # testset below, not what this test targets. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - add_parallel_tie = true, - tie_available = false, - monitored = s -> vcat( - collect(get_components(ACTransmission, s)), - collect(get_components(PSY.get_available, AreaInterchange, s)), - ), - ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(ps_model) - flow_deviation_var = IOM.get_variable( - container, - PostContingencyAreaInterchangeFlowDeviationVariable, - AreaInterchange, - ) - interchange_axis = Set(k[1] for k in keys(flow_deviation_var.data)) - @test "1_2" in interchange_axis - @test !("1_2_b" in interchange_axis) +_outaged(sys, outage) = + collect(PSY.get_associated_components(sys, outage; component_type = PSY.Generator)) - # Matches pre-contingency semantics: the disabled tie is not the sole - # path here (the original "1_2" tie stays available), so the model - # remains solvable and the response still balances exactly. - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemOutputs(ps_model) - compare_outage_power_and_deployed_reserves(sys, res, reserve_up) +function _power_value(container, generator, t) + power = IOM.get_variable(container, ActivePowerVariable, typeof(generator)) + return JuMP.value(power[PSY.get_name(generator), t]) end -@testset "G-1 AreaBalanceNetworkModel: unavailable monitored AreaInterchange is dropped" begin - # Monitor every AreaInterchange, including the disabled one: only available - # ties get a post-contingency rate constraint. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - add_parallel_tie = true, - tie_available = false, - monitored = s -> collect(get_components(AreaInterchange, s)), - ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT +# Σ deployment − Σ outaged power per `(outage, t, location(component))`, from the solution. +function _net_deployment(sys, container, deployments, outages, location) + net = Dict{Tuple{Int, Int, Any}, Float64}() + add!(key, value) = (net[key] = get(net, key, 0.0) + value) + for ((D, _, _, d, uuid, t), var) in deployments + add!((uuid, t, location(get_component(D, sys, d))), JuMP.value(var)) + end + for outage in outages, generator in _outaged(sys, outage), + t in IOM.get_time_steps(container) - container = IOM.get_optimization_container(ps_model) - for meta in ("G1_lb", "G1_ub") - cons = IOM.get_constraint( - container, - IOM.ConstraintKey(PostContingencyFlowRateConstraint, PSY.AreaInterchange, meta), + add!( + (IS.get_id(outage), t, location(generator)), + -_power_value(container, generator, t), ) - @test Set(k[1] for k in keys(cons.data)) == Set(["1_2"]) end + return net end -# The AreaInterchange `DeviceModel`'s set — not the network model's scope — is -# what Δf must span: a tie excluded by that DeviceModel's `filter_function` -# has no pre-contingency `FlowActivePowerVariable`, so a Δf term on it would -# be an unanchored transfer path in the per-area balance (and the monitored -# post-contingency flow expression would hit a `KeyError`). -# -# Both testsets below build under `AreaBalanceNetworkModel`, so -# `ignores_branch_filtering(AreaBalanceNetworkModel) = true` makes -# `_validate_branch_models` log a stray "Branch filtering is ignored for -# network model AreaBalanceNetworkModel" warning during `build!`. That warning -# is inaccurate for this DeviceModel: filtering IS honored here (Δf's axis -# comes straight from the filtered `FlowActivePowerVariable__AreaInterchange` -# container, asserted below). Do not "fix" these tests by removing -# `filter_function` — the warning is a known false positive in the unrelated -# branch-filtering validation path, not a signal that filtering was ignored. -_exclude_parallel_tie_model!(template::PowerOperationsProblemTemplate) = set_device_model!( - template, - DeviceModel( - AreaInterchange, - StaticBranch; - attributes = Dict("filter_function" => x -> get_name(x) != "1_2_b"), - ), -) +function check_g1_deployment(sys, model, outages) + container = IOM.get_optimization_container(model) + time_steps = IOM.get_time_steps(container) + deployments = _deployments(container) + uuids = Dict(IS.get_id(o) => o for o in values(outages)) -@testset "G-1 AreaBalanceNetworkModel: filter_function-excluded tie gets no Δf term" begin - ps_model, sys, reserve_up = _two_area_g1_setup!(; - add_parallel_tie = true, - monitored = s -> [get_component(AreaInterchange, s, "1_2")], - set_interchange_model! = _exclude_parallel_tie_model!, + # Outaged devices do not deploy under their own outage. + outaged = Set( + (typeof(g), PSY.get_name(g), uuid) for (uuid, o) in uuids for g in _outaged(sys, o) ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT + @test !any(((D, _, _, d, uuid, _),) -> (D, d, uuid) in outaged, keys(deployments)) - container = IOM.get_optimization_container(ps_model) - flow_var = - IOM.get_variable(container, FlowActivePowerVariable, AreaInterchange) - flow_deviation_var = IOM.get_variable( - container, - PostContingencyAreaInterchangeFlowDeviationVariable, - AreaInterchange, + # Deployment replaces the outaged generation. + net = _net_deployment(sys, container, deployments, values(outages), _ -> :system) + @test all( + abs(get(net, (uuid, t, :system), 0.0)) < _G1_TOL for uuid in keys(uuids), + t in time_steps ) - # Δf spans exactly the pre-contingency flow variable's ties. - @test Set(axes(flow_var, 1)) == Set(["1_2"]) - @test Set(k[1] for k in keys(flow_deviation_var.data)) == Set(["1_2"]) - - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemOutputs(ps_model) - variables = read_variables(res) - compare_outage_power_and_deployed_reserves(sys, res, reserve_up) - outage_id = IS.get_id( - only(collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, reserve_up))), - ) - total_deployment = total_deployment_series( - variables["PostContingencyActivePowerReserveDeploymentVariable__ThermalStandard__OnlineReserve__ReserveUp"], - outage_id, + # Deployment on the procured reserve stays within the award. + award(D, S) = + IOM.get_variable(container, ActivePowerReserveVariable, IOM.ComponentPairKey{D, S}) + @test all( + JuMP.value(var) <= JuMP.value(award(D, S)[s, d, t]) + _G1_TOL for + ((D, S, s, d, _, t), var) in deployments if S <: OnlineReserve ) - tie_flow_dev = _sc_flow_deviation_series( - variables["PostContingencyAreaInterchangeFlowDeviationVariable__AreaInterchange"], - outage_id, - "1_2", + + # Output plus total deployment stays within the device maximum. + totals = Dict{Tuple{DataType, String, Int, Int}, Float64}() + for ((D, _, _, d, uuid, t), var) in deployments + totals[(D, d, uuid, t)] = get(totals, (D, d, uuid, t), 0.0) + JuMP.value(var) + end + @test all( + _power_value(container, get_component(D, sys, d), t) + deployed <= + PSY.get_max_active_power(get_component(D, sys, d), PSY.SU) + _G1_TOL for + ((D, d, _, t), deployed) in totals ) - # Per-area balance residual: the single modeled tie carries the whole - # cross-area response, with no free deviation on the excluded tie. - @test all(total_deployment .> 0.0) - @test maximum(abs.(total_deployment .+ tie_flow_dev)) < 1e-3 + return end -@testset "G-1 AreaBalanceNetworkModel: monitored tie excluded by filter_function rejected at validation" begin - # Both ties are available and in the network model's scope, so only the - # DeviceModel-inclusion check can reject the monitored "1_2_b". `build!` - # catches the `ConflictingInputsError` and reports `FAILED`. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - add_parallel_tie = true, - monitored = s -> collect(get_components(AreaInterchange, s)), - set_interchange_model! = _exclude_parallel_tie_model!, - ) +function check_g1_containers(model, outages) + container = IOM.get_optimization_container(model) + online_key(D) = IOM.ComponentPairKey{D, OnlineReserve{ReserveUp}} + offline_key(D) = IOM.ComponentPairKey{D, OfflineReserve} - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.FAILED -end + # Same-named contributors of different types stay apart. + for D in (ThermalStandard, RenewableDispatch) + award = IOM.get_variable(container, ActivePowerReserveVariable, online_key(D)) + @test haskey(award.data, (_G1_RESERVE, "Brighton_2", 1)) + end -@testset "G-1 AreaBalanceNetworkModel: no AreaInterchange device model warns and covers in-area" begin - # No AreaInterchange DeviceModel means no modeled tie at all: the per-area - # post-contingency balance reduces to in-area coverage, so the outage is - # sited in Area2 where Reserve1_2's contributing devices live. - ps_model, sys, reserve_up = _two_area_g1_setup!(; - outaged_gen_name = "Solitude_2", - monitored = s -> collect(get_components(Line, s)), - set_interchange_model! = t -> nothing, + # Same-named services of different types stay apart; only the online one responds to A + # and only the offline one to C. + online = IOM.get_variable( + container, + PostContingencyDeploymentVariable, + online_key(ThermalStandard), ) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT + offline = IOM.get_variable( + container, + PostContingencyDeploymentVariable, + offline_key(ThermalStandard), + ) + responds(variable, outage) = any(k -> k[3] == IS.get_id(outage), keys(variable.data)) + @test responds(online, outages.a) && !responds(offline, outages.a) + @test responds(online, outages.b) && responds(offline, outages.b) + @test !responds(online, outages.c) && responds(offline, outages.c) - container = IOM.get_optimization_container(ps_model) + # The offline reserve has no requirement series, so it is not procured. + @test !IOM.has_container_key(container, RequirementConstraint, OfflineReserve) @test !IOM.has_container_key( container, - FlowActivePowerVariable, - AreaInterchange, + ActivePowerReserveVariable, + offline_key(ThermalStandard), + ) + @test IOM.has_container_key( + container, + PostContingencyDeploymentConstraint, + online_key(ThermalStandard), ) - # No modeled tie, so no Δf container at all: the per-area balance reduces - # to in-area coverage. @test !IOM.has_container_key( container, - PostContingencyAreaInterchangeFlowDeviationVariable, - AreaInterchange, + PostContingencyDeploymentConstraint, + offline_key(ThermalStandard), ) - - # `build!` installs its own logger, so the construct-time warning is only - # observable in the model's log file; it must be emitted exactly once. - log_text = read(IOM.get_log_file(ps_model), String) - @test length( - collect( - eachmatch( - r"needs PSY\.AreaInterchange\(s\) and DeviceModel\{PSY\.AreaInterchange\}", - log_text, - ), - ), - ) == 1 - - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = OptimizationProblemOutputs(ps_model) - compare_outage_power_and_deployed_reserves(sys, res, reserve_up) + return end -@testset "SecurityConstrainedContingencyReserve rejects Reserve{ReserveDown}" begin - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - template = get_thermal_dispatch_template_network(PTDFNetworkModel) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveDown}, - SecurityConstrainedContingencyReserve, - )) +# Post-contingency flow change on each monitored entry equals the PTDF of a fresh matrix times +# the net deployment at each bus, recomputed from the solution and the system data. +function check_g1_ptdf_flows(sys, model, outages) + container = IOM.get_optimization_container(model) + network_model = IOM.get_network_model(IOM.get_template(model)) + reduction = POM.get_network_reduction(network_model) + catalog = POM.get_branch_catalog(network_model) + ptdf = PNM.VirtualPTDF(sys) + bus_axis = PNM.get_bus_axis(ptdf) + deployments = _deployments(container) + mapped_bus(component) = PNM.get_mapped_bus_number(reduction, PSY.get_bus(component)) - # `build!` catches this internally and reports `ModelBuildStatus.FAILED` - # rather than propagating it (see `build_impl!`), so the throw is only - # observable by calling the helper directly. - @test_throws IS.ConflictingInputsError POM._security_constrained_outages( - sys, - IOM.get_service_models(template), - IOM.get_network_model(template), + net = _net_deployment(sys, container, deployments, values(outages), mapped_bus) + + post_flow = IOM.get_expression(container, PostContingencyBranchFlow, Line, "G1") + pre_flow = IOM.get_expression(container, PTDFBranchFlow, Line) + arc_map = PNM.get_name_to_arc_map(catalog, Line) + rows = Dict(name => ptdf[arc, :] for (name, arc) in arc_map) + flow_change(entry_name, uuid, t) = + sum( + r * get(net, (uuid, t, bus), 0.0) for + (r, bus) in zip(rows[entry_name], bus_axis) + ) + @test !isempty(post_flow.data) + @test all( + isapprox( + JuMP.value(flow) - JuMP.value(pre_flow[entry_name, t]), + flow_change(entry_name, uuid, t); + atol = _G1_TOL, + ) for ((entry_name, uuid, t), flow) in post_flow.data ) -end -# Regression for A-1 (PR #1617 review remediation, simplify pass 2): -# `_monitored_components_by_modeled_type` admits `PSY.AreaInterchange` for the -# service consumer only. On the device-side path an AreaInterchange monitor -# must be reported via `_warn_uncovered_monitored_types` and excluded from -# `device_model.outages`, not silently dropped later inside -# `_resolve_monitored_arcs`. -@testset "Device-side AreaInterchange monitor is reported, not silently dropped" begin - sys = PSB.build_system(PSISystems, "two_area_pjm_DA"; add_reserves = true) - transform_single_time_series!(sys, Hour(24), Hour(1)) + # The parallel circuits share one reduced entry, limited once. + entries = PNM.get_component_to_reduction_name_map(catalog, Line) + @test entries["4_1"] == entries["4_1_copy"] + return +end - line = first(collect(get_components(Line, sys))) - tie = get_component(AreaInterchange, sys, "1_2") - attach_fixed_outage!( - sys, - line, - PSY.Service[]; - monitored_components = [line, tie], +function check_g1_area_flows(sys, model, outages) + container = IOM.get_optimization_container(model) + time_steps = IOM.get_time_steps(container) + deployments = _deployments(container) + deviation = IOM.get_variable( + container, + PostContingencyDeviationVariable, + AreaInterchange, ) + area(c) = PSY.get_name(PSY.get_area(PSY.get_bus(c))) + net = _net_deployment(sys, container, deployments, values(outages), area) - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), + # Each area balances its net deployment against the deviations on the Area1 -> Area2 + # interchanges. + exported(uuid, t) = sum(JuMP.value(deviation[i, uuid, t]) for i in ("1_2", "1_2_b")) + uuids = [IS.get_id(o) for o in values(outages)] + @test all( + abs(get(net, (uuid, t, "Area1"), 0.0) - exported(uuid, t)) < _G1_TOL && + abs(get(net, (uuid, t, "Area2"), 0.0) + exported(uuid, t)) < _G1_TOL for + uuid in uuids, t in time_steps ) - set_device_model!(template, Line, SecurityConstrainedStaticBranch) - set_device_model!(template, AreaInterchange, StaticBranch) - - @test_logs ( - :warn, - r"AreaInterchange.*is not a modeled ACTransmission branch type", - ) POM._build_device_model_outages!(template, sys) - - device_model = only(POM._sc_branch_models(template)) - outage_uuid = only(keys(device_model.outages)) - per_type = device_model.outages[outage_uuid] - @test haskey(per_type, Line) - @test !haskey(per_type, AreaInterchange) -end - -@testset "PostContingencyBranchFlow uses reduced-arc PTDF for series-reduced monitored branch" begin - # `RADIAL1-RADIAL2-i_1` is a `:ToFrom`-oriented member of a degree-two series - # reduction (orientation_sign == -1.0). Pre- and post-contingency flows are both - # keyed by the reduced arc, so the arc's PTDF column applies unflipped. - sys_red = PSB.build_system(PSITestSystems, "case10_radial_series_reductions") - dummy_forecast = Deterministic( - "max_active_power", - Dict(DateTime("2020-01-01T00:00:00") => ones(24)), - Hour(1), + # Both interchanges are modeled, but only the monitored one is limited. + limits = IOM.get_constraint( + container, + PostContingencyFlowRateConstraint, + AreaInterchange, + "G1_ub", ) - load = first(collect(get_components(StandardLoad, sys_red))) - add_time_series!(sys_red, load, dummy_forecast) + limited = Set(k[1] for k in keys(limits.data)) + @test "1_2" in limited + @test !("1_2_b" in limited) + @test Set(axes(deviation, 1)) == Set(["1_2", "1_2_b"]) + return +end - gens = collect(get_components(ThermalStandard, sys_red)) - add_service!(sys_red, - OnlineReserve{ReserveUp}(; - name = "Reserve1", - available = true, - time_frame = 0.0, - requirement = 0.0, - sustained_time = 3600, - max_output_fraction = 1.0, - max_participation_factor = 0.25, - deployed_fraction = 0.0, +@testset "G-1 reserves: $(network)" for network in ( + CopperPlateNetworkModel, + PTDFNetworkModel, + AreaBalanceNetworkModel, +) + sys, outages = g1_system() + model = g1_model(g1_template(network), sys) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + check_g1_containers(model, outages) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + check_g1_deployment(sys, model, outages) + network === PTDFNetworkModel && check_g1_ptdf_flows(sys, model, outages) + network === AreaBalanceNetworkModel && check_g1_area_flows(sys, model, outages) +end + +@testset "G-1 reserves on a degree-two reduced network" begin + # `RADIAL1-RADIAL2-i_1` is merged into a degree-two series entry, so its post-contingency + # flow is keyed and limited by that entry. + sys = PSB.build_system(PSITestSystems, "case10_radial_series_reductions") + load = first(collect(get_components(StandardLoad, sys))) + add_time_series!( + sys, + load, + Deterministic( + "max_active_power", + Dict(DateTime("2020-01-01T00:00:00") => ones(24)), + Hour(1), ), - gens) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys_red, "Reserve1") - - outaged_gen = gens[1] + ) + generators = collect(get_components(ThermalStandard, sys)) + reserve = OnlineReserve{ReserveUp}(; + name = _G1_RESERVE, + available = true, + time_frame = 0.0, + requirement = 0.0, + sustained_time = 3600, + max_output_fraction = 1.0, + max_participation_factor = 1.0, + deployed_fraction = 0.0, + ) + add_service!(sys, reserve, generators) monitored_name = "RADIAL1-RADIAL2-i_1" - monitored_line = get_component(Line, sys_red, monitored_name) - outage = attach_fixed_outage!( - sys_red, - outaged_gen, - [reserve_up]; - monitored_components = [monitored_line], + outage = _attach_outage!( + sys, + [first(generators)], + [reserve], + [get_component(Line, sys, monitored_name)], ) + reductions = PNM.NetworkReduction[DegreeTwoReduction()] template = PowerOperationsProblemTemplate( NetworkModel( PTDFNetworkModel; - network_source = SystemNetworkSource(DegreeTwoReduction()), + network_source = SystemNetworkSource(reductions...), ), ) set_device_model!(template, ThermalStandard, ThermalBasicDispatch) set_device_model!(template, StandardLoad, StaticPowerLoad) set_device_model!(template, Line, StaticBranch) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys_red; optimizer = HiGHS_optimizer) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveUp}, SecurityConstrainedContingencyReserve), + ) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT - container = IOM.get_optimization_container(ps_model) - network_model = IOM.get_network_model(IOM.get_template(ps_model)) + # Coefficient-level: the post-contingency flow is the pre-contingency flow plus a fresh + # reduced PTDF row times the deployment and outaged power, mapped to reduced buses. + container = IOM.get_optimization_container(model) + network_model = IOM.get_network_model(IOM.get_template(model)) catalog = POM.get_branch_catalog(network_model) - orientation_sign = POM.get_ptdf_orientation_sign(catalog, Line, monitored_name) - @test orientation_sign == -1.0 # guard: fixture must exercise the :ToFrom member + reduction = POM.get_network_reduction(network_model) + entry_name = PNM.get_component_to_reduction_name_map(catalog, Line)[monitored_name] + @test entry_name != monitored_name + + ptdf = PNM.VirtualPTDF(sys; network_reductions = reductions) + bus_axis = PNM.get_bus_axis(ptdf) + row = ptdf[PNM.get_name_to_arc_map(catalog, Line)[entry_name], :] + factor(component) = + row[findfirst( + ==(PNM.get_mapped_bus_number(reduction, PSY.get_bus(component))), + bus_axis, + )] - time_steps = IOM.get_time_steps(container) - outage_id = IS.get_id(outage) - actual_container = - IOM.get_expression(container, PostContingencyBranchFlow, Line, "G1") + uuid = IS.get_id(outage) + deployments = _deployments(container) + power = IOM.get_variable(container, ActivePowerVariable, ThermalStandard) + post_flow = IOM.get_expression(container, PostContingencyBranchFlow, Line, "G1") pre_flow = IOM.get_expression(container, PTDFBranchFlow, Line) - nodal_deployment = IOM.get_expression( - container, - PostContingencyNodalActivePowerDeployment, - ACBus, - ) - # The series member resolves to its reduction entry, which keys both the - # pre-contingency and post-contingency flow rows. - arc_map = PNM.get_name_to_arc_map(catalog, Line) - entry_name = if haskey(arc_map, monitored_name) - monitored_name - else - PNM.get_component_to_reduction_name_map(catalog, Line)[monitored_name] - end - ptdf = POM.get_network_matrix(network_model) - ptdf_col = ptdf[arc_map[entry_name], :] - full_bus_axis = PNM.get_bus_axis(ptdf) - # Nodal deployment is sparse over injection-relevant buses (contributing-device - # and outaged-generator buses); other buses carry no deployment and are skipped. - relevant_buses = Set(k[1] for k in keys(nodal_deployment.data)) - - for t in time_steps - actual = actual_container[entry_name, outage_id, t] - arc_oriented = copy(pre_flow[entry_name, t]) - member_oriented = copy(pre_flow[entry_name, t]) - for b in eachindex(ptdf_col) - coef = ptdf_col[b] - abs(coef) < IOM.PTDF_ZERO_TOL && continue - bus_key = string(full_bus_axis[b]) - bus_key in relevant_buses || continue - JuMP.add_to_expression!( - arc_oriented, coef, nodal_deployment[bus_key, outage_id, t], - ) - JuMP.add_to_expression!( - member_oriented, orientation_sign * coef, - nodal_deployment[bus_key, outage_id, t], - ) + for t in IOM.get_time_steps(container) + expected = copy(pre_flow[entry_name, t]) + for ((D, _, _, d, u, tt), var) in deployments + (u == uuid && tt == t) || continue + JuMP.add_to_expression!(expected, factor(get_component(D, sys, d)), var) end - @test aff_exprs_approx_equal(actual, arc_oriented; atol = 1e-8) - @test !aff_exprs_approx_equal(actual, member_oriented; atol = 1e-8) + outaged = first(generators) + JuMP.add_to_expression!( + expected, + -factor(outaged), + power[PSY.get_name(outaged), t], + ) + @test aff_exprs_approx_equal(post_flow[entry_name, uuid, t], expected) end end -@testset "PostContingencyActivePowerGenerationLimitsConstraint has no lower bound (UC, no-requirement path)" begin - # Regression for M5: for an up-reserve formulation, `p + Δ >= limits.min` - # is redundant when the unit is committed (`p >= limits.min * u`, `Δ >= - # 0`) and actively wrong when it is not (`u == 0` forces `p == 0`, so the - # old lower bound demanded a positive `Δ` from an offline unit just to - # reach `limits.min`). Fixing the offline unit's on-status AND its - # deployment to zero must remain feasible: under the dropped lower bound - # this is `0 >= limits.min`, which is false whenever `limits.min > 0` — - # provably infeasible under the pre-fix constraint. - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - remove_time_series!(sys, Deterministic, reserve_up, "requirement") - - outaged_gen = get_component(ThermalStandard, sys, "Alta") - offline_gen = get_component(ThermalStandard, sys, "Park City") - @test PSY.get_active_power_limits(offline_gen, PSY.SU).min > 0.0 # guard: fix-to-zero must bind the old lb - - outage = attach_fixed_outage!( - sys, - outaged_gen, - [reserve_up]; - monitored_components = collect(get_components(ACTransmission, sys)), - ) - - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_device_model!(template, ThermalStandard, ThermalStandardUnitCommitment) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) - - ps_model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - container = IOM.get_optimization_container(ps_model) - on_var = IOM.get_variable(container, OnVariable, ThermalStandard) - deployment_var = IOM.get_variable( - container, - PostContingencyActivePowerReserveDeploymentVariable, - IOM.ComponentPairKey{ThermalStandard, OnlineReserve{ReserveUp}}, - ) - outage_id = IS.get_id(outage) - for t in IOM.get_time_steps(container) - JuMP.fix(on_var["Park City", t], 0.0; force = true) - JuMP.fix(deployment_var["Reserve1", "Park City", outage_id, t], 0.0; force = true) +@testset "G-1 reserves validation" begin + @testset "monitored interchange excluded by filter_function is rejected" begin + sys, _ = g1_system() + template = g1_template( + AreaBalanceNetworkModel; + interchange_filter = x -> PSY.get_name(x) != "1_2", + ) + @test build!(g1_model(template, sys); output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED end - @test solve!(ps_model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED -end - -@testset "has_requirement_ts is gated on the mapped requirement series, not any time series" begin - # Regression for M12: a service carrying only an unrelated time series - # (not the name mapped to `RequirementTimeSeriesParameter`) must take the - # no-requirement path — no `RequirementConstraint` should be built. - sys = PSB.build_system(PSITestSystems, "c_sys5_uc"; add_reserves = true) - reserve_up = get_component(OnlineReserve{ReserveUp}, sys, "Reserve1") - remove_time_series!(sys, Deterministic, reserve_up, "requirement") - - unrelated_forecast = Deterministic( - "unrelated_series", - Dict(DateTime("2020-01-01T00:00:00") => ones(24)), - Hour(1), - ) - add_time_series!(sys, reserve_up, unrelated_forecast) + @testset "monitored interchange without a device model is rejected" begin + sys, _ = g1_system() + template = g1_template(AreaBalanceNetworkModel; model_interchanges = false) + @test build!(g1_model(template, sys); output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + end - outaged_gen = get_component(ThermalStandard, sys, "Alta") - attach_fixed_outage!( - sys, - outaged_gen, - [reserve_up]; - monitored_components = collect(get_components(ACTransmission, sys)), - ) + @testset "no AreaInterchange model warns and balances each area alone" begin + sys, _ = g1_system(; monitored = s -> [get_component(Line, s, "2_2")]) + model = g1_model( + g1_template(AreaBalanceNetworkModel; model_interchanges = false), + sys, + ) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test !IOM.has_container_key( + container, + PostContingencyDeviationVariable, + AreaInterchange, + ) + # `build!` logs to the model's log file, not the caller's logger. + log_text = read(IOM.get_log_file(model), String) + @test length( + collect(eachmatch(r"each area must cover its own outages", log_text)), + ) == + 1 + end - template = get_thermal_dispatch_template_network( - NetworkModel(PTDFNetworkModel), - ) - set_service_model!(template, - ServiceModel( - OnlineReserve{ReserveUp}, - SecurityConstrainedContingencyReserve, - )) + @testset "unmodeled outaged generator warns and is skipped" begin + sys, outages = g1_system() + online = get_component(OnlineReserve{ReserveUp}, sys, _G1_RESERVE) + _attach_outage!( + sys, + [get_component(RenewableDispatch, sys, "PVBus5")], + [online], + [get_component(Line, sys, "2_2")], + ) + template = g1_template(CopperPlateNetworkModel) + delete!(template.devices, :RenewableDispatch) + model = g1_model(template, sys) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + log_text = read(IOM.get_log_file(model), String) + @test occursin(r"PVBus5.*is not modeled", log_text) + end - ps_model = - DecisionModel(template, sys; optimizer = HiGHS_optimizer, interval = Hour(24)) - @test build!(ps_model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT + @testset "service models sharing an outage must agree on use_slacks" begin + sys, _ = g1_system() + template = g1_template(PTDFNetworkModel; offline_slacks = false) + @test build!(g1_model(template, sys); output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + end - container = IOM.get_optimization_container(ps_model) - requirement = - IOM.get_constraint(container, RequirementConstraint, OnlineReserve{ReserveUp}) - @test !("Reserve1" in axes(requirement, 1)) + @testset "down reserves are rejected" begin + sys, _ = g1_system() + template = g1_template(CopperPlateNetworkModel) + set_service_model!( + template, + ServiceModel(OnlineReserve{ReserveDown}, SecurityConstrainedContingencyReserve), + ) + @test build!(g1_model(template, sys); output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + end end From 07d7ce4fadd3032c87be37290a08fafa0994e643 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Wed, 23 Sep 2026 16:07:45 -0400 Subject: [PATCH 22/22] sc test fixes --- ..._static_injection_security_constrained_models.jl | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/test_static_injection_security_constrained_models.jl b/test/test_static_injection_security_constrained_models.jl index 71c074c2..8711acfa 100644 --- a/test/test_static_injection_security_constrained_models.jl +++ b/test/test_static_injection_security_constrained_models.jl @@ -143,7 +143,7 @@ _pair_types(::Type{IOM.ComponentPairKey{D, S}}) where {D, S} = (D, S) # Every deployment entry as (device type, service type, service, device, outage, t) => variable. function _deployments(container) - entries = Dict{Tuple{DataType, DataType, String, String, Int, Int}, JuMP.VariableRef}() + entries = Dict{Tuple{DataType, Type, String, String, Int, Int}, JuMP.VariableRef}() for (key, variable) in IOM.get_variables(container) IOM.get_entry_type(key) === PostContingencyDeploymentVariable || continue D, S = _pair_types(IOM.get_component_type(key)) @@ -359,8 +359,8 @@ end end @testset "G-1 reserves on a degree-two reduced network" begin - # `RADIAL1-RADIAL2-i_1` is merged into a degree-two series entry, so its post-contingency - # flow is keyed and limited by that entry. + # `RADIAL1-RADIAL2-i_1` is merged into a degree-two series chain. Series segments keep + # their own names but map to the chain's reduced arc. sys = PSB.build_system(PSITestSystems, "case10_radial_series_reductions") load = first(collect(get_components(StandardLoad, sys))) add_time_series!( @@ -417,11 +417,14 @@ end catalog = POM.get_branch_catalog(network_model) reduction = POM.get_network_reduction(network_model) entry_name = PNM.get_component_to_reduction_name_map(catalog, Line)[monitored_name] - @test entry_name != monitored_name + reduced_arc = PNM.get_name_to_arc_map(catalog, Line)[entry_name] + line_arc = PSY.get_arc(get_component(Line, sys, monitored_name)) + @test reduced_arc != + (PSY.get_number(PSY.get_from(line_arc)), PSY.get_number(PSY.get_to(line_arc))) ptdf = PNM.VirtualPTDF(sys; network_reductions = reductions) bus_axis = PNM.get_bus_axis(ptdf) - row = ptdf[PNM.get_name_to_arc_map(catalog, Line)[entry_name], :] + row = ptdf[reduced_arc, :] factor(component) = row[findfirst( ==(PNM.get_mapped_bus_number(reduction, PSY.get_bus(component))),