From baa3976521ae7f6b4ab8d756f018325b67ed98ef Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 9 Sep 2026 14:20:05 -0600 Subject: [PATCH] Parse BINIT into solved_admittance, rename initial_status to number_engaged Parser half of the SwitchedAdmittance changes in PowerSystems.jl#1774, following Sienna-Platform/SiennaSchemas#44 (merged), which added `solved_admittance` and renamed `initial_status` to `number_engaged`. BINIT was being parsed into `bs`, which `shunt.jl` writes to `Y`. A PSS/E switched shunt has no fixed base admittance -- the SWITCHED SHUNT record carries only BINIT (the solved/initial total susceptance) and the per-block increments -- so putting the solved total into `Y` forced everything downstream to disentangle the two again. Pre-v35 did it by zeroing a full-length block-status vector so the blocks could not double-count what BINIT already included; that zeroed vector then became the sentinel PowerFlows.jl read to decide which convention a shunt followed. BINIT now goes to its own `solved_admittance` key with `bs = 0.0`, so "solved total" and "fixed base + blocks" are distinguishable without inference, and the pre-v35 zero-fill means what it says: no per-block information. v35's S1..S8 keeps parsing to `number_engaged`. Three details worth review: - `_make_per_unit!` gains `solved_admittance` alongside `bs`/`y_increment`. BINIT is an admittance and must be per-unitized with them; without it the value reaches the component a factor of baseMVA too large. - `device_base.jl` classifies `("SwitchedAdmittance", :solved_admittance)` as `:skip`, matching `Y`/`Y_increase`/`admittance_limits`. It inherits the `admittance_units` discriminator, which is a representation switch rather than a natural-unit choice, and PSY's `to_openapi` scales it by the SYSTEM base in both document conventions. The registry is total, so omitting it errored by construction rather than silently mis-scaling a COMPONENT_BASE document. - `solved_admittance` is populated unconditionally, since a SWITCHED SHUNT record always carries BINIT (pti.jl defaults it to 0.0). A parsed shunt therefore always has a solved admittance and downstream reads it instead of summing blocks -- which is what PSS/E does with the value. Gating on MODSW == 0 is the alternative reading of the manual's "locked" clause. Tests updated: the pre-v35 testset now pins `gs == bs == 0.0` and a per-unitized `solved_admittance`, and the OpenAPI shunt test asserts `Y` is zero with BINIT in `solved_admittance` rather than `Y.imag == 50.0`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LP6eB1zx4eyE3hd7tpvSue --- src/openapi/device_base.jl | 4 ++++ src/openapi/shunt.jl | 15 +++++++++++++-- src/pm_io/data.jl | 3 +++ src/pm_io/psse.jl | 26 +++++++++++++++++--------- test/test_openapi_dc_shunt.jl | 7 +++++-- test/test_parse_psse.jl | 21 ++++++++++++++------- 6 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/openapi/device_base.jl b/src/openapi/device_base.jl index 4df1c96..a89609e 100644 --- a/src/openapi/device_base.jl +++ b/src/openapi/device_base.jl @@ -138,6 +138,10 @@ const _DEVICEBASE_INSTANCE_DISPATCHED = Dict{Tuple{String, Symbol}, Symbol}( ("SwitchedAdmittance", :Y) => :skip, ("SwitchedAdmittance", :Y_increase) => :skip, ("SwitchedAdmittance", :admittance_limits) => :skip, + # BINIT (PowerSystems.jl#1774) is the same COMPONENT_MVAR-on-system-base quantity as `Y` + # and `Y_increase` above -- PSY's own to_openapi scales it by the SYSTEM base in both + # document conventions -- so it takes their classification, not a device-base conversion. + ("SwitchedAdmittance", :solved_admittance) => :skip, # parameter_units/dc_voltage_units/admittance_units always "NATURAL_UNITS" for the # PSS/E-native LCC/VSC fields (dc_branch.jl) -- fixed ohm/kV/S regardless of the # run's power_units, the mirror image of the COMPONENT_BASE cases above. diff --git a/src/openapi/shunt.jl b/src/openapi/shunt.jl index a419b5d..c93a6ab 100644 --- a/src/openapi/shunt.jl +++ b/src/openapi/shunt.jl @@ -122,8 +122,19 @@ function make_switched_admittance!( Int(get(d, "regulated_bus_number", 0)), "1", ) - if haskey(d, "initial_status") - set_value!(component, :initial_status, d["initial_status"]) + if haskey(d, "number_engaged") + set_value!(component, :number_engaged, d["number_engaged"]) + end + # PSS/E BINIT. Always present in a SWITCHED SHUNT record (pti.jl defaults it to 0.0), so + # a parsed switched shunt always carries a solved admittance and downstream reads it + # rather than summing blocks -- which is what PSS/E itself does with the value. + if haskey(d, "solved_admittance") + set_value!( + component, + :solved_admittance, + d["solved_admittance"] * base_power, + "MVAr", + ) end add_component!(sys, component) set_component_ext!(sys, component, get(d, "ext", Dict{String, Any}())) diff --git a/src/pm_io/data.jl b/src/pm_io/data.jl index 17de7ac..0f05c5a 100644 --- a/src/pm_io/data.jl +++ b/src/pm_io/data.jl @@ -340,6 +340,9 @@ function _make_per_unit!(data::Dict{String, <:Any}, mva_base::Real) _apply_func!(sw_shunt, "gs", rescale) _apply_func!(sw_shunt, "bs", rescale) _apply_func!(sw_shunt, "y_increment", rescale) + # BINIT is an admittance like the two above and must be per-unitized with them; + # skipping it here would leave it a factor of baseMVA too large downstream. + _apply_func!(sw_shunt, "solved_admittance", rescale) end end diff --git a/src/pm_io/psse.jl b/src/pm_io/psse.jl index b3b54e7..f264b25 100644 --- a/src/pm_io/psse.jl +++ b/src/pm_io/psse.jl @@ -897,7 +897,14 @@ function _psse2pm_shunt!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) get(sub_data, "sw_id", "1"), ) sub_data["gs"] = 0.0 - sub_data["bs"] = pop!(switched_shunt, "BINIT") + # 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. + sub_data["bs"] = 0.0 + sub_data["solved_admittance"] = pop!(switched_shunt, "BINIT") sub_data["status"] = _determine_injector_status( switched_shunt, pm_data, @@ -944,18 +951,19 @@ function _psse2pm_shunt!(pm_data::Dict, pti_data::Dict, import_all::Bool, nb) ) initial_ss_status_sorted = sort(collect(keys(initial_ss_status)); by = x -> parse(Int, x[2:end])) - sub_data["initial_status"] = + sub_data["number_engaged"] = [initial_ss_status[k] for k in initial_ss_status_sorted] - sub_data["initial_status"] = - sub_data["initial_status"][1:length(sub_data["step_number"])] + sub_data["number_engaged"] = + sub_data["number_engaged"][1:length(sub_data["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. BINIT - # already holds the total in-service admittance, which `bs` above passes - # on, so every block must start out of service whatever MODSW says — - # counting any of them in would double-count that same admittance. - sub_data["initial_status"] = zeros(Int, length(sub_data["y_increment"])) + # 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"])) else error("Unsupported PSS(R)E source version: $(pm_data["source_version"])") end diff --git a/test/test_openapi_dc_shunt.jl b/test/test_openapi_dc_shunt.jl index fc34569..7337f01 100644 --- a/test/test_openapi_dc_shunt.jl +++ b/test/test_openapi_dc_shunt.jl @@ -101,13 +101,16 @@ end ) # The fixture's bus-101 SWITCHED SHUNT record declares BINIT = 50.00 and B1 = 100.00, # so both must read back in the RAW's own MVAr rather than the pm dict's 0.5/1.0 pu. - @test _matches_nt(PFP.get_value(shunt, :Y), (real = 0.0, imag = 50.0)) + # BINIT is the SOLVED admittance, not a fixed base: it lands in `solved_admittance` and + # `Y` stays zero, since a PSS/E switched shunt has no fixed base term (#1774). + @test _matches_nt(PFP.get_value(shunt, :Y), (real = 0.0, imag = 0.0)) + @test PFP.get_value(shunt, :solved_admittance) == 50.0 @test only(y_increase).imag == 100.0 @test _matches_nt( PFP.get_value(shunt, :admittance_limits), (min = d["admittance_limits"][1], max = d["admittance_limits"][2]), ) - @test PFP.get_value(shunt, :initial_status) == d["initial_status"] + @test PFP.get_value(shunt, :number_engaged) == d["number_engaged"] end @testset "_switched_admittance_control_mode rejects an unrecognized MODSW code" begin diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index 312cdb9..c292756 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -146,11 +146,12 @@ end @test !haskey(pm_v33["bus"][3], "area_slack") end -@testset "PSSE pre-v35 switched shunt blocks start out of service" begin - # Pre-v35 SWITCHED SHUNT records have no per-block status field, so the parser has to - # fabricate one. BINIT already carries the total in-service admittance into `bs`, so - # every block must start at zero whatever MODSW says; an in-service block would - # double-count the admittance BINIT has already contributed. +@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. + # BINIT is the device's actual admittance and now lands in its own `solved_admittance` + # key rather than in `bs`, so nothing has to be reconstructed from the blocks and there + # is no double-count to avoid. See PowerSystems.jl#1774. raw = read_fixture(FOURTEEN_BUS_FIXTURE) pm_data = parse_file(IOBuffer(raw); filetype = "raw") @test pm_data["source_version"] == "33" @@ -158,7 +159,11 @@ end shunts = collect(values(pm_data["switched_shunt"])) @test !isempty(shunts) for shunt in shunts - @test shunt["initial_status"] == zeros(Int, length(shunt["y_increment"])) + @test shunt["number_engaged"] == zeros(Int, length(shunt["y_increment"])) + # BINIT no longer masquerades as a fixed base admittance. + @test shunt["gs"] == 0.0 + @test shunt["bs"] == 0.0 + @test haskey(shunt, "solved_admittance") end # Bus 101's record is MODSW=1, which an earlier mode-specific patch already zeroed. @@ -171,7 +176,9 @@ end @test shunt_101["control_mode"] == 3 @test shunt_101["step_number"] == [5] @test length(shunt_101["y_increment"]) == 1 - @test shunt_101["initial_status"] == [0] + @test shunt_101["number_engaged"] == [0] + # BINIT = 50 MVAr on a 100 MVA base, per-unitized alongside bs/y_increment. + @test shunt_101["solved_admittance"] == 0.5 end @testset "PSSE transformer CM=2 magnetizing susceptance is inductive" begin