diff --git a/src/pm_io/common.jl b/src/pm_io/common.jl index 6b57c0d..0439e63 100644 --- a/src/pm_io/common.jl +++ b/src/pm_io/common.jl @@ -4,17 +4,21 @@ import_all = false, validate = true, correct_branch_rating = true, + solved_case = false, ) Parses a Matpower .m `file` or PTI (PSS(R)E-v33) .raw `file` into a PowerModels data structure. All fields from PTI files will be imported if -`import_all` is true (Default: false). +`import_all` is true (Default: false). Set `solved_case` when a .raw file was written out +after a converged power flow: its switched shunts then take BINIT as their solved +admittance rather than reconstructing one from the engaged blocks. """ function parse_file( file::String; import_all = false, validate = true, correct_branch_rating = true, + solved_case = false, ) pm_data = open(file) do io pm_data = parse_file( @@ -22,6 +26,7 @@ function parse_file( import_all = import_all, validate = validate, correct_branch_rating = correct_branch_rating, + solved_case = solved_case, filetype = split(lowercase(file), '.')[end], ) end @@ -34,6 +39,7 @@ function parse_file( import_all = false, validate = true, correct_branch_rating = true, + solved_case = false, filetype = "json", ) if filetype == "m" @@ -44,6 +50,7 @@ function parse_file( import_all = import_all, validate = validate, correct_branch_rating = correct_branch_rating, + solved_case = solved_case, ) elseif filetype == "json" pm_data = parse_json(io; validate = validate) diff --git a/src/pm_io/psse.jl b/src/pm_io/psse.jl index f264b25..a9de6c7 100644 --- a/src/pm_io/psse.jl +++ b/src/pm_io/psse.jl @@ -828,12 +828,52 @@ function _psse2pm_load!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) end """ -Whether `key` names one of a SWITCHED SHUNT record's up-to-eight admittance-block columns -for `prefix` — `N1`..`N8` (step counts) or `B1`..`B8` (admittances). Exact, so a future -column merely starting with the same letter is not swept in. +The admittance blocks a SWITCHED SHUNT record defines, in the order they switch on. + +PSS(R)E describes block `i` by a step count `Ni` and a per-step admittance `Bi` (plus, in +v35, a status `Si`), and reads them as a contiguous run starting at block 1. """ -function _is_switched_shunt_block(key::AbstractString, prefix::Char) - return ncodeunits(key) == 2 && key[1] == prefix && '1' <= key[2] <= '8' +function _switched_shunt_blocks(switched_shunt::Dict) + step_number = Int[] + y_increment = Float64[] + for i in 1:8 + steps = get(switched_shunt, "N$i", 0) + increment = get(switched_shunt, "B$i", 0.0) + # Edge case: per the POM, a zero value for `Ni` or `Bi` terminates the list early. + (steps == 0 || increment == 0.0) && break + push!(step_number, steps) + push!(y_increment, increment) + end + return step_number, y_increment +end + +# PSS(R)E MODSW codes that adjust the shunt in discrete steps: 1 (local voltage) and the +# remote-quantity modes 3/4/5. Mode 0 is locked and mode 2 is continuous; both are handled +# separately in `_binit_is_authoritative`. +const _DISCRETE_SWITCHED_SHUNT_MODES = (1, 3, 4, 5) + +""" +Whether a SWITCHED SHUNT record's `BINIT` should be used as the device's admittance. Per the +POM, this is the case when one of the following is met: + + * the shunt is locked (`MODSW == 0`), or sits on a type 3 (swing) bus; + * the shunt is continuously controlled (`MODSW == 2`); + * the record carries no per-block status (versions before v35); + * the caller declares the case solved. + +Otherwise, BINIT is only a starting value, and device admittance is calculated +from the engaged blocks. +""" +function _binit_is_authoritative( + control_mode::Int, + bus_type::Int, + has_block_status::Bool, + solved_case::Bool, +) + solved_case && return true + has_block_status || return true + bus_type == PM_BUS_TYPE_REF && return true + return !(control_mode in _DISCRETE_SWITCHED_SHUNT_MODES) end """ @@ -842,9 +882,17 @@ end Parses PSS(R)E-style Fixed and Switched Shunt data into a PowerModels-style Dict. "source_id" is given by `["I", "ID"]` for Fixed Shunts, and `["I", "SWREM"]` for Switched Shunts, as given by the PSS(R)E Fixed and Switched Shunts -specifications. +specifications. `solved_case` declares the RAW to have been written out from a converged +power flow, which decides whether each switched shunt keeps its BINIT — see +`_binit_is_authoritative`. """ -function _psse2pm_shunt!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) +function _psse2pm_shunt!( + pm_data::Dict, + pti_data::Dict, + import_all::Bool, + nb, + solved_case::Bool, +) @info "Parsing PSS(R)E Fixed & Switched Shunt data into a PowerModels Dict..." # bus records may have already contributed shunt entries @@ -897,14 +945,11 @@ function _psse2pm_shunt!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) get(sub_data, "sw_id", "1"), ) sub_data["gs"] = 0.0 - # A PSS/E switched shunt has no fixed base admittance: the record carries only - # BINIT (the solved/initial total susceptance) and the per-block increments. BINIT - # is therefore NOT a `bs` -- it is the device's solved admittance, and goes to its - # own field so downstream can tell "solved total" from "fixed base + blocks" - # instead of inferring it from a zeroed block-status vector. - # See PowerSystems.jl#1774. + # The device's solved admittance goes to its own `solved_admittance` key + # so downstream can tell "solved total" from "total based on engaged blocks," + # which differ in continuous mode. sub_data["bs"] = 0.0 - sub_data["solved_admittance"] = pop!(switched_shunt, "BINIT") + binit = pop!(switched_shunt, "BINIT") sub_data["status"] = _determine_injector_status( switched_shunt, pm_data, @@ -916,13 +961,10 @@ function _psse2pm_shunt!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) (pop!(switched_shunt, "VSWLO"), pop!(switched_shunt, "VSWHI")) # N1..N8 hold the step count of each admittance block, B1..B8 its admittance. - step_numbers = Dict( - k => v for (k, v) in switched_shunt if _is_switched_shunt_block(k, 'N') - ) - step_numbers_sorted = - sort(collect(keys(step_numbers)); by = x -> parse(Int, x[2:end])) - sub_data["step_number"] = [step_numbers[k] for k in step_numbers_sorted] - sub_data["step_number"] = sub_data["step_number"][sub_data["step_number"] .!= 0] + step_number, y_increment = _switched_shunt_blocks(switched_shunt) + sub_data["step_number"] = step_number + # `y_increment` is an admittance: the record's Bi is a susceptance. + sub_data["y_increment"] = complex.(0.0, y_increment) sub_data["control_mode"] = switched_shunt["MODSW"] # pti.jl names the regulated-bus column "SWREM" for every source version, @@ -935,39 +977,35 @@ function _psse2pm_shunt!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) "RMIDNT" => switched_shunt["RMIDNT"], ) - y_increment = Dict( - k => v for - (k, v) in switched_shunt if _is_switched_shunt_block(k, 'B') - ) - y_increment_sorted = - sort(collect(keys(y_increment)); by = x -> parse(Int, x[2:end])) - sub_data["y_increment"] = [y_increment[k] for k in y_increment_sorted]im - sub_data["y_increment"] = sub_data["y_increment"][sub_data["y_increment"] .!= 0] - - if pm_data["source_version"] == "35" - initial_ss_status = Dict( - k => v for - (k, v) in switched_shunt if startswith(k, "S") && isdigit(last(k)) - ) - initial_ss_status_sorted = - sort(collect(keys(initial_ss_status)); by = x -> parse(Int, x[2:end])) - sub_data["number_engaged"] = - [initial_ss_status[k] for k in initial_ss_status_sorted] - sub_data["number_engaged"] = - sub_data["number_engaged"][1:length(sub_data["step_number"])] + has_block_status = pm_data["source_version"] == "35" + if has_block_status + # Si are block statuses, not step counts, so 1 => all steps on. + sub_data["number_engaged"] = [ + get(switched_shunt, "S$i", 1) != 0 ? steps : 0 for + (i, steps) in enumerate(step_number) + ] sub_data["ext"]["NREG"] = pop!(switched_shunt, "NREG") elseif pm_data["source_version"] ∈ ("30", "32", "33") - # Pre-v35 SWITCHED SHUNT records carry no per-block status field, so how - # many steps of each block are engaged is simply unknown. The device's - # actual admittance is BINIT, now carried in `solved_admittance` above, so - # nothing has to be reconstructed from the blocks; zeros here record "no - # per-block information", not "every block is out of service". - sub_data["number_engaged"] = zeros(Int, length(sub_data["y_increment"])) + # Pre-v35 SWITCHED SHUNT records carry no per-block status, so initialize to + # all-off and take admittance from BINIT. + sub_data["number_engaged"] = zeros(Int, length(step_number)) else error("Unsupported PSS(R)E source version: $(pm_data["source_version"])") end + # only keep BINIT where it states the device's actual admittance. The swing-bus + # rule is about the RAW's own bus I, so it reads `bus_number`: a node-breaker + # `shunt_bus` can be a node-bus still initialized to PQ at this point. + if _binit_is_authoritative( + Int(sub_data["control_mode"]), + pm_data["bus"][bus_number]["bus_type"], + has_block_status, + solved_case, + ) + sub_data["solved_admittance"] = binit + end + sub_data["index"] = length(pm_data["switched_shunt"]) + 1 sub_data["source_id"] = ["switched shunt", bus_number, sub_data["index"]] @@ -2650,13 +2688,16 @@ end Converts PSS(R)E-style data parsed from a PTI raw file, passed by `pti_data` into a format suitable for use internally in PowerModels. Imports all remaining -data from the PTI file if `import_all` is true (Default: false). +data from the PTI file if `import_all` is true (Default: false). Set `solved_case` when the +file was written out after a converged power flow, so that every switched shunt's BINIT is +taken as its solved admittance (see `_binit_is_authoritative`). """ function _pti_to_powermodels!( pti_data::Dict; import_all = false, validate = true, correct_branch_rating = true, + solved_case = false, )::Dict pm_data = Dict{String, Any}() @@ -2694,7 +2735,7 @@ function _pti_to_powermodels!( _psse2pm_transformer!(pm_data, pti_data, import_all, nb) # Injectors need to be parsed after branches and transformers to find topologically connected buses _psse2pm_load!(pm_data, pti_data, import_all, nb) - _psse2pm_shunt!(pm_data, pti_data, import_all, nb) + _psse2pm_shunt!(pm_data, pti_data, import_all, nb, solved_case) _psse2pm_generator!(pm_data, pti_data, import_all, nb) _migrate_node_breaker_gen_bus_type!(pm_data, nb) _psse2pm_facts!(pm_data, pti_data, import_all) diff --git a/src/power_models_data.jl b/src/power_models_data.jl index 0eb1e3d..fc6c5fb 100644 --- a/src/power_models_data.jl +++ b/src/power_models_data.jl @@ -27,6 +27,8 @@ Currently Supports MATPOWER and PSSE data files parsed by PowerModels. - `pm_data_corrections::Bool=true`: Run PowerModels data corrections (validation) - `import_all::Bool=false`: Import all fields from PTI files - `correct_branch_rating::Bool=true`: Correct branch ratings during parsing +- `solved_case::Bool=false`: Treat a PSS(R)E .raw file as written out from a converged + power flow, so switched shunts take BINIT as their solved admittance # Example ```julia @@ -38,11 +40,13 @@ function PowerModelsData(file::Union{String, IO}; kwargs...) validate = get(kwargs, :pm_data_corrections, true) import_all = get(kwargs, :import_all, false) correct_branch_rating = get(kwargs, :correct_branch_rating, true) + solved_case = get(kwargs, :solved_case, false) pm_dict = parse_file( file; import_all = import_all, validate = validate, correct_branch_rating = correct_branch_rating, + solved_case = solved_case, ) pm_data = PowerModelsData(pm_dict) correct_pm_transformer_status!(pm_data) diff --git a/test/fixtures/v35_switched_shunt.raw b/test/fixtures/v35_switched_shunt.raw new file mode 100644 index 0000000..8fc8efd --- /dev/null +++ b/test/fixtures/v35_switched_shunt.raw @@ -0,0 +1,42 @@ +@!IC,SBASE,REV,XFRRAT,NXFRAT,BASFRQ +0, 100.00, 35, 0, 1, 60.00 +Synthetic fictional v35 case exercising SWITCHED SHUNT MODSW/BINIT handling +Second comment line +0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA +@! I,'NAME ', BASKV, IDE,AREA,ZONE,OWNER, VM, VA, NVHI, NVLO, EVHI, EVLO + 1,'BUSONE ', 138.0000,3, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 2,'BUSTWO ', 138.0000,1, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 3,'BUSTHREE ', 138.0000,1, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 +0 / END OF BUS DATA, BEGIN LOAD DATA + 2,'1 ', 1, 1, 1, 100.000, 25.000, 0.000, 0.000, 0.000, 0.000, 1, 1, 0, 20.000, 5.000, 1,' V' + 3,'1 ', 1, 1, 1, 50.000, 10.000, 0.000, 0.000, 0.000, 0.000, 1, 1, 0, 15.000, 3.000, 0,' V' +0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA +0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA + 1,'1 ', 80.000, 0.000, 80.000, -80.000,1.00000, 0, 0, 100.000, 0.00000E+0, 1.00000E-1, 0.00000E+0, 0.00000E+0,1.00000,1, 100.0, 120.000, 0.000, 0,1,1.0000 +0 / END OF GENERATOR DATA, BEGIN BRANCH DATA + 1, 2,'1 ', 1.00000E-02, 1.00000E-01,0.02000,'BRANCH_1_2 ', 500.00, 500.00, 500.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00000, 0.00000, 0.00000, 0.00000,1,1, 1.00, 1,1.0000 + 2, 3,'1 ', 1.00000E-02, 1.00000E-01,0.02000,'BRANCH_2_3 ', 500.00, 500.00, 500.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00000, 0.00000, 0.00000, 0.00000,1,1, 1.00, 1,1.0000 +0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA +0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA +0 / END OF TRANSFORMER DATA, BEGIN AREA DATA +0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA +0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA +0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA +0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA +0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA +0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA +0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA +0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA +0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA +0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA +@! I,'ID',MODSW,ADJM,STAT, VSWHI, VSWLO, SWREG, NREG, RMPCT,'RMIDNT', BINIT, S1, N1, B1, S2, N2, B2, S3, N3, B3 + 1,'1 ', 1, 0, 1, 1.05000, 0.95000, 0, 0, 100.0,' ', 25.000, 1, 5, 10.000 + 2,'1 ', 1, 0, 1, 1.05000, 0.95000, 0, 0, 100.0,' ', 60.000, 1, 3, 10.000, 0, 2, 20.000 + 2,'2 ', 0, 0, 1, 1.05000, 0.95000, 0, 0, 100.0,' ', 45.000, 1, 2, 15.000 + 2,'3 ', 2, 0, 1, 1.05000, 0.95000, 0, 0, 100.0,' ', 37.500, 1, 4, 5.000 + 3,'1 ', 1, 0, 1, 1.05000, 0.95000, 0, 0, 100.0,' ', 16.000, 1, 2, 8.000, 1, 0, 12.000, 1, 3, 6.000 +0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DATA +0 / END OF GNE DATA, BEGIN INDUCTION MACHINE DATA +0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA +0 / END OF SUBSTATION DATA +Q diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index c292756..386f5de 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -146,6 +146,92 @@ end @test !haskey(pm_v33["bus"][3], "area_slack") end +@testset "PSSE v35 switched shunts: block statuses, and when BINIT is believed" begin + # Si is a block status, so an in-service block engages all Ni of its steps. BINIT is + # only the device's admittance where PSS/E would not have adjusted it. See issue #57. + file = joinpath(@__DIR__, "fixtures", "v35_switched_shunt.raw") + pm_data = PowerModelsData(file).data + @test pm_data["source_version"] == "35" + + shunts = values(pm_data["switched_shunt"]) + at(bus, id) = only( + v for v in shunts if v["shunt_bus"] == bus && strip(v["sw_id"]) == id + ) + + # MODSW=1 on a type 1 bus: BINIT (60 MVAr) is only a starting value, so it is dropped + # and the engaged blocks stand -- 3 steps of B1, none of the out-of-service B2. + adjusted = at(2, "1") + @test adjusted["control_mode"] == 1 + @test !haskey(adjusted, "solved_admittance") + @test adjusted["step_number"] == [3, 2] + @test adjusted["y_increment"] == [0.1im, 0.2im] + @test adjusted["number_engaged"] == [3, 0] + + # MODSW=0: locked at BINIT, which PSS/E never moves. + locked = at(2, "2") + @test locked["control_mode"] == 0 + @test locked["solved_admittance"] == 0.45 + @test locked["number_engaged"] == [2] + + # MODSW=2: the admittance is off the step ladder, so the blocks cannot express it. + continuous = at(2, "3") + @test continuous["control_mode"] == 2 + @test continuous["solved_admittance"] == 0.375 + + # A shunt on the type 3 (swing) bus is locked whatever MODSW says. + swing = at(1, "1") + @test swing["control_mode"] == 1 + @test swing["solved_admittance"] == 0.25 + + # The bus-3 record has N2=0, which terminates the block list: the nonzero B3 columns + # past it are not a third block. + terminated = at(3, "1") + @test terminated["step_number"] == [2] + @test terminated["y_increment"] == [0.08im] + @test terminated["number_engaged"] == [2] + + # Declaring the case solved takes BINIT at face value everywhere. + solved = PowerModelsData(file; solved_case = true).data + solved_at(bus, id) = only( + v for v in values(solved["switched_shunt"]) if + v["shunt_bus"] == bus && strip(v["sw_id"]) == id + ) + @test solved_at(2, "1")["solved_admittance"] == 0.6 + @test solved_at(3, "1")["solved_admittance"] == 0.16 + @test solved_at(2, "1")["number_engaged"] == [3, 0] +end + +@testset "PSSE switched shunt swing-bus rule survives a node-breaker split" begin + # The type 3 rule is about the RAW's own bus I, not the node-bus the shunt routes onto: + # `_prepare_node_breaker!` initializes those to PQ, and only a generator promotes one + # back -- in a pass that runs after this section. + raw = read_fixture(joinpath(@__DIR__, "fixtures", "synthetic_v35_node_breaker.raw")) + + # Make the substation's bus 2 the swing bus, leaving bus 1 PV with its generator, and + # put a MODSW=1 switched shunt on node 2 -- not the representative node, so it lands on + # an injected node-bus rather than on bus 2 itself. + patched = replace( + raw, + " 1,'BUSONE ', 138.0000,3," => " 1,'BUSONE ', 138.0000,2,", + " 2,'BUSTWO ', 138.0000,1," => " 2,'BUSTWO ', 138.0000,3,", + "0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA\n" => + "0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA\n" * + " 2,'1 ', 1, 0, 1, 1.05000, 0.95000, 0, 0, 100.0," * + "' ', 30.000, 1, 2, 10.000\n", + " 0 / END OF SUBSTATION TERMINAL DATA" => " 2, 2, 'S', '1 '\n 0 / END OF SUBSTATION TERMINAL DATA", + ) + @test patched != raw + + pm_data = parse_file(IOBuffer(patched); filetype = "raw") + shunt = only(values(pm_data["switched_shunt"])) + + @test shunt["shunt_bus"] != 2 + @test pm_data["bus"][shunt["shunt_bus"]]["bus_type"] == PFP.PM_BUS_TYPE_PQ + @test pm_data["bus"][2]["bus_type"] == PFP.PM_BUS_TYPE_REF + @test shunt["control_mode"] == 1 + @test shunt["solved_admittance"] == 0.3 +end + @testset "PSSE pre-v35 switched shunts carry BINIT separately from the blocks" begin # Pre-v35 SWITCHED SHUNT records have no per-block status field, so how many steps of # each block are engaged is unknown and `number_engaged` is zero-filled to record that.