From df192889b5eea6cd4d024e5951d1a48c93de2ac7 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Fri, 4 Sep 2026 17:58:09 -0700 Subject: [PATCH] check interfaces --- Project.toml | 2 +- .../instantiate_network_model.jl | 21 +- src/network_models/reduction_exceptions.jl | 21 ++ src/operation/template_validation.jl | 97 +++++++++ test/Project.toml | 2 +- test/test_native_network_reductions.jl | 65 ++++++ test/test_services_constructor.jl | 186 ++++++++++++++++++ 7 files changed, 385 insertions(+), 9 deletions(-) diff --git a/Project.toml b/Project.toml index df85eed2..dfa7f0fe 100644 --- a/Project.toml +++ b/Project.toml @@ -37,7 +37,7 @@ PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" # than PSY's local checkout expects (TimeSeriesKey UnionAll mismatch on load). # Revert to the git rev pin once PSY's local changes land on IS4. InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} -InfrastructureOptimizationModels = {rev = "jd/pwl_width_refs", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} # LOCAL PATH CO-DEV PIN (temporary): the variable -> variable_operation_cost cost-field # rename on PSY's generated cost types is uncommitted on disk, not on the psy6 branch yet. # Revert to the git rev pin once pushed/merged. diff --git a/src/network_models/instantiate_network_model.jl b/src/network_models/instantiate_network_model.jl index 2f1ad0a0..ca5e74ae 100644 --- a/src/network_models/instantiate_network_model.jl +++ b/src/network_models/instantiate_network_model.jl @@ -1,8 +1,9 @@ """ Concrete implementations of `instantiate_network_model!` for specific network formulations. -These methods extend the generic dispatch from IOM's `operation_model_interface.jl`, which -calls `instantiate_network_model!(network_model, branch_models, number_of_steps, sys)`. +These methods extend the generic dispatch from IOM's `optimization_model_interface.jl`, which +calls +`instantiate_network_model!(network_model, branch_models, service_models, number_of_steps, sys)`. Each method here handles the formulation-specific setup: computing PTDF/MODF matrices, discovering subnetworks, applying network reductions, etc. """ @@ -285,8 +286,11 @@ function _validate_prebuilt_exceptions( throw( IS.ConflictingInputsError( "The prebuilt network source eliminated buses $(sort!(collect(dropped))), which \ - the template pins as reduction exceptions (outage-monitored components or \ - time-varying branch ratings). Rebuild the matrix with these buses passed as \ + the model pins as reduction exceptions: the NetworkModel's own \ + `reduction_exceptions`, plus the buses of DC converter terminals, branches with \ + rating time series, outaged and monitored components, `model_all_branches` \ + monitored lines, controlled transformer circuits and their regulated buses, and \ + transmission interface branches. Rebuild the matrix with these buses passed as \ `irreducible_buses`, or pass a `SystemNetworkSource` so the build derives the \ reduction itself.", ), @@ -401,11 +405,12 @@ end function IOM.instantiate_network_model!( model::NetworkModel{T}, branch_models::BranchModelContainer, + service_models::ServicesModelContainer, number_of_steps::Int, sys::PSY.System, ) where {T <: AbstractNetworkModel} _validate_network_and_branches(model, branch_models, sys) - exceptions = _collect_reduction_exceptions(sys, model, branch_models) + exceptions = _collect_reduction_exceptions(sys, model, branch_models, service_models) ybus = _reduced_ybus!(model, sys, exceptions) IOM.set_network_data!( model, @@ -422,11 +427,12 @@ end function IOM.instantiate_network_model!( model::NetworkModel{DCPNetworkModel}, branch_models::BranchModelContainer, + service_models::ServicesModelContainer, number_of_steps::Int, sys::PSY.System, ) _validate_network_and_branches(model, branch_models, sys) - exceptions = _collect_reduction_exceptions(sys, model, branch_models) + exceptions = _collect_reduction_exceptions(sys, model, branch_models, service_models) ybus = _reduced_ybus!(model, sys, exceptions) catalog = _build_catalog(ybus, branch_models) if IOM._template_has_outage_aware_branch(branch_models) @@ -453,11 +459,12 @@ end function IOM.instantiate_network_model!( model::NetworkModel{T}, branch_models::BranchModelContainer, + service_models::ServicesModelContainer, number_of_steps::Int, sys::PSY.System, ) where {T <: AbstractPTDFNetworkModel} _validate_network_and_branches(model, branch_models, sys) - exceptions = _collect_reduction_exceptions(sys, model, branch_models) + exceptions = _collect_reduction_exceptions(sys, model, branch_models, service_models) IOM.set_network_data!( model, _ptdf_network_data( diff --git a/src/network_models/reduction_exceptions.jl b/src/network_models/reduction_exceptions.jl index 2d997843..a5de8c21 100644 --- a/src/network_models/reduction_exceptions.jl +++ b/src/network_models/reduction_exceptions.jl @@ -49,6 +49,7 @@ function _collect_reduction_exceptions( sys::PSY.System, network_model::NetworkModel, branch_models::BranchModelContainer, + service_models::ServicesModelContainer, ) @debug "Collecting reduction exceptions" _group = IOM.LOG_GROUP_NETWORK_CONSTRUCTION @@ -61,9 +62,29 @@ function _collect_reduction_exceptions( _pin_model_all_branches!(buses, m) _pin_transformer_controls!(buses, m, sys, network_model) end + for m in values(service_models) + _pin_interface_branch_buses!(buses, m) + end return collect(buses) end +# A branch in a modeled TransmissionInterface pins both its endpoints: the interface's flow +# expression reads the branch's own flow, and a reduction that merged the branch into a +# series or parallel equivalent (or eliminated it as radial) would leave that flow with no +# term to read. Reads the contributing devices `finalize_template!` populated, so only the +# interfaces the template models are pinned, and only their branch-typed contributors. +function _pin_interface_branch_buses!( + buses::Set{Int}, + m::ServiceModel{<:PSY.TransmissionInterface}, +) + for branch in get_contributing_devices(m) + _push_component_buses!(buses, branch) + end + return +end + +_pin_interface_branch_buses!(::Set{Int}, ::ServiceModel) = nothing + # A converter's AC terminal must survive the reduction. Merging one away drops the # converter from the model without a word, so this is keyed on the system rather than on a # DeviceModel — the exposure exists whether or not the template happens to model the diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index b6c8048b..730bde20 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -108,6 +108,7 @@ function validate_template_impl!(model::IOM.AbstractOptimizationModel) for k in branch_keys_to_delete delete!(template.branches, k) end + _check_interface_branches(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) @@ -121,6 +122,102 @@ function validate_template_impl!(model::IOM.AbstractOptimizationModel) return end +################################################################################# +# Transmission interface contributors +################################################################################# + +# Whether the network formulation builds a flow for this interface contributor, so that the +# interface's flow expression reads it. Aggregated formulations carry no AC branch flow (the +# AreaBalance interface constructor warns that it ignores them); an AreaInterchange is modeled +# wherever its branch model is. +_interface_contributor_has_flow( + ::PSY.ACTransmission, + ::Type{N}, +) where {N <: AbstractNetworkModel} = branches_modeled(N) +_interface_contributor_has_flow(::PSY.AreaInterchange, ::Type{<:AbstractNetworkModel}) = + true +_interface_contributor_has_flow(::PSY.Device, ::Type{<:AbstractNetworkModel}) = false + +""" +Reject a template whose transmission interfaces include a branch the model builds no flow +for: either the branch's type has no branch model in the template, or that branch model's +`filter_function` (or subsystem) excludes the branch. Either way the interface's flow +expression would silently omit the branch, so the template is rejected and the user must +make the filter and the interface definitions consistent. +""" +function _check_interface_branches( + template::PowerOperationsProblemTemplate, + sys::PSY.System, + ::NetworkModel{N}, +) where {N <: AbstractNetworkModel} + problems = String[] + modeled_names = Dict{DataType, Set{String}}() + for service_model in values(get_service_models(template)) + get_component_type(service_model) <: PSY.TransmissionInterface || continue + for interface in get_available_components(service_model, sys) + _interface_branch_problems!( + problems, + modeled_names, + template, + sys, + interface, + N, + ) + end + end + isempty(problems) && return + throw(IS.ConflictingInputsError(join(problems, "\n"))) +end + +function _interface_branch_problems!( + problems::Vector{String}, + modeled_names::Dict{DataType, Set{String}}, + template::PowerOperationsProblemTemplate, + sys::PSY.System, + interface::PSY.TransmissionInterface, + ::Type{N}, +) where {N <: AbstractNetworkModel} + unmodeled = Dict{DataType, Vector{String}}() + excluded = Dict{DataType, Vector{String}}() + for branch in PSY.get_contributing_devices(sys, interface) + PSY.get_available(branch) || continue + _interface_contributor_has_flow(branch, N) || continue + T = typeof(branch) + branch_model = get_model(template, T) + name = PSY.get_name(branch) + if branch_model === nothing + push!(get!(Vector{String}, unmodeled, T), name) + continue + end + names = get!(modeled_names, T) do + Set{String}(PSY.get_name(b) for b in get_device_cache(branch_model)) + end + name ∈ names || push!(get!(Vector{String}, excluded, T), name) + end + interface_name = PSY.get_name(interface) + for (T, names) in unmodeled + push!( + problems, + "TransmissionInterface \"$(interface_name)\" includes $(T) branches \ + $(sort!(names)) but the template has no branch model for $(T); their flow would \ + be omitted from the interface. Add a branch model for $(T), remove the \ + TransmissionInterface service model from the template, or remove the branches \ + from the interface in the system data.", + ) + end + for (T, names) in excluded + push!( + problems, + "TransmissionInterface \"$(interface_name)\" includes $(T) branches \ + $(sort!(names)) that the template's $(T) branch model excludes through its \ + filter_function or subsystem; their flow would be omitted from the interface. \ + Change the filter_function to admit every branch in the interface, or remove \ + the TransmissionInterface service model from the template.", + ) + end + return +end + ################################################################################# # Security-constrained branch validation and outage population ################################################################################# diff --git a/test/Project.toml b/test/Project.toml index e1a3bbe7..4a95631a 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -52,7 +52,7 @@ PowerOperationsModels = {path = ".."} # than PSY's local checkout expects (TimeSeriesKey UnionAll mismatch on load). # Revert to the git rev pin once PSY's local changes land on IS4. InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} -InfrastructureOptimizationModels = {rev = "jd/pwl_width_refs", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} # LOCAL PATH CO-DEV PIN (temporary): the variable -> variable_operation_cost cost-field # rename on PSY's generated cost types is uncommitted on disk, not on the psy6 branch yet. # Revert to the git rev pin once pushed/merged. diff --git a/test/test_native_network_reductions.jl b/test/test_native_network_reductions.jl index fb82a9f8..48d4924e 100644 --- a/test/test_native_network_reductions.jl +++ b/test/test_native_network_reductions.jl @@ -785,6 +785,7 @@ end sys, get_network_model(get_template(model)), branch_models, + get_service_models(get_template(model)), ) # "4-5-i_1" carries the rating time series; its endpoint buses (4, 5) must be # pinned so the reduction can't merge them away. @@ -793,6 +794,70 @@ end @test length(exceptions) == 2 end +@testset "Interface branches are pinned from the build's reduction" begin + # "1-8-i_1" is the radial branch to leaf bus 8, which RadialReduction folds into bus 1 + # unless something pins it. An interface over that branch reads its flow, so modeling + # the interface must keep the branch (and bus 8) in the reduced network. + function _build_with_interface(model_interface::Bool) + sys = _case11_with_forecast() + interface = TransmissionInterface(; + name = "leaf_interface", + available = true, + active_power_flow_limits = (min = -100.0, max = 100.0), + ) + add_service!(sys, interface, [get_component(Line, sys, "1-8-i_1")]) + net = NetworkModel( + POM.DCPNetworkModel; + network_source = SystemNetworkSource(PNM.RadialReduction()), + ) + template = get_thermal_dispatch_template_network(net) + if model_interface + set_service_model!( + template, + ServiceModel(TransmissionInterface, ConstantMaxInterfaceFlow), + ) + end + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!( + model; + output_dir = mktempdir(; cleanup = true), + console_level = Logging.Error, + ) == IOM.ModelBuildStatus.BUILT + return model + end + + unpinned = _build_with_interface(false) + unpinned_network = get_network_model(get_template(unpinned)) + @test !(8 in keys(PNM.get_bus_reduction_map(get_network_reduction(unpinned_network)))) + + pinned = _build_with_interface(true) + pinned_template = get_template(pinned) + pinned_network = get_network_model(pinned_template) + exceptions = POM._collect_reduction_exceptions( + get_system(pinned), + pinned_network, + get_branch_models(pinned_template), + get_service_models(pinned_template), + ) + @test Set(exceptions) == Set([1, 8]) + @test 8 in keys(PNM.get_bus_reduction_map(get_network_reduction(pinned_network))) + + # The retained branch is what the interface reads: its flow and the interface flow agree. + @test solve!(pinned) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + container = IOM.get_optimization_container(pinned) + line_flow = IOM.get_expression(container, POM.BThetaBranchFlow, Line) + interface_flow = + IOM.get_expression(container, POM.InterfaceTotalFlow, TransmissionInterface) + @test "1-8-i_1" in axes(line_flow)[1] + for t in axes(interface_flow)[2] + @test isapprox( + JuMP.value(interface_flow["leaf_interface", t]), + JuMP.value(line_flow["1-8-i_1", t]); + atol = POM.ABSOLUTE_TOLERANCE, + ) + end +end + @testset "Caller-supplied reduction_exceptions survive the build's reduction" begin # Bus 8 hangs off a radial branch, so RadialReduction absorbs it into bus 1 unless # something pins it. Asserted through a real build so the wiring from the diff --git a/test/test_services_constructor.jl b/test/test_services_constructor.jl index d98e3401..3a959de0 100644 --- a/test/test_services_constructor.jl +++ b/test/test_services_constructor.jl @@ -1873,3 +1873,189 @@ end @test sum(awards[t, c] for c in sub_cols) ≈ demand[t, "UP_GROUP"] atol = 1e-3 end end + +# The interface spans lines "1", "2" and "6", but the branch filter admits only line "1". The +# interface flow would silently omit the other two, so the template must be rejected until the +# filter admits every branch in the interface. +@testset "Interface branch excluded by a branch filter is rejected" begin + c_sys5_uc = PSB.build_system(PSITestSystems, "c_sys5_uc") + interface = TransmissionInterface(; + name = "west_east", + available = true, + active_power_flow_limits = (min = 0.0, max = 400.0), + ) + interface_line_names = ["1", "2", "6"] + add_service!( + c_sys5_uc, + interface, + [get_component(Line, c_sys5_uc, n) for n in interface_line_names], + ) + + function _filtered_template(net, filter_function) + template = get_thermal_dispatch_template_network(net) + set_device_model!( + template, + DeviceModel( + Line, + StaticBranch; + attributes = Dict("filter_function" => filter_function), + ), + ) + set_service_model!( + template, + ServiceModel(TransmissionInterface, ConstantMaxInterfaceFlow), + ) + return template + end + + for net in (DCPNetworkModel, PTDFNetworkModel) + template = _filtered_template(net, x -> PSY.get_name(x) == "1") + model = DecisionModel(template, c_sys5_uc; optimizer = HiGHS_optimizer) + err = try + POM.validate_template(model) + nothing + catch e + e + end + @test err isa IS.ConflictingInputsError + message = err.msg + @test occursin("west_east", message) + @test occursin("filter_function", message) + @test occursin("\"2\", \"6\"", message) + @test !occursin("\"1\"", message) + end + + # A filter consistent with the interface builds, and the interface flow is the sum over + # every line in the interface. + template = _filtered_template( + PTDFNetworkModel, + x -> PSY.get_name(x) in ("1", "2", "3", "6"), + ) + model = DecisionModel(template, c_sys5_uc; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + container = IOM.get_optimization_container(model) + branch_flow = IOM.get_expression(container, POM.PTDFBranchFlow, Line) + @test Set(axes(branch_flow)[1]) == Set(["1", "2", "3", "6"]) + interface_flow = + IOM.get_expression(container, POM.InterfaceTotalFlow, TransmissionInterface) + for t in axes(interface_flow)[2] + expected = sum(JuMP.value(branch_flow[name, t]) for name in interface_line_names) + @test isapprox( + JuMP.value(interface_flow["west_east", t]), + expected; + atol = POM.ABSOLUTE_TOLERANCE, + ) + end +end + +# A transformer in the interface with no TwoWindingTransformer branch model has no flow to +# read: the interface would silently omit it, so the template is rejected instead. +@testset "Interface branch with no branch model is rejected" begin + c_sys14 = PSB.build_system(PSITestSystems, "c_sys14") + line = get_component(Line, c_sys14, "Line1") + transformer = first(PSY.get_components(TwoWindingTransformer, c_sys14)) + interface = TransmissionInterface(; + name = "mixed_types", + available = true, + active_power_flow_limits = (min = -400.0, max = 400.0), + ) + add_service!(c_sys14, interface, [line, transformer]) + + template = PowerOperationsProblemTemplate(PTDFNetworkModel) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + set_device_model!(template, PowerLoad, StaticPowerLoad) + set_device_model!(template, Line, StaticBranch) + set_service_model!( + template, + ServiceModel(TransmissionInterface, ConstantMaxInterfaceFlow), + ) + + model = DecisionModel(template, c_sys14; optimizer = HiGHS_optimizer) + err = try + POM.validate_template(model) + nothing + catch e + e + end + @test err isa IS.ConflictingInputsError + message = err.msg + @test occursin("TwoWindingTransformer", message) + @test occursin(PSY.get_name(transformer), message) + @test occursin("mixed_types", message) + @test occursin("no branch model", message) + + set_device_model!(template, TwoWindingTransformer, StaticBranch) + ok_model = DecisionModel(template, c_sys14; optimizer = HiGHS_optimizer) + @test build!(ok_model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT +end + +@testset "Interface interchange excluded by a branch filter is rejected" begin + sys_rts_da = build_system(PSISystems, "modified_RTS_GMLC_DA_sys") + transform_single_time_series!(sys_rts_da, Hour(24), Hour(1)) + interchange1 = AreaInterchange(; + name = "interchange1_2", + available = true, + active_power_flow = 100.0, + flow_limits = (from_to = 1.0, to_from = 1.0), + from_area = get_component(Area, sys_rts_da, "1"), + to_area = get_component(Area, sys_rts_da, "2"), + ) + interchange2 = AreaInterchange(; + name = "interchange1_3", + available = true, + active_power_flow = 100.0, + flow_limits = (from_to = 1.0, to_from = 1.0), + from_area = get_component(Area, sys_rts_da, "1"), + to_area = get_component(Area, sys_rts_da, "3"), + ) + add_components!(sys_rts_da, [interchange1, interchange2]) + interface = TransmissionInterface(; + name = "interface1_2_3", + available = true, + active_power_flow_limits = (min = 0.0, max = 1.0), + direction_mapping = Dict("interchange1_2" => 1, "interchange1_3" => -1), + ) + add_service!(sys_rts_da, interface, [interchange1, interchange2]) + + template = PowerOperationsProblemTemplate(NetworkModel(AreaBalanceNetworkModel)) + set_device_model!(template, ThermalStandard, ThermalDispatchNoMin) + set_device_model!(template, RenewableDispatch, RenewableFullDispatch) + set_device_model!(template, PowerLoad, StaticPowerLoad) + set_device_model!(template, RenewableNonDispatch, FixedOutput) + set_device_model!(template, HydroDispatch, HydroDispatchRunOfRiver) + set_device_model!( + template, + DeviceModel( + AreaInterchange, + StaticBranch; + attributes = Dict( + "filter_function" => x -> PSY.get_name(x) == "interchange1_2", + ), + ), + ) + set_service_model!( + template, + ServiceModel(TransmissionInterface, ConstantMaxInterfaceFlow), + ) + model = DecisionModel( + template, + sys_rts_da; + resolution = Hour(1), + optimizer = HiGHS_optimizer, + ) + err = try + POM.validate_template(model) + nothing + catch e + e + end + @test err isa IS.ConflictingInputsError + message = err.msg + @test occursin("interface1_2_3", message) + @test occursin("AreaInterchange", message) + @test occursin("interchange1_3", message) + @test !occursin("\"interchange1_2\"", message) +end