[WIP] Port G-1 security-constrained reserves (PSI #1617)#148
[WIP] Port G-1 security-constrained reserves (PSI #1617)#148acostarelli wants to merge 5 commits into
Conversation
Add the reserve/service-side security-constrained contingency layer:
post-contingency reserve deployment under generator (G-1) outages with
monitored-branch post-contingency flow constraints, across the CopperPlate,
AreaBalance, PTDF and AreaPTDF network models.
- New SecurityConstrained{Contingency,Ramp}Reserve formulations; contingency
variable/expression/constraint types; post-contingency slack-cost constant;
natural-unit conversions and exports.
- Service-side outage population in template validation
(_build_service_model_outages! and helpers); admit PSY.AreaInterchange as a
monitored component type.
- New services_models/static_injection_security_constrained_models.jl
(sparse, monitored post-contingency containers) and its test file.
Two AreaBalance objective expected-values were re-recorded for psy6
(two_area_pjm_DA system-data drift; model structure matches PSI exactly per
moi_tests/constraint-key checks).
[sources] pins InfrastructureOptimizationModels to its matching ac/g1-port
branch (ServiceModel.outages + 1-D time-only store output methods).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR ports G-1 (security-constrained) reserve formulations into PowerOperationsModels, adding service-side post-contingency reserve-deployment modeling and an extensive regression test suite to match the behavior being ported from PowerSimulations (PSI #1617).
Changes:
- Add security-constrained reserve formulations (contingency + ramp) with post-contingency deployment variables and sparse monitored-component flow constraints.
- Add template validation to build per-service outage scoping (
service_model.outages) based on outages attached to services. - Add comprehensive tests for multiple network models/formulations and adjust test dependencies to track required upstream IOM changes.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| test/test_static_injection_security_constrained_models.jl | New regression suite covering SC reserve build/solve behavior and outage scoping. |
| test/Project.toml | Pins test dependency InfrastructureOptimizationModels to ac/g1-port. |
| src/services_models/static_injection_security_constrained_models.jl | New service-side SC reserve model implementation (deployment vars, post-contingency expressions/constraints). |
| src/services_models/reserves.jl | Adds default requirement time-series mapping for SC reserve formulations. |
| src/PowerOperationsModels.jl | Includes new service-model file and exports SC types/keys. |
| src/operation/template_validation.jl | Adds _build_service_model_outages! and broadens monitored-type admission (incl. AreaInterchange). |
| src/core/variables.jl | Adds contingency variable supertypes + SC reserve deployment variable types and unit-conversion flags. |
| src/core/formulations.jl | Introduces SC reserve formulation types. |
| src/core/expressions.jl | Adds post-contingency expression types and output/unit-conversion hooks. |
| src/core/definitions.jl | Adds post-contingency slack penalty constant. |
| src/core/constraints.jl | Adds post-contingency balance/limit constraint types used by SC reserves. |
| Project.toml | Pins InfrastructureOptimizationModels to ac/g1-port. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| current_v = | ||
| filter(x -> x[col_name] == device_name, required_variables)[!, "value"] |
| test_reserves_deployment( | ||
| outage_dict[outage_name][i], | ||
| reserve_dict[outage_name][i], | ||
| ) | ||
| end |
| # Fall back: in the new G-1 pattern, outages are attached to the outaged | ||
| # generator, not the reserve service. Resolve from system if empty. | ||
| if isempty(associated_outages) | ||
| all_outages = collect(PSY.get_supplemental_attributes(PSY.UnplannedOutage, sys)) | ||
| associated_outages = all_outages | ||
| end |
| Security-constrained contingency reserve formulation: requires a | ||
| `RequirementTimeSeriesParameter` and deploys reserves under each G-1 outage | ||
| attached to a contributing generator. Post-contingency branch-flow constraints | ||
| are added only for the monitored components listed on each outage's | ||
| `monitored_components`. |
| has_requirement_ts = | ||
| haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && | ||
| length(PSY.get_time_series_keys(service)) > 0 |
| has_requirement_ts = | ||
| has_requirement_ts_default || ( | ||
| haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && | ||
| length(PSY.get_time_series_keys(service)) > 0 | ||
| ) |
| has_requirement_ts = | ||
| has_requirement_ts_default || ( | ||
| haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && | ||
| length(PSY.get_time_series_keys(service)) > 0 | ||
| ) |
| has_requirement_ts = | ||
| has_requirement_ts_default || ( | ||
| haskey(get_time_series_names(model), RequirementTimeSeriesParameter) && | ||
| length(PSY.get_time_series_keys(service)) > 0 | ||
| ) |
|
Performance Results
|
| struct PTDFBranchFlow <: ExpressionType end | ||
| abstract type PostContingencySystemBalanceExpressions <: SystemBalanceExpressions end | ||
| struct PostContingencyActivePowerBalance <: PostContingencySystemBalanceExpressions end | ||
| struct PostContingencyAreaInterchangeFlow <: PostContingencyExpressions end |
There was a problem hiding this comment.
I think PostContingencyExpressions is an IOM type. Maybe it should move, along with the other contingency types it has.
There was a problem hiding this comment.
Leave this for later.
| @@ -0,0 +1,2592 @@ | |||
| function get_outage_total_power_by_step_dict( | |||
There was a problem hiding this comment.
Double-check that all test cases were ported as 1:1 as possible from the original.
| ::Set{<:DataType}, | ||
| network_model::NetworkModel{<:AreaBalancePowerModel}, | ||
| ) where {SR <: PSY.AbstractReserve} | ||
| _construct_service_model_areabalance!( |
There was a problem hiding this comment.
For these construct_service! methods, we have these _construct_service_model_* helpers when we could instead dispatch on the network model.
There was a problem hiding this comment.
It also seems like those helpers have a lot of similarities.
| service, | ||
| service_name, | ||
| slack_resolved, | ||
| F(), |
There was a problem hiding this comment.
We should be passing by type not instance.
| contents[(outage_id, name, t)] = zero(JuMP.AffExpr) | ||
| end | ||
| end | ||
| expression_container = SparseAxisArray(contents) |
There was a problem hiding this comment.
I'm seeing this a lot in this PR, creating a SparseAxisArray directly and then the keys manually. Check the IOM API, we might be able to do this cleaner than we were doing in PSI. If not, we should probably update the IOM API.
| jump_model = get_jump_model(container) | ||
|
|
||
| use_slacks = get_use_slacks(service_model) | ||
| slack_ub = if use_slacks |
There was a problem hiding this comment.
I've seen this use_slacks routine at least in one other place.
| #! format: on | ||
|
|
||
| # Add `multiplier * var` to a JuMP scalar in place (post-contingency builders). | ||
| function _add_to_jump_expression!( |
There was a problem hiding this comment.
This already exists in jump_utils.jl
| device_name = PSY.get_name(device) | ||
| current_v = | ||
| filter(x -> x[col_name] == device_name, required_variables)[!, "value"] | ||
| if i == 1 |
There was a problem hiding this comment.
Surely there's a more Julian way to do this sum.
Bugs: - has_requirement_ts now checks for the specific requirement time series via PSY.has_time_series(service, ts_type, "requirement") instead of testing only that some time series exists on the service. - Test helper get_reserve_total_power_by_step_dict now scopes the deployment filter by outage id, so a device responding to multiple outages no longer mixes deployments across them. - compare_outage_power_and_deployed_reserves now forwards its tolerance kwarg to test_reserves_deployment. Docs/comments: - Reworded the SecurityConstrainedContingencyReserve docstring (outages scoped to the service with system fallback; requirement time series optional) and the misleading outage-attachment comment in the test. Cleanups: - Removed the redundant _add_to_jump_expression! wrapper (inline JuMP.add_to_expression!), pass the formulation by type to the slack builder, build the irregular sparse containers via the IOM sparse_container_spec explicit-keys overload, and use mapreduce for the test accumulators. Refactors: - Collapsed the 6 construct_service! methods + 3 near-duplicate helpers into one generic construct_service! dispatching the network model, a shared _construct_service_model_sc! core, and three thin _add_post_contingency_network_terms! methods; the boolean flags became a requires_requirement_ts trait. - Extracted the duplicated use_slacks logic into _make_post_contingency_slacks and _add_post_contingency_flow_rate_constraint!. Depends on the matching IOM sparse_container_spec overload (ac/g1-port). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| ::Dict{Symbol, DeviceModel}, | ||
| ::Set{<:DataType}, | ||
| network_model::NetworkModel{ | ||
| <:Union{PM.AbstractDCPModel, CopperPlatePowerModel, AreaBalancePowerModel}, |
There was a problem hiding this comment.
Do we have to rely on PowerModels here? We are trying to phase out this dependency, but maybe we still need it right now.
| PostContingencyAreaActivePowerDeployment, ActivePowerBalance, | ||
| service, model, network_model, | ||
| ) | ||
| add_post_contingency_flow_expressions!( |
There was a problem hiding this comment.
Reminder for myself: why do we have multiple dispatches, one that takes sys and one that doesn't? If we passed sys anyway could we combine with the PTDF one?
| # Shared ModelConstructStage skeleton for every supported network model. The | ||
| # network-specific post-contingency deployment/flow terms are dispatched on the | ||
| # network model through `_add_post_contingency_network_terms!`. | ||
| function _construct_service_model_sc!( |
There was a problem hiding this comment.
This does not need to be a separate function...
| contributing_devices, service, model, network_model, | ||
| ) | ||
| attribute_device_map = PSY.get_component_supplemental_attribute_pairs( | ||
| PSY.Generator, PSY.UnplannedOutage, sys, |
There was a problem hiding this comment.
What is the difference between UnplannedOutages and regular Outages? Should this dispatch on an abstract type?
|
|
||
| # Shared ArgumentConstructStage helper used by both formulations: builds | ||
| # pre-contingency reserve variable + post-contingency deployment variable. | ||
| function _construct_service_arguments_sc!( |
There was a problem hiding this comment.
This also doesn't need to be a separate function.
| name = PSY.get_name(device) | ||
| area_name = PSY.get_name(PSY.get_area(PSY.get_bus(device))) | ||
| for t in time_steps | ||
| JuMP.add_to_expression!( |
There was a problem hiding this comment.
We should use the jump_utils.jl where possible.
| PSY.get_associated_components(sys, outage; component_type = PSY.Generator) | ||
| outage_id = string(IS.get_uuid(outage)) | ||
| for device in contributing_devices | ||
| mult = device in associated_devices ? 0.0 : mult_default |
There was a problem hiding this comment.
I think if we're going to be multiplying be 0, we shouldn't add to the expression at all.
| ] | ||
| end | ||
|
|
||
| # Whether SC service model `m` should skip `outage`. Dispatched on the outage |
| # Cache PTDF columns per (monitored_type, arc) — multiple outages may | ||
| # monitor the same arc. | ||
| pre_flow_cache = Dict{DataType, Any}() | ||
| for (uuid, entries) in resolved | ||
| outage_id = string(uuid) | ||
| # Positional slice over the bus/time axes; matches `ptdf_col`'s | ||
| # positional indexing and avoids keyed lookup mismatches when the | ||
| # nodal expression's bus axis is a subset of the PTDF column space | ||
| # (e.g. AreaPTDFPowerModel). | ||
| post_cont_expr = nodal_deployment[outage_id, :, :].data | ||
| for (entry_type, name, arc, _) in entries | ||
| pre_flow = get!(pre_flow_cache, entry_type) do | ||
| get_expression(container, PTDFBranchFlow, entry_type) | ||
| end | ||
| ptdf_col = ptdf[arc, :] | ||
| for t in time_steps |
| Security-constrained contingency reserve formulation: deploys reserves under | ||
| each G-1 outage scoped to the reserve service (the outages attached to the | ||
| service, falling back to the system's outages when none are attached). A | ||
| `RequirementTimeSeriesParameter` is optional — when present the requirement / |
Port unported upstream PSI (sm/g-1_monitored_c) changes:
- Broaden UnplannedOutage -> abstract PSY.Outage at the post-contingency
balance/deployment dispatch and supplemental-attribute lookups so planned
outages flow through when opted in (PSI b74bd31/9b819fe/2577880/5d5b3c6).
- Correct SecurityConstrainedContingencyReserve docstring: outages scoped to
the PSY.Service, requirement time series optional (PSI ced97500).
Review fixes:
- Skip zero-coefficient terms for outaged generators instead of adding them.
- Cache PTDF columns by arc to avoid repeated KLU solves per outage/time.
- add_variables! dispatches on ::Type{F} instead of a formulation instance.
- Inline the two single-call construct_service! helpers.
- Narrow the SC DCP path to AbstractPTDFModel, dropping PowerModels coupling.
- Trim/remove unnecessary comments; document the requirement time-series name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hand-built SparseAxisArray + manual ExpressionKey/ConstraintKey/ VariableKey + _assign_container! boilerplate in both security-constrained models with add_expression_container! (seed-by-keys) and the new add_variable_container!/add_constraints_container! pre-built overloads, per IOM's "never create container keys directly" rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-contingency expression, constraint, and slack containers are now created and prefilled via IOM's sparse_keys kwarg instead of building a SparseAxisArray in POM and handing it across. The container's keys are the resolved (outage, name, t) tuples — sparse storage, no cartesian holes. Branch slack containers are created only when use_slacks (dropping the conditional post-loop registration). Removed the stale docstring claiming the expression pre-fill is needed for the parallel PTDF build: those tasks return results and the writes into the container happen serially on the main thread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| container, PostContingencyFlowActivePowerSlackUpperBound, V; | ||
| sparse_keys = index_keys, | ||
| ) |
There was a problem hiding this comment.
[JuliaFormatter] reported by reviewdog 🐶
| container, PostContingencyFlowActivePowerSlackUpperBound, V; | |
| sparse_keys = index_keys, | |
| ) | |
| container, PostContingencyFlowActivePowerSlackUpperBound, V; | |
| sparse_keys = index_keys, | |
| ) |
| container, PostContingencyFlowActivePowerSlackLowerBound, V; | ||
| sparse_keys = index_keys, | ||
| ) |
There was a problem hiding this comment.
[JuliaFormatter] reported by reviewdog 🐶
| container, PostContingencyFlowActivePowerSlackLowerBound, V; | |
| sparse_keys = index_keys, | |
| ) | |
| container, PostContingencyFlowActivePowerSlackLowerBound, V; | |
| sparse_keys = index_keys, | |
| ) |
| struct PostContingencyAreaInterchangeFlow <: PostContingencyExpressions end | ||
| struct PostContingencyNodalActivePowerDeployment <: PostContingencyExpressions end | ||
| struct PostContingencyAreaActivePowerDeployment <: PostContingencyExpressions end |
|
|
||
| [sources] | ||
| InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} | ||
| InfrastructureOptimizationModels = {rev = "ac/g1-port", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} |
Sienna-Platform/PowerSimulations.jl#1617