SCUC branch formulation using MODF#1579
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces an initial security-constrained (N-1) AC transmission branch formulation for DC power-flow models using a Virtual MODF matrix, integrating post-contingency flow expressions/constraints into the branch device modeling and adding tests to exercise PTDF/MODF workflows (including parallel lines and network reductions).
Changes:
- Add
SecurityConstrainedStaticBranchformulation andPostContingencyEmergencyFlowRateConstraintconstraint type. - Extend
NetworkModel(and initialization template cloning) to carry aMODF_matrixand exposeget_MODF_matrix. - Add AC transmission security-constrained device-model implementation and a new test suite covering VirtualPTDF+VirtualMODF and reductions.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
test/test_ac_transmission_security_constrained_models.jl |
New tests for SC branch formulation across systems, with parallel lines and reductions. |
src/PowerSimulations.jl |
Exports new formulation/constraint and includes the new device model file; adds VirtualMODF export. |
src/initial_conditions/initialization.jl |
Propagates MODF_matrix when building the initialization network model. |
src/devices_models/devices/ac_transmission_security_constrained_models.jl |
Implements post-contingency branch flow expressions and emergency flow limit constraints using MODF. |
src/core/network_reductions.jl |
Adds helper overload to build constraint axis across multiple branch types. |
src/core/network_model.jl |
Adds MODF_matrix storage + accessor and documents the new keyword argument. |
src/core/formulations.jl |
Adds AbstractSecurityConstrainedStaticBranch and SecurityConstrainedStaticBranch. |
src/core/constraints.jl |
Replaces old contingency constraint struct with PostContingencyEmergencyFlowRateConstraint and adds docstring. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Agent-Logs-Url: https://github.com/NREL-Sienna/PowerSimulations.jl/sessions/ea35e7ed-ecfc-4cdd-811d-a132561c732e Co-authored-by: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com>
Agent-Logs-Url: https://github.com/NREL-Sienna/PowerSimulations.jl/sessions/a0d66157-cc43-4278-8396-d7dc5286ab13 Co-authored-by: SebastianManriqueM <104728742+SebastianManriqueM@users.noreply.github.com>
There was a problem hiding this comment.
@m-bossart and @SebastianManriqueM the changes here are 100% complicated to handle all the combinations of time series, monitored components and outage modeling. It need a careful review
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 84 out of 85 changed files in this pull request and generated 12 comments.
Comments suppressed due to low confidence (4)
src/devices_models/devices/ac_transmission_security_constrained_models.jl:610
- For the PTDF security-constrained formulation this should use the same reduction-aware branch-parameter builder as
StaticBranch. Calling the genericadd_parameters!keys the rating time series by original device names and usesPSY.get_ratingas the multiplier; after network reductions the flow-rate constraints are keyed by reduced names and expect equivalent ratings, so rating time series on reduced/parallel SC branches can be missed or applied with the wrong limit.
if haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter)
add_parameters!(
container,
BranchRatingTimeSeriesParameter,
devices,
device_model,
)
src/devices_models/devices/ac_transmission_security_constrained_models.jl:704
- This generic parameter path has the same reduction/name-multiplier mismatch for AC security-constrained models: reduced branch constraints look up rating time-series columns by the reduced branch name and equivalent rating, while
add_parameters!creates columns for the original devices withPSY.get_ratingmultipliers. Use a reduction-aware branch parameter path or ensure the constraint builder and parameter axes/multipliers are aligned.
if haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter)
add_parameters!(
container,
BranchRatingTimeSeriesParameter,
devices,
device_model,
)
src/devices_models/devices/AC_branches.jl:736
- This has the same configuration-vs-container issue: when
BranchRatingTimeSeriesParameteris configured but no branch of this type has the time series, the argument-construction stage skips the parameter container, and this unconditional lookup fails during constraint construction instead of using static ratings.
has_ts_rating =
haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter)
if has_ts_rating
param = get_parameter_array(container, BranchRatingTimeSeriesParameter(), B)
mult = get_parameter_multiplier_array(
container,
BranchRatingTimeSeriesParameter(),
B,
)
src/devices_models/devices/AC_branches.jl:810
- This unconditional parameter lookup also fails when the time-series name is configured but no matching series exists for this branch type. The code should treat absence of the parameter container as
name_has_ts = falsefor all branches and build static limits.
has_ts_rating =
haskey(get_time_series_names(device_model), BranchRatingTimeSeriesParameter)
if has_ts_rating
param = get_parameter_array(container, BranchRatingTimeSeriesParameter(), B)
mult = get_parameter_multiplier_array(
container,
BranchRatingTimeSeriesParameter(),
B,
)
| _check_security_constrained_three_winding_transformer!(model.template.branches) | ||
| validate_network_model(network_model, unmodeled_branch_types, model_has_branch_filters) |
| - An outage UUID is "claimed" by `DeviceModel{D, SC}` iff the type of every | ||
| outaged (associated) component on that outage is `D`. The OUTAGED | ||
| component's type — not the monitored components — must be covered by an | ||
| SC `DeviceModel` for the outage to contribute any constraints. |
| comp_type = typeof(component) | ||
| if comp_type in modeled_types | ||
| push!(get!(per_type, comp_type, Set{String}()), PSY.get_name(component)) | ||
| else |
| if has_ts_rating | ||
| ts_name = get_time_series_names(device_model)[BranchRatingTimeSeriesParameter] | ||
| param = get_parameter_array(container, BranchRatingTimeSeriesParameter(), T) | ||
| ts_branch_names = axes(param, 1) |
|
|
||
| For the per-formulation constraint algebra (variable names, slacks, objective | ||
| terms) see the [`PowerSystems.Branch` Formulations](@ref) page in the | ||
| Formulation Library. This page is the conceptual companion: *where the number |
There was a problem hiding this comment.
"Where the constraint branch flow limit on the right-hand side comes from"
|
|
||
| applied through `FlowRateConstraint` (PTDF and DC) or, for | ||
| `StaticBranchBounds`, directly as variable bounds on the flow variable. | ||
| `MonitoredLine` is the exception — it carries explicit, possibly asymmetric |
There was a problem hiding this comment.
We should describe here how this exception interacts with the content above about using branch_rating to derive a single value and then min_max_flow_limits to make the symmetrical limits. Its not clear how this happens when you have for example a MonitoredLine with asymmetrical flow limits that is within a reduction.
|
|
||
| - General rate limit: ``\min(\text{rating},\ \text{flow\_limits.from\_to},\ \text{flow\_limits.to\_from})``, | ||
| applied symmetrically. A warning is emitted when the from-to and to-from | ||
| limits differ (the minimum is then used). |
There was a problem hiding this comment.
This is confusing to me. We seem to imply that we can have asymmetric limits above, but then this seems like the asymmetry is ignored and a warning is issued.
| (across every monitored type) to its arc in the active reduction graph — | ||
| using `component_to_reduction_name_map` as a redirect when the monitored name | ||
| is an individual component that was reduced into a representative (e.g. `"3"`, | ||
| `"3_copy"` → `"3double_circuit"`). Duplicate arcs within an outage are |
There was a problem hiding this comment.
The example with the names is just confusing, I would remove
| net_reduction_data::PNM.NetworkReductionData, | ||
| ) | ||
| name_to_arc_maps = PNM.get_name_to_arc_maps(net_reduction_data) | ||
| c2r_maps = PNM.get_component_to_reduction_name_map(net_reduction_data) |
There was a problem hiding this comment.
spell out variable name for consistency
| expressions = get_expression(container, PostContingencyBranchFlow(), V) | ||
| jump_model = get_jump_model(container) | ||
|
|
||
| # Multi-component outage dedup: if another SC DeviceModel already added |
There was a problem hiding this comment.
Should this functionality for checking for already existing constraints be included in the branch tracker? It is very similar functionally to what we do for non-emergency constraints.
| container, T, V, resolved, time_steps, | ||
| ) | ||
|
|
||
| # Multi-component outage dedup: an outage attached to N>1 component types |
There was a problem hiding this comment.
What is the intended behavior when an outage is associated with two different component types but only one of them has a security constrained formulation? I would think we shouldn't silently only add one of the outages but rather warn or error.
There was a problem hiding this comment.
we have flow gates for mixed transformers and lines... this is the best idea I could come up with
| event_model.attribute_device_map[model_name] | ||
| sim_time = get_current_time(simulation) | ||
| for (dtype, device_names) in device_type_maps | ||
| if dtype == PSY.RenewableDispatch |
There was a problem hiding this comment.
Do you recall why this was in here?
There was a problem hiding this comment.
I determined this was left over code that we never removed
|
Looks good pending the tests |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 84 out of 85 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (5)
src/devices_models/devices/AC_branches.jl:749
- This repeats the UUID-vs-branch-name axis mixup:
axes(param, 1)contains time-series UUIDs, not branch names. The FromTo apparent-power constraint therefore never takes the time-varying rating path for normal branch names and uses the static rating instead.
if has_container_key(container, BranchRatingTimeSeriesParameter, B)
param = get_parameter_array(container, BranchRatingTimeSeriesParameter(), B)
mult = get_parameter_multiplier_array(
container,
BranchRatingTimeSeriesParameter(),
B,
)
ts_branch_names = axes(param, 1)
src/devices_models/devices/AC_branches.jl:841
- This uses the parameter array's UUID axis to decide whether a branch has a rating time series. Since
nameis a branch name, the ToFrom apparent-power constraint will skip the time-series path and enforce only the static rating even whenBranchRatingTimeSeriesParameteris configured.
if has_container_key(container, BranchRatingTimeSeriesParameter, B)
# In this case the value of the multiplier and the param need to equal to rating^2.
# The updating needs to happen in a clever way to avoid performance issues. The param and multiplier are
# stored separately to allow the time series to be updated without needing to rebuild the multiplier, which is more expensive to update since it requires updating all entries instead of just the ones in the time series.
param = get_parameter_array(container, BranchRatingTimeSeriesParameter(), B)
mult = get_parameter_multiplier_array(
container,
BranchRatingTimeSeriesParameter(),
B,
)
ts_branch_names = axes(param, 1)
src/devices_models/devices/AC_branches.jl:857
- The ToFrom apparent-power time-series path has the same units issue as the FromTo path:
param * multis built as the linear ratingR(t), but this quadratic constraint needsR(t)^2on the RHS. Using the linear value makes AC branch rating time-series limits incorrect whenever this path is taken.
constraint[name, t] = JuMP.@constraint(
get_jump_model(container),
var1[name, t]^2 + var2[name, t]^2 -
(use_slacks ? slack_ub[name, t] : 0.0) <=
param[name, t] * mult[name, t]
)
test/test_network_constructors_with_branch_rating_time_series.jl:764
- This assertion only compares AC against DCP, so it passes even if both formulations ignore
BranchRatingTimeSeriesParameterand use the static rating. Add an expected-value check against the configuredrating_factors(preferably at a timestep with a factor different from 1.0) so the test actually proves the time-varying rating is applied.
src/devices_models/devices/AC_branches.jl:857 - This path also indexes the UUID-keyed parameter array with a branch name. If the time-series branch is correctly detected,
param[name, t]will not resolve; fetch the branch's parameter column through theTimeSeriesAttributesmapping instead of indexing the raw array by branch name.
constraint[name, t] = JuMP.@constraint(
get_jump_model(container),
var1[name, t]^2 + var2[name, t]^2 -
(use_slacks ? slack_ub[name, t] : 0.0) <=
param[name, t] * mult[name, t]
)
| # container and `get_parameter_array` would throw. An empty | ||
| # `ts_branch_names` then routes every arc through the static-rating path, | ||
| # which is the intended fallback. `name in ts_branch_names` is | ||
| # self-sufficient at the call site. | ||
| ts_branch_names = String[] | ||
| if has_container_key(container, BranchRatingTimeSeriesParameter, T) | ||
| ts_name = get_time_series_names(device_model)[BranchRatingTimeSeriesParameter] | ||
| param = get_parameter_array(container, BranchRatingTimeSeriesParameter(), T) | ||
| ts_branch_names = axes(param, 1) |
| # The time-series `param * mult` is built to equal `rating^2` directly, so | ||
| # it is NOT squared here; the static path squares the scalar rating. | ||
| if name in ts_branch_names | ||
| for t in time_steps | ||
| constraint[name, t] = JuMP.@constraint( | ||
| get_jump_model(container), | ||
| var1[name, t]^2 + var2[name, t]^2 - | ||
| (use_slacks ? slack_ub[name, t] : 0.0) <= | ||
| param[name, t] * mult[name, t] |
| `AbstractSecurityConstrainedStaticBranch`. Any other formulation — | ||
| including `StaticBranchUnbounded`, which enforces no flow limits — raises a | ||
| `ConflictingInputsError` rather than silently ignoring the time series. |
| """ | ||
| Parameter to define the dynamic rating time series of a branch | ||
| Parameter to define the rating time series of a branch | ||
| """ | ||
| struct DynamicBranchRatingTimeSeriesParameter <: | ||
| AbstractDynamicBranchRatingTimeSeriesParameter end | ||
| struct BranchRatingTimeSeriesParameter <: | ||
| AbstractBranchRatingTimeSeriesParameter end | ||
|
|
||
| """ | ||
| Parameter to define the dynamic ratings time series of an AC branch for post-contingency condition | ||
| Parameter to define the rating time series of an AC branch for post-contingency condition | ||
| """ | ||
| struct PostContingencyDynamicBranchRatingTimeSeriesParameter <: | ||
| AbstractDynamicBranchRatingTimeSeriesParameter end | ||
| struct PostContingencyBranchRatingTimeSeriesParameter <: | ||
| AbstractBranchRatingTimeSeriesParameter end |
| model.MODF_matrix = PNM.VirtualMODF( | ||
| sys; | ||
| tol = PTDF_ZERO_TOL, | ||
| network_reductions = _build_network_reductions(model, irreducible_buses), | ||
| ) |
| end | ||
| end | ||
| pf_e_data.is_solved = true | ||
| pf_e_data.is_solved = _check_pf_converged(pf_data) |
| constraint[name, t] = JuMP.@constraint( | ||
| get_jump_model(container), | ||
| var1[name, t]^2 + var2[name, t]^2 - | ||
| (use_slacks ? slack_ub[name, t] : 0.0) <= | ||
| param[name, t] * mult[name, t] | ||
| ) |


Here is a first implementation for this.
I think the ContingencySpec in PNM greatly simplified this work and the handling regarding the reductions.