Skip to content

SCUC branch formulation using MODF#1579

Merged
jd-lara merged 76 commits into
mainfrom
sm/n-1_modf
May 18, 2026
Merged

SCUC branch formulation using MODF#1579
jd-lara merged 76 commits into
mainfrom
sm/n-1_modf

Conversation

@SebastianManriqueM
Copy link
Copy Markdown
Contributor

@SebastianManriqueM SebastianManriqueM commented Mar 30, 2026

Here is a first implementation for this.

I think the ContingencySpec in PNM greatly simplified this work and the handling regarding the reductions.

  • We need to merge first the MODF PNM branch so this test pass

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SecurityConstrainedStaticBranch formulation and PostContingencyEmergencyFlowRateConstraint constraint type.
  • Extend NetworkModel (and initialization template cloning) to carry a MODF_matrix and expose get_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.

Comment thread src/PowerSimulations.jl
Comment thread src/core/network_reductions.jl Outdated
Comment thread src/core/constraints.jl Outdated
Comment thread src/devices_models/devices/ac_transmission_security_constrained_models.jl Outdated
Comment thread src/devices_models/devices/ac_transmission_security_constrained_models.jl Outdated
@SebastianManriqueM SebastianManriqueM changed the base branch from main to jd/add_interval April 16, 2026 17:10
@SebastianManriqueM SebastianManriqueM marked this pull request as ready for review April 16, 2026 17:11
Comment thread src/core/network_model.jl
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Comment thread src/operation/template_validation.jl Outdated
Comment thread src/operation/template_validation.jl Outdated
Comment thread src/devices_models/devices/AC_branches.jl Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 generic add_parameters! keys the rating time series by original device names and uses PSY.get_rating as 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 with PSY.get_rating multipliers. 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 BranchRatingTimeSeriesParameter is 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 = false for 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,
        )

Comment thread src/devices_models/devices/ac_transmission_security_constrained_models.jl Outdated
Comment on lines +180 to 181
_check_security_constrained_three_winding_transformer!(model.template.branches)
validate_network_model(network_model, unmodeled_branch_types, model_has_branch_filters)
Comment thread test/test_events.jl Outdated
Comment thread src/operation/template_validation.jl Outdated
Comment thread src/devices_models/device_constructors/branch_constructor.jl
Comment thread src/core/device_model.jl Outdated
Comment thread src/core/formulations.jl Outdated
Comment on lines +162 to +165
- 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.
Comment thread src/core/network_model.jl
Comment on lines +315 to +318
comp_type = typeof(component)
if comp_type in modeled_types
push!(get!(per_type, comp_type, Set{String}()), PSY.get_name(component))
else
Comment on lines +454 to +457
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)
Comment thread src/simulation/simulation_events.jl Outdated

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Where the constraint branch flow limit on the right-hand side comes from"

Comment thread test/test_events.jl Outdated

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/src/explanation/branch_rating_limits.md

- 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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you recall why this was in here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I determined this was left over code that we never removed

@m-bossart
Copy link
Copy Markdown
Contributor

When we move this to POM we should try to make the testing of these three interacting features and the (many) combinations/edge cases that might arise more systematic and comprehensive:

  1. Time varying branch ratings
  2. Network reductions
  3. Outages/ Security constrained formulations.

Here are some initial notes on relevant combinations to test (many of these are already included, but just need to be systematized so we don't end up with a test file that is many thousands of lines...)
image

@m-bossart
Copy link
Copy Markdown
Contributor

When we move this to POM we should try to make the testing of these three interacting features and the (many) combinations/edge cases that might arise more systematic and comprehensive:

  1. Time varying branch ratings
  2. Network reductions
  3. Outages/ Security constrained formulations.

Here are some initial notes on relevant combinations to test (many of these are already included, but just need to be systematized so we don't end up with a test file that is many thousands of lines...) image

One more case to test: Flow gate with mixed components outaged where only one of the component types has a security constrained formulation. I think at least we need a warning in this case that the outage being modeled does not match the flowgate specification.

@m-bossart m-bossart self-requested a review May 18, 2026 20:04
@m-bossart
Copy link
Copy Markdown
Contributor

Looks good pending the tests

@jd-lara jd-lara requested review from Copilot and jd-lara May 18, 2026 20:06
@jd-lara jd-lara merged commit 33b73cc into main May 18, 2026
12 checks passed
@jd-lara jd-lara deleted the sm/n-1_modf branch May 18, 2026 20:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 name is a branch name, the ToFrom apparent-power constraint will skip the time-series path and enforce only the static rating even when BranchRatingTimeSeriesParameter is 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 * mult is built as the linear rating R(t), but this quadratic constraint needs R(t)^2 on 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 BranchRatingTimeSeriesParameter and use the static rating. Add an expected-value check against the configured rating_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 the TimeSeriesAttributes mapping 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]
                )

Comment on lines +455 to +463
# 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)
Comment on lines +758 to +766
# 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]
Comment on lines +164 to +166
`AbstractSecurityConstrainedStaticBranch`. Any other formulation —
including `StaticBranchUnbounded`, which enforces no flow limits — raises a
`ConflictingInputsError` rather than silently ignoring the time series.
Comment thread src/core/parameters.jl
Comment on lines 481 to +491
"""
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
Comment thread src/core/network_model.jl
Comment on lines +591 to +595
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)
Comment on lines +762 to +767
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]
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants