From b3e52420002f527dc5644a349b28214c91bab593 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Wed, 23 Sep 2026 12:32:30 -0700 Subject: [PATCH 1/2] Convert tap-ratio limits out of CW units with the ratio they bracket RMA/RMI bracket a winding's turns ratio in the record's CW units. The ratio itself was converted to per unit, but the limits were passed through as read, so under CW=2 a per-unit tap sat against a band in kV. Both transformer paths now build the factor that maps a CW-unit WINDV onto the modeled ratio and apply that one factor to the ratio and to its limits: the 2W `tap` (which also divides by WINDV2) and each 3W winding's turns ratio. The phase-shift objectives (|COD| 3 or 5) bracket an angle in degrees and are left as read. `ext` keeps the record's own values. --- src/pm_io/psse.jl | 74 ++++++++++--------- ...nthetic_v35_transformer_discriminators.raw | 2 +- ...test_openapi_transformer_discriminators.jl | 9 +++ test/test_parse_psse.jl | 71 ++++++++++++++++++ 4 files changed, 120 insertions(+), 36 deletions(-) diff --git a/src/pm_io/psse.jl b/src/pm_io/psse.jl index a9de6c7..61a3b87 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"] 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 From 331a73b2a68c3c284bd17d33b9c885fe5c011321 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Wed, 23 Sep 2026 12:35:53 -0700 Subject: [PATCH 2/2] Drop substations that declare no nodes during the PowerModels conversion A SUBSTATION record with an empty node block attaches to no bus, so it has no bus-branch representation. `_psse2pm_substation_data!` emitted it anyway, and the OpenAPI reader, which associates each substation with the buses its nodes name, rejected the whole file. The conversion now skips such records and reports the count once. A skipped record that still carries switching devices or terminals references nodes that do not exist, so it also gets its own warning. pti.jl keeps every record, and `read_substations!` keeps its throw as an invariant check. --- src/pm_io/psse.jl | 16 ++++++++++ test/test_parse_substation.jl | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/pm_io/psse.jl b/src/pm_io/psse.jl index 61a3b87..a2da607 100644 --- a/src/pm_io/psse.jl +++ b/src/pm_io/psse.jl @@ -2632,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") @@ -2678,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/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