diff --git a/src/pm_io/psse.jl b/src/pm_io/psse.jl index a9de6c7..a2da607 100644 --- a/src/pm_io/psse.jl +++ b/src/pm_io/psse.jl @@ -1050,6 +1050,23 @@ function apply_tap_correction!( return windv_value end +""" +Return winding `winding`'s `(RMI, RMA)` in the units of its modeled turns ratio, where +`turns_ratio_scale` maps the record's CW-unit WINDV onto that ratio. Phase-shift objectives +(|COD| 3 or 5) bracket an angle in degrees instead, which CW does not touch. +""" +function _tap_ratio_limits( + transformer::Dict{String, Any}, + winding::Int, + turns_ratio_scale::Float64, +) + rmi, rma = transformer["RMI$winding"], transformer["RMA$winding"] + if abs(transformer["COD$winding"]) ∈ (3, 5) + return rmi, rma + end + return rmi * turns_ratio_scale, rma * turns_ratio_scale +end + # Two-winding records name their buses "f_bus"/"t_bus", three-winding ones # "bus_primary"/"bus_secondary"/"bus_tertiary"; both reach the warning below. function _transformer_bus_label(sub_data::Dict) @@ -1340,11 +1357,13 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, transformer["CW"], "primary", ) - sub_data["tap"] = windv1 / pop!(transformer, "WINDV2") + # Maps a winding 1 turns ratio in CW units onto the modeled tap, so the + # RMA1/RMI1 that bracket WINDV1 bracket `tap` in the same units. + tap_scale = 1.0 / pop!(transformer, "WINDV2") sub_data["shift"] = pop!(transformer, "ANG1") if transformer["CW"] != 1 # NOT "for off-nominal turns ratio in pu of winding bus base voltage" - sub_data["tap"] *= + tap_scale *= _get_bus_value(transformer["J"], "base_kv", pm_data) / _get_bus_value(transformer["I"], "base_kv", pm_data) if transformer["CW"] == 3 # "for off-nominal turns ratio in pu of nominal winding voltage, NOMV1, NOMV2 and NOMV3." @@ -1362,10 +1381,10 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, winding2_nominal_voltage = transformer["NOMV2"] end - sub_data["tap"] *= - winding1_nominal_voltage / winding2_nominal_voltage + tap_scale *= winding1_nominal_voltage / winding2_nominal_voltage end end + sub_data["tap"] = windv1 * tap_scale if import_all sub_data["cw"] = transformer["CW"] @@ -1395,8 +1414,8 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, sub_data["index"] = length(pm_data["branch"]) + 1 sub_data["COD1"] = transformer["COD1"] sub_data["CONT1"] = transformer["CONT1"] - sub_data["RMA1"] = transformer["RMA1"] - sub_data["RMI1"] = transformer["RMI1"] + sub_data["RMI1"], sub_data["RMA1"] = + _tap_ratio_limits(transformer, 1, tap_scale) sub_data["VMA1"] = transformer["VMA1"] sub_data["VMI1"] = transformer["VMI1"] sub_data["NTP1"] = transformer["NTP1"] @@ -1805,34 +1824,19 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, "tertiary", ) - if transformer["CW"] == 1 - sub_data["primary_turns_ratio"] = windv1 - sub_data["secondary_turns_ratio"] = windv2 - sub_data["tertiary_turns_ratio"] = windv3 - elseif transformer["CW"] == 2 - sub_data["primary_turns_ratio"] = - windv1 / _get_bus_value(transformer["I"], "base_kv", pm_data) - sub_data["secondary_turns_ratio"] = - windv2 / _get_bus_value(transformer["J"], "base_kv", pm_data) - sub_data["tertiary_turns_ratio"] = - windv3 / _get_bus_value(transformer["K"], "base_kv", pm_data) - else + # Maps each winding's turns ratio in CW units onto its per-unit ratio, so + # that winding's RMA/RMI bracket its turns ratio in the same units. + winding_buses = (transformer["I"], transformer["J"], transformer["K"]) + turns_ratio_scales = ntuple(3) do i + transformer["CW"] == 1 && return 1.0 + bus_base_kv = _get_bus_value(winding_buses[i], "base_kv", pm_data) + transformer["CW"] == 2 && return 1.0 / bus_base_kv @assert transformer["CW"] == 3 - sub_data["primary_turns_ratio"] = - windv1 * ( - sub_data["base_voltage_primary"] / - _get_bus_value(transformer["I"], "base_kv", pm_data) - ) - sub_data["secondary_turns_ratio"] = - windv2 * ( - sub_data["base_voltage_secondary"] / - _get_bus_value(transformer["J"], "base_kv", pm_data) - ) - sub_data["tertiary_turns_ratio"] = - windv3 * ( - sub_data["base_voltage_tertiary"] / - _get_bus_value(transformer["K"], "base_kv", pm_data) - ) + return sub_data["base_voltage_$(WINDING_NAMES[i])"] / bus_base_kv + end + for (i, windv) in enumerate((windv1, windv2, windv3)) + sub_data["$(WINDING_NAMES[i])_turns_ratio"] = + windv * turns_ratio_scales[i] end sub_data["circuit"] = strip(transformer["CKT"]) sub_data["COD1"] = transformer["COD1"] @@ -1840,8 +1844,8 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, sub_data["COD3"] = transformer["COD3"] for i in 1:3 sub_data["CONT$i"] = transformer["CONT$i"] - sub_data["RMA$i"] = transformer["RMA$i"] - sub_data["RMI$i"] = transformer["RMI$i"] + sub_data["RMI$i"], sub_data["RMA$i"] = + _tap_ratio_limits(transformer, i, turns_ratio_scales[i]) sub_data["VMA$i"] = transformer["VMA$i"] sub_data["VMI$i"] = transformer["VMI$i"] sub_data["NTP$i"] = transformer["NTP$i"] @@ -2628,7 +2632,20 @@ function _psse2pm_substation_data!(pm_data::Dict, pti_data::Dict, import_all::Bo return end + # A substation declaring no nodes attaches to no bus, so it has nothing to contribute to + # the bus-branch model; the record stays in `pti_data`. + nodeless_count = 0 for substation in pti_data["SUBSTATION DATA"] + if isempty(substation["NODES"]) + nodeless_count += 1 + n_devices = length(substation["SWITCHING DEVICES"]) + n_terminals = length(substation["TERMINALS"]) + if n_devices + n_terminals > 0 + @warn "Substation $(substation["IS"]) declares no nodes but has $n_devices switching device(s) and $n_terminals terminal(s); dropping it." + end + continue + end + sub_data = Dict{String, Any}() sub_data["number"] = pop!(substation, "IS") sub_data["name"] = pop!(substation, "NAME") @@ -2674,6 +2691,9 @@ function _psse2pm_substation_data!(pm_data::Dict, pti_data::Dict, import_all::Bo push!(pm_data["substation"], sub_data) end + if nodeless_count > 0 + @info "Dropped $nodeless_count PSS(R)E substation(s) that declare no nodes and so attach to no bus." + end return end diff --git a/test/fixtures/synthetic_v35_transformer_discriminators.raw b/test/fixtures/synthetic_v35_transformer_discriminators.raw index c73e616..f07c613 100644 --- a/test/fixtures/synthetic_v35_transformer_discriminators.raw +++ b/test/fixtures/synthetic_v35_transformer_discriminators.raw @@ -28,7 +28,7 @@ Two 2W transformers with device base (SBASE1-2) != system base (100 MVA): T1 CZ= 0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA 201, 202, 0, '1 ', 2, 2, 1, 0.001, 0.002, 2, 'XFMR_T1 ', 1, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, ' ' 0.01, 0.05, 50.0 -105.0, 0.0, 0.0, 40.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0.0, 0.0, 0.0 +105.0, 0.0, 0.0, 40.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0, 0, 110.0, 90.0, 1.1, 0.9, 33, 0, 0.0, 0.0, 0.0 21.0, 0.0 203, 204, 0, '1 ', 3, 3, 2, 80000.0, 0.005, 2, 'XFMR_T2 ', 1, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, ' ' 800.0, 0.05, 80.0 diff --git a/test/test_openapi_transformer_discriminators.jl b/test/test_openapi_transformer_discriminators.jl index c119840..d66ed28 100644 --- a/test/test_openapi_transformer_discriminators.jl +++ b/test/test_openapi_transformer_discriminators.jl @@ -53,6 +53,11 @@ _transformer_discriminator_pm_data() = # exercised via T2 below, whose ratio does NOT cancel. @test d["tap"] ≈ 1.0 + # RMA1/RMI1 = 110/90 kV bracket WINDV1 and take the same CW=2 conversion as the tap: + # (110/21) * (20/100) and (90/21) * (20/100). + @test d["RMA1"] ≈ 110.0 / 105.0 + @test d["RMI1"] ≈ 90.0 / 105.0 + # rate_a: RATE11 = 40.0 MVA raw, divided by sys_mbase (PowerModels' generic branch # per-unit correction, applied uniformly to every "branch" entry) = 0.4 system-pu. @test d["rate_a"] ≈ 40.0 / sys_mbase @@ -80,6 +85,10 @@ _transformer_discriminator_pm_data() = # 0.4*100=40.0 (wrong) vs 0.4*50=20.0 (right). @test PFP.get_value(circuit, :rating) ≈ d["rate_a"] * d["base_power"] @test PFP.get_value(circuit, :rating) ≈ 20.0 + @test _matches_nt( + PFP.get_value(circuit, :control_limits), + (min = d["RMI1"], max = d["RMA1"]), + ) end @testset "T2 (CZ=3, CW=3, CM=2, SBASE1-2=80 != sys_mbase=100): hand-derived br_r/br_x/g_fr/b_fr/tap/rating" begin diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index 386f5de..dde84ee 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -346,3 +346,74 @@ end @test transformer_3w["g"] == 0.0 @test transformer_3w["b"] == 0.0 end + +@testset "PSSE CW=2 tap-ratio limits are converted with the tap" begin + # Under CW=2 WINDV and the RMA/RMI that bracket it are winding voltages in kV. Both must + # reach the pm dict in the tap's own units, so a tap inside its band stays inside it. + raw = read_fixture(FOURTEEN_BUS_FIXTURE) + + # "TRAFO 2W 1" runs 230 kV bus 106 to 138 kV bus 105 and regulates voltage (COD1=1). + # WINDV1 = 232.875 kV sits on step 18 of the 33-step 207..253 kV ladder, and + # WINDV2 = 141.45 kV is 1.025 pu, so the tap and its band all divide by 1.025. + # "TRAFO 2W 3" is a CW=2 phase shifter (COD1=3), whose limits are degrees. + # "TRAFO 3W 1" winds 230/230/500 kV and exercises one objective per winding. + cw2 = replace( + raw, + " 106, 105, 0,'1 ',1,1,1, 0.00000E+0, 0.00000E+0,2,'TRAFO 2W 1 ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '\n" * + " 0.00000E+0, 1.00000E-4, 100.00\n" * + "1.00000, 0.000, 0.000, 0.00, 0.00, 0.00, 0, 0, 1.10000, 0.90000, 1.10000, 0.90000, 33, 7, 0.00000, 0.00000, 0.000\n" * + "1.00000, 0.000\n" => + " 106, 105, 0,'1 ',2,1,1, 0.00000E+0, 0.00000E+0,2,'TRAFO 2W 1 ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '\n" * + " 0.00000E+0, 1.00000E-4, 100.00\n" * + "232.875, 0.000, 0.000, 0.00, 0.00, 0.00, 1, 105, 253.000, 207.000, 1.10000, 0.90000, 33, 7, 0.00000, 0.00000, 0.000\n" * + "141.450, 0.000\n", + " 109, 104, 0,'1 ',1,1,1, 0.00000E+0, 0.00000E+0,2,'TRAFO 2W 3 ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '\n" * + " 0.00000E+0, 1.00000E-4, 100.00\n" * + "1.00000, 0.000, 0.000, 0.00, 0.00, 0.00, 0, 0, 1.10000, 0.90000, 1.10000, 0.90000, 33, 4, 0.00000, 0.00000, 0.000\n" * + "1.00000, 0.000\n" => + " 109, 104, 0,'1 ',2,1,1, 0.00000E+0, 0.00000E+0,2,'TRAFO 2W 3 ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '\n" * + " 0.00000E+0, 1.00000E-4, 100.00\n" * + "500.000, 0.000, 0.000, 0.00, 0.00, 0.00, 3, 0, 30.0000, -30.000, 1.10000, 0.90000, 33, 4, 0.00000, 0.00000, 0.000\n" * + "138.000, 0.000\n", + " 113, 110, 114,'1 ',1,1,1, 0.00000E+0, 0.00000E+0,2,'TRAFO 3W 1 ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '\n" * + " 0.00000E+0, 2.00000E-4, 100.00, 0.00000E+0, 2.00000E-4, 100.00, 0.00000E+0, 2.00000E-4, 100.00,0.99967, -3.0658\n" * + "1.00000, 0.000, 0.000, 0.00, 0.00, 0.00, 0, 0, 1.10000, 0.90000, 1.10000, 0.90000, 33, 9, 0.00000, 0.00000, 0.000\n" * + "1.00000, 0.000, 0.000, 0.00, 0.00, 0.00, 0, 0, 1.10000, 0.90000, 1.10000, 0.90000, 33, 8, 0.00000, 0.00000, 0.000\n" * + "1.00000, 0.000, 0.000, 0.00, 0.00, 0.00, 0, 0, 1.10000, 0.90000, 1.10000, 0.90000, 33, 9, 0.00000, 0.00000, 0.000\n" => + " 113, 110, 114,'1 ',2,1,1, 0.00000E+0, 0.00000E+0,2,'TRAFO 3W 1 ',1, 1,1.0000, 0,1.0000, 0,1.0000, 0,1.0000,' '\n" * + " 0.00000E+0, 2.00000E-4, 100.00, 0.00000E+0, 2.00000E-4, 100.00, 0.00000E+0, 2.00000E-4, 100.00,0.99967, -3.0658\n" * + "232.875, 0.000, 0.000, 0.00, 0.00, 0.00, 1, 113, 253.000, 207.000, 1.10000, 0.90000, 33, 9, 0.00000, 0.00000, 0.000\n" * + "230.000, 0.000, 0.000, 0.00, 0.00, 0.00, 0, 0, 253.000, 207.000, 1.10000, 0.90000, 33, 8, 0.00000, 0.00000, 0.000\n" * + "500.000, 0.000, 0.000, 0.00, 0.00, 0.00, 3, 0, 30.0000, -30.000, 1.10000, 0.90000, 33, 9, 0.00000, 0.00000, 0.000\n", + ) + @test cw2 != raw + pm_data = parse_file(IOBuffer(cw2); filetype = "raw") + by_name(section, name) = only( + v for v in values(pm_data[section]) if + get(get(v, "ext", Dict()), "psse_name", "") == name + ) + + regulating = by_name("branch", "TRAFO 2W 1 ") + @test regulating["tap"] ≈ 1.0125 / 1.025 + @test regulating["RMI1"] ≈ 0.9 / 1.025 + @test regulating["RMA1"] ≈ 1.1 / 1.025 + @test regulating["RMI1"] < regulating["tap"] < regulating["RMA1"] + # ext keeps the record's own values. + @test regulating["ext"]["RMA1"] == 253.0 + + phase_shifter = by_name("branch", "TRAFO 2W 3 ") + @test phase_shifter["tap"] ≈ 1.0 + @test phase_shifter["RMI1"] == -30.0 + @test phase_shifter["RMA1"] == 30.0 + + transformer_3w = by_name("3w_transformer", "TRAFO 3W 1 ") + @test transformer_3w["primary_turns_ratio"] ≈ 1.0125 + @test transformer_3w["RMI1"] ≈ 0.9 + @test transformer_3w["RMA1"] ≈ 1.1 + @test transformer_3w["secondary_turns_ratio"] ≈ 1.0 + @test transformer_3w["RMI2"] ≈ 0.9 + @test transformer_3w["RMA2"] ≈ 1.1 + @test transformer_3w["tertiary_turns_ratio"] ≈ 1.0 + @test transformer_3w["RMI3"] == -30.0 + @test transformer_3w["RMA3"] == 30.0 +end diff --git a/test/test_parse_substation.jl b/test/test_parse_substation.jl index 4d38f57..728d146 100644 --- a/test/test_parse_substation.jl +++ b/test/test_parse_substation.jl @@ -1280,3 +1280,63 @@ end PowerModelsData(file) ) end + +@testset "a substation declaring no nodes has no PowerModels representation" begin + # A SUBSTATION record whose node block is empty attaches to no bus. pti.jl keeps it, + # but it has nothing to contribute to the pm dict, so the conversion drops it. + raw = read_fixture(V35_SUBSTATION_FIXTURE) + nodeless = + "@! BEGIN SUBSTATION DATA BLOCK\n" * + " 3,'CHARLIE ', 0.0000000, 0.0000000, 0.0000\n" * + " 0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA\n" * + " 0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION TERMINAL DATA\n" * + " 0 / END OF SUBSTATION TERMINAL DATA\n" + patched = replace( + raw, + "0 / END OF SUBSTATION DATA\n" => nodeless * "0 / END OF SUBSTATION DATA\n", + ) + @test patched != raw + path = joinpath(mktempdir(), "nodeless_substation.raw") + write(path, patched) + + @test length(PFP.parse_pti(path)["SUBSTATION DATA"]) == 3 + + pm = @test_logs( + (:info, r"Dropped 1 PSS\(R\)E substation\(s\) that declare no nodes"), + match_mode = :any, + PowerModelsData(path) + ) + substations = pm.data["substation"] + @test sort([s["number"] for s in values(substations)]) == [1, 2] + @test sort([s["index"] for s in values(substations)]) == [1, 2] + + sys = PFP.build_openapi_system(pm) + @test sort([ + PFP.get_value(s, :number) for + s in PFP.get_supplemental_attributes(sys, "Substation") + ]) == [1, 2] + + # Switching devices between nodes that do not exist are malformed rather than a + # placeholder, so dropping that record is announced on its own. + with_devices = + "@! BEGIN SUBSTATION DATA BLOCK\n" * + " 4,'DELTA ', 0.0000000, 0.0000000, 0.0000\n" * + " 0 / END OF SUBSTATION NODE DATA, BEGIN SUBSTATION SWITCHING DEVICE DATA\n" * + " 1, 2, '1 ','DELTA\$138\$CB\$0001 ', 2, 1, 1, 0.00010, 0.00, 0.00, 0.00\n" * + " 0 / END OF SUBSTATION SWITCHING DEVICE DATA, BEGIN SUBSTATION TERMINAL DATA\n" * + " 0 / END OF SUBSTATION TERMINAL DATA\n" + write( + path, + replace( + patched, + "0 / END OF SUBSTATION DATA\n" => with_devices * "0 / END OF SUBSTATION DATA\n", + ), + ) + pm = @test_logs( + (:warn, r"Substation 4 declares no nodes but has 1 switching device\(s\)"), + (:info, r"Dropped 2 PSS\(R\)E substation\(s\) that declare no nodes"), + match_mode = :any, + PowerModelsData(path) + ) + @test sort([s["number"] for s in values(pm.data["substation"])]) == [1, 2] +end