From 624516aeccfd70f18d17003225c03d87ce470f28 Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Tue, 9 Jun 2026 14:16:48 -0600 Subject: [PATCH 1/9] feat: first approach on addressing parallel branch type issues for network reductions --- src/PowerSystems.jl | 1 + src/base.jl | 1 + src/definitions.jl | 8 ++++ src/parsers/power_models_data.jl | 78 +++++++++++++++++++++++++++++++- src/utils/IO/system_checks.jl | 49 ++++++++++++++++++++ 5 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/PowerSystems.jl b/src/PowerSystems.jl index a28c739223..b8e3df97b5 100644 --- a/src/PowerSystems.jl +++ b/src/PowerSystems.jl @@ -611,6 +611,7 @@ export check export check_component export check_components export check_ac_transmission_rate_values +export check_parallel_branch_type_consistency # From IS logging.jl, generate_struct_files.jl export configure_logging diff --git a/src/base.jl b/src/base.jl index 3ec7c538e3..5a773f4f02 100644 --- a/src/base.jl +++ b/src/base.jl @@ -2340,6 +2340,7 @@ function check(sys::System) critical_components_check(sys) adequacy_check(sys) check_subsystems(sys) + check_parallel_branch_type_consistency(sys) return end diff --git a/src/definitions.jl b/src/definitions.jl index 2207dcd86f..1761f5108f 100644 --- a/src/definitions.jl +++ b/src/definitions.jl @@ -579,6 +579,14 @@ const PARSER_TAP_RATIO_CORRECTION_TOL = 1e-5 const ZERO_IMPEDANCE_REACTANCE_THRESHOLD = 1e-4 +# Tap-ratio tolerance below which a TapTransformer +# can be considered electrically equivalent to a Transformer2W +const IDENTITY_TAP_TOL = 1e-4 + +# Phase-angle shift tolerance (radians) below which a PhaseShiftingTransformer can be +# considered to have no real shift and is equivalent to TapTransformer/Transformer2W. +const ZERO_ANGLE_SHIFT_TOL = 1e-6 + const WINDING_NAMES = Dict( WindingCategory.PRIMARY_WINDING => "primary", WindingCategory.SECONDARY_WINDING => "secondary", diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index 0eb486a671..f7b8219cfa 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1205,9 +1205,23 @@ function get_branch_type_matpower( _add_vector_control_group(d, "shift", "group_number") + is_identity_tap = abs(tap - 1.0) <= IDENTITY_TAP_TOL || tap == 0.0 + is_zero_shift = abs(shift) <= ZERO_ANGLE_SHIFT_TOL + if d["group_number"] == WindingGroupNumber.UNDEFINED + # Degenerate PST: no recognisable shift → demote + if is_zero_shift + @warn "PhaseShiftingTransformer with near-zero shift ($(rad2deg(shift))°) normalised to $(is_identity_tap ? "Transformer2W" : "TapTransformer")" _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + return is_identity_tap ? Transformer2W : TapTransformer + end return PhaseShiftingTransformer elseif tap != 1.0 + if is_identity_tap + @warn "TapTransformer with near-identity tap ($(tap)) normalised to Transformer2W" _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + return Transformer2W + end return TapTransformer else return Transformer2W @@ -1223,6 +1237,7 @@ function get_branch_type_psse( is_transformer = d["transformer"] tap = d["tap"] + shift = get(d, "shift", 0.0) if !is_transformer if (tap != 0.0) && (tap != 1.0) @@ -1236,10 +1251,26 @@ function get_branch_type_psse( _add_vector_control_group(d, "shift", "group_number") is_tap_controllable, is_alpha_controllable = _determine_control_modes(d, "COD1", "tap") + + is_identity_tap = abs(tap - 1.0) <= IDENTITY_TAP_TOL || tap == 0.0 + is_zero_shift = abs(shift) <= ZERO_ANGLE_SHIFT_TOL + if d["group_number"] == WindingGroupNumber.UNDEFINED || is_alpha_controllable + # Degenerate PST: controllable but shift is effectively zero → demote + if is_zero_shift && !is_alpha_controllable + @warn "PhaseShiftingTransformer with near-zero shift ($(rad2deg(shift))°) normalised to $(is_identity_tap ? "Transformer2W" : "TapTransformer")" _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + return is_identity_tap ? Transformer2W : TapTransformer + end return PhaseShiftingTransformer elseif (is_tap_controllable || (tap != 1.0)) && d["group_number"] != WindingGroupNumber.UNDEFINED + # Normalise TapTransformers whose tap is effectively 1.0 to Transformer2W + if is_identity_tap + @warn "TapTransformer with near-identity tap ($(tap)) normalised to Transformer2W" _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + return Transformer2W + end return TapTransformer elseif !is_tap_controllable && d["group_number"] != WindingGroupNumber.UNDEFINED return Transformer2W @@ -1248,15 +1279,47 @@ function get_branch_type_psse( end end +function _normalized_arc_key(f_bus::Int, t_bus::Int) + return f_bus <= t_bus ? (f_bus, t_bus) : (t_bus, f_bus) +end + +function _collect_parallel_branch_type_overrides(data::Dict{String, Any}) + overrides = Dict{Tuple{Int, Int}, DataType}() + if !haskey(data, "branch") || get(data, "source_type", "") != "pti" + return overrides + end + + branch_types_by_arc = Dict{Tuple{Int, Int}, Set{DataType}}() + for d in values(data["branch"]) + arc_key = _normalized_arc_key(d["f_bus"], d["t_bus"]) + branch_type = get_branch_type_psse(d) + push!(get!(branch_types_by_arc, arc_key, Set{DataType}()), branch_type) + end + + for (arc_key, branch_types) in branch_types_by_arc + if length(branch_types) > 1 && + all(t -> t in (Transformer2W, TapTransformer), branch_types) + overrides[arc_key] = TapTransformer + @warn "Normalizing mixed parallel transformer types on arc $(arc_key[1])-$(arc_key[2]) to TapTransformer for parser consistency." _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + end + end + + return overrides +end + function make_branch( name::String, d::Dict, bus_f::ACBus, bus_t::ACBus, source_type::String; + branch_type_override::Union{DataType, Nothing} = nothing, kwargs..., ) - if source_type == "matpower" + if !isnothing(branch_type_override) + branch_type = branch_type_override + elseif source_type == "matpower" branch_type = get_branch_type_matpower(d) elseif source_type == "pti" branch_type = get_branch_type_psse(d) @@ -1693,11 +1756,14 @@ function read_branch!( _get_name = get(kwargs, :branch_name_formatter, nothing) ict_instances = _impedance_correction_table_lookup(data) branch_pair_counts = Dict{Tuple{String, String}, Int}() + branch_type_overrides = _collect_parallel_branch_type_overrides(data) source_type = data["source_type"] for d in values(data["branch"]) bus_f = bus_number_to_bus[d["f_bus"]] bus_t = bus_number_to_bus[d["t_bus"]] + arc_key = _normalized_arc_key(d["f_bus"], d["t_bus"]) + branch_type_override = get(branch_type_overrides, arc_key, nothing) name = if isnothing(_get_name) if source_type == "pti" _get_pm_branch_name_with_counter!(d, bus_f, bus_t, branch_pair_counts) @@ -1707,7 +1773,15 @@ function read_branch!( else _get_name(d, bus_f, bus_t) end - value = make_branch(name, d, bus_f, bus_t, source_type; kwargs...) + value = make_branch( + name, + d, + bus_f, + bus_t, + source_type; + branch_type_override = branch_type_override, + kwargs..., + ) if !isnothing(value) add_component!(sys, value; skip_validation = SKIP_PM_VALIDATION) diff --git a/src/utils/IO/system_checks.jl b/src/utils/IO/system_checks.jl index 8ea7c46a1e..56520829a5 100644 --- a/src/utils/IO/system_checks.jl +++ b/src/utils/IO/system_checks.jl @@ -127,3 +127,52 @@ function total_capacity_rating(sys::System) @debug "Total System capacity: $total" _group = IS.LOG_GROUP_SYSTEM_CHECKS return total end + +""" + check_parallel_branch_type_consistency(sys::System) -> Int + +Scan all two-terminal AC branches for arcs that carry multiple branches of **different** +PSY types (e.g. a `Transformer2W` in parallel with a `TapTransformer`). Such mixed-type parallel +groups can introduce issues in the network reduction. + +# Example +```julia +n = check_parallel_branch_type_consistency(sys) +n == 0 || @warn "System has \$n arcs with mixed-type parallel branches" +``` +""" +function check_parallel_branch_type_consistency(sys::System) + # Arc key → [(branch_name, type_string)] + arc_entries = Dict{Tuple{Int, Int}, Vector{Tuple{String, String}}}() + for branch in get_components(ACBranch, sys) + hasmethod(get_arc, Tuple{typeof(branch)}) || continue + arc = get_arc(branch) + from_num = get_number(get_from(arc)) + to_num = get_number(get_to(arc)) + # Normalise orientation so (A,B) and (B,A) resolve to the same key + key = from_num <= to_num ? (from_num, to_num) : (to_num, from_num) + push!( + get!(arc_entries, key, Tuple{String, String}[]), + (get_name(branch), string(typeof(branch))), + ) + end + + n_mixed = 0 + for (arc_key, entries) in arc_entries + length(entries) < 2 && continue + types = unique(e[2] for e in entries) + length(types) == 1 && continue + n_mixed += 1 + names = join((e[1] for e in entries), ", ") + type_str = join(types, ", ") + @warn "Mixed-type parallel branches on arc $(arc_key[1])-$(arc_key[2]): [$names] " * + "with types [$type_str]. This may indicate incomplete or incorrect source data." _group = + IS.LOG_GROUP_SYSTEM_CHECKS maxlog = PS_MAX_LOG + end + if n_mixed > 0 + @warn "Found $n_mixed arc(s) with mixed-type parallel branches. " * + "Consider re-parsing with corrected source data or using `get_components` to inspect." _group = + IS.LOG_GROUP_SYSTEM_CHECKS + end + return n_mixed +end From cca9475110560c844d60acbe1b50c82468f58246 Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Tue, 9 Jun 2026 14:55:40 -0600 Subject: [PATCH 2/9] feat: add logic for line/switch-breaker conversion --- src/definitions.jl | 1 + src/parsers/power_models_data.jl | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/definitions.jl b/src/definitions.jl index 1761f5108f..a0456bfb5d 100644 --- a/src/definitions.jl +++ b/src/definitions.jl @@ -578,6 +578,7 @@ const PSSE_PARSER_TAP_RATIO_LBOUND = 0.5 const PARSER_TAP_RATIO_CORRECTION_TOL = 1e-5 const ZERO_IMPEDANCE_REACTANCE_THRESHOLD = 1e-4 +const ZERO_IMPEDANCE_RESISTANCE_THRESHOLD = 1e-6 # Tap-ratio tolerance below which a TapTransformer # can be considered electrically equivalent to a Transformer2W diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index f7b8219cfa..c1e729033f 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1283,6 +1283,11 @@ function _normalized_arc_key(f_bus::Int, t_bus::Int) return f_bus <= t_bus ? (f_bus, t_bus) : (t_bus, f_bus) end +function _is_near_zero_impedance_line(d::Dict) + return abs(d["br_r"]) <= ZERO_IMPEDANCE_RESISTANCE_THRESHOLD && + abs(d["br_x"]) <= ZERO_IMPEDANCE_REACTANCE_THRESHOLD +end + function _collect_parallel_branch_type_overrides(data::Dict{String, Any}) overrides = Dict{Tuple{Int, Int}, DataType}() if !haskey(data, "branch") || get(data, "source_type", "") != "pti" @@ -1290,10 +1295,19 @@ function _collect_parallel_branch_type_overrides(data::Dict{String, Any}) end branch_types_by_arc = Dict{Tuple{Int, Int}, Set{DataType}}() + has_line_by_arc = Dict{Tuple{Int, Int}, Bool}() + line_near_zero_by_arc = Dict{Tuple{Int, Int}, Bool}() for d in values(data["branch"]) arc_key = _normalized_arc_key(d["f_bus"], d["t_bus"]) branch_type = get_branch_type_psse(d) push!(get!(branch_types_by_arc, arc_key, Set{DataType}()), branch_type) + + if branch_type == Line + has_line_by_arc[arc_key] = true + is_near_zero = _is_near_zero_impedance_line(d) + line_near_zero_by_arc[arc_key] = + get(line_near_zero_by_arc, arc_key, true) && is_near_zero + end end for (arc_key, branch_types) in branch_types_by_arc @@ -1302,12 +1316,36 @@ function _collect_parallel_branch_type_overrides(data::Dict{String, Any}) overrides[arc_key] = TapTransformer @warn "Normalizing mixed parallel transformer types on arc $(arc_key[1])-$(arc_key[2]) to TapTransformer for parser consistency." _group = IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + elseif length(branch_types) > 1 && + all(t -> t in (Line, DiscreteControlledACBranch), branch_types) + all_lines_near_zero = + get(has_line_by_arc, arc_key, false) && + get(line_near_zero_by_arc, arc_key, false) + if all_lines_near_zero + overrides[arc_key] = DiscreteControlledACBranch + @warn "Normalizing mixed parallel Line/DiscreteControlledACBranch on near-zero-impedance arc $(arc_key[1])-$(arc_key[2]) to DiscreteControlledACBranch." _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + else + @warn "Keeping mixed parallel Line/DiscreteControlledACBranch on arc $(arc_key[1])-$(arc_key[2]) because at least one Line has non-negligible impedance." _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + end end end return overrides end +function _collect_existing_discrete_arc_keys(sys::System) + arc_keys = Set{Tuple{Int, Int}}() + for br in get_components(DiscreteControlledACBranch, sys) + arc = get_arc(br) + from_num = get_number(get_from(arc)) + to_num = get_number(get_to(arc)) + push!(arc_keys, _normalized_arc_key(from_num, to_num)) + end + return arc_keys +end + function make_branch( name::String, d::Dict, @@ -1757,6 +1795,7 @@ function read_branch!( ict_instances = _impedance_correction_table_lookup(data) branch_pair_counts = Dict{Tuple{String, String}, Int}() branch_type_overrides = _collect_parallel_branch_type_overrides(data) + existing_discrete_arc_keys = _collect_existing_discrete_arc_keys(sys) source_type = data["source_type"] for d in values(data["branch"]) @@ -1764,6 +1803,16 @@ function read_branch!( bus_t = bus_number_to_bus[d["t_bus"]] arc_key = _normalized_arc_key(d["f_bus"], d["t_bus"]) branch_type_override = get(branch_type_overrides, arc_key, nothing) + if isnothing(branch_type_override) && + source_type == "pti" && + (arc_key in existing_discrete_arc_keys) + inferred_branch_type = get_branch_type_psse(d) + if inferred_branch_type == Line && _is_near_zero_impedance_line(d) + branch_type_override = DiscreteControlledACBranch + @warn "Normalizing near-zero-impedance Line on arc $(arc_key[1])-$(arc_key[2]) to DiscreteControlledACBranch because a parallel switch/breaker arc already exists." _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + end + end name = if isnothing(_get_name) if source_type == "pti" _get_pm_branch_name_with_counter!(d, bus_f, bus_t, branch_pair_counts) @@ -1773,6 +1822,14 @@ function read_branch!( else _get_name(d, bus_f, bus_t) end + + if branch_type_override == DiscreteControlledACBranch && + has_component(DiscreteControlledACBranch, sys, name) + @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because DiscreteControlledACBranch '$name' already exists." _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + continue + end + value = make_branch( name, d, From cc34620d544f124aede47626f470c9f5e7212c6d Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Tue, 9 Jun 2026 15:21:40 -0600 Subject: [PATCH 3/9] fix: add branch availability when applying conversion --- src/parsers/power_models_data.jl | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index c1e729033f..983aa26480 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1288,6 +1288,29 @@ function _is_near_zero_impedance_line(d::Dict) abs(d["br_x"]) <= ZERO_IMPEDANCE_REACTANCE_THRESHOLD end +function _is_active_pti_branch(d::Dict) + return get(d, "br_status", 0) == 1 +end + +function _expected_discrete_state(d::Dict, bus_f::ACBus, bus_t::ACBus) + available = d["br_status"] == 1 + if get_bustype(bus_f) == ACBusTypes.ISOLATED || + get_bustype(bus_t) == ACBusTypes.ISOLATED + available = false + end + status = if available + DiscreteControlledBranchStatus.CLOSED + else + DiscreteControlledBranchStatus.OPEN + end + return available, status +end + +function _is_active_discrete_branch(br::DiscreteControlledACBranch) + return get_available(br) && + get_branch_status(br) == DiscreteControlledBranchStatus.CLOSED +end + function _collect_parallel_branch_type_overrides(data::Dict{String, Any}) overrides = Dict{Tuple{Int, Int}, DataType}() if !haskey(data, "branch") || get(data, "source_type", "") != "pti" @@ -1298,6 +1321,7 @@ function _collect_parallel_branch_type_overrides(data::Dict{String, Any}) has_line_by_arc = Dict{Tuple{Int, Int}, Bool}() line_near_zero_by_arc = Dict{Tuple{Int, Int}, Bool}() for d in values(data["branch"]) + _is_active_pti_branch(d) || continue arc_key = _normalized_arc_key(d["f_bus"], d["t_bus"]) branch_type = get_branch_type_psse(d) push!(get!(branch_types_by_arc, arc_key, Set{DataType}()), branch_type) @@ -1338,6 +1362,7 @@ end function _collect_existing_discrete_arc_keys(sys::System) arc_keys = Set{Tuple{Int, Int}}() for br in get_components(DiscreteControlledACBranch, sys) + _is_active_discrete_branch(br) || continue arc = get_arc(br) from_num = get_number(get_from(arc)) to_num = get_number(get_to(arc)) @@ -1805,6 +1830,7 @@ function read_branch!( branch_type_override = get(branch_type_overrides, arc_key, nothing) if isnothing(branch_type_override) && source_type == "pti" && + _is_active_pti_branch(d) && (arc_key in existing_discrete_arc_keys) inferred_branch_type = get_branch_type_psse(d) if inferred_branch_type == Line && _is_near_zero_impedance_line(d) @@ -1825,9 +1851,27 @@ function read_branch!( if branch_type_override == DiscreteControlledACBranch && has_component(DiscreteControlledACBranch, sys, name) - @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because DiscreteControlledACBranch '$name' already exists." _group = - IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG - continue + existing = get_component(DiscreteControlledACBranch, sys, name) + existing_arc = get_arc(existing) + existing_arc_key = _normalized_arc_key( + get_number(get_from(existing_arc)), + get_number(get_to(existing_arc)), + ) + expected_available, expected_status = _expected_discrete_state(d, bus_f, bus_t) + + if existing_arc_key == arc_key && + get_available(existing) == expected_available && + get_branch_status(existing) == expected_status + @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because equivalent DiscreteControlledACBranch '$name' already exists (same arc and operating state)." _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG + continue + else + throw( + DataFormatError( + "Name collision for DiscreteControlledACBranch '$name' on arc $(arc_key[1])-$(arc_key[2]) with non-equivalent operating state. Existing available=$(get_available(existing)), status=$(get_branch_status(existing)); expected available=$expected_available, status=$expected_status.", + ), + ) + end end value = make_branch( From 0cf823e18e81fc775e8384fa4621f701bc047907 Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Tue, 9 Jun 2026 18:10:03 -0600 Subject: [PATCH 4/9] fix: consider tap controllability when converting component --- src/parsers/power_models_data.jl | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index 983aa26480..858b960806 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1265,8 +1265,8 @@ function get_branch_type_psse( return PhaseShiftingTransformer elseif (is_tap_controllable || (tap != 1.0)) && d["group_number"] != WindingGroupNumber.UNDEFINED - # Normalise TapTransformers whose tap is effectively 1.0 to Transformer2W - if is_identity_tap + # Consider tap control capability when converting component + if is_identity_tap && !is_tap_controllable @warn "TapTransformer with near-identity tap ($(tap)) normalised to Transformer2W" _group = IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG return Transformer2W @@ -1430,7 +1430,7 @@ function _make_switch_from_zero_impedance_line( else status_value = DiscreteControlledBranchStatus.OPEN end - @warn "Branch $name has zero impedance and available = $available_value; converting to a DiscreteControlledACBranch of type SWITCH with available = $available_value and branch_status = $status_value" + @warn "Branch $name has zero or near-zero impedance and available = $available_value; converting to a DiscreteControlledACBranch of type SWITCH with available = $available_value and branch_status = $status_value" return DiscreteControlledACBranch(; name = name, available = Bool(available_value), @@ -1828,6 +1828,16 @@ function read_branch!( bus_t = bus_number_to_bus[d["t_bus"]] arc_key = _normalized_arc_key(d["f_bus"], d["t_bus"]) branch_type_override = get(branch_type_overrides, arc_key, nothing) + # The arc-level DiscreteControlledACBranch override was determined from active, + # near-zero Lines only. Do not apply it to inactive or non-near-zero Lines: + # converting them would lose their impedance data and silently turn a real line + # into an ideal switch if it were ever re-activated. + if !isnothing(branch_type_override) && + branch_type_override == DiscreteControlledACBranch && + source_type == "pti" && + !(_is_active_pti_branch(d) && _is_near_zero_impedance_line(d)) + branch_type_override = nothing + end if isnothing(branch_type_override) && source_type == "pti" && _is_active_pti_branch(d) && From c3bd767924c09aa88a5999b7cabcd7288cbd757d Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Wed, 10 Jun 2026 08:51:09 -0600 Subject: [PATCH 5/9] fix: resolve copilot comments on better parsing handler --- src/PowerSystems.jl | 1 - src/parsers/power_models_data.jl | 69 ++++++++++++++++++-------- src/utils/IO/system_checks.jl | 2 +- test/test_parse_matpower.jl | 38 ++++++++++++++ test/test_parse_psse.jl | 85 ++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 23 deletions(-) diff --git a/src/PowerSystems.jl b/src/PowerSystems.jl index b8e3df97b5..a28c739223 100644 --- a/src/PowerSystems.jl +++ b/src/PowerSystems.jl @@ -611,7 +611,6 @@ export check export check_component export check_components export check_ac_transmission_rate_values -export check_parallel_branch_type_consistency # From IS logging.jl, generate_struct_files.jl export configure_logging diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index 858b960806..2bd0428d66 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1205,7 +1205,7 @@ function get_branch_type_matpower( _add_vector_control_group(d, "shift", "group_number") - is_identity_tap = abs(tap - 1.0) <= IDENTITY_TAP_TOL || tap == 0.0 + is_identity_tap = abs(tap - 1.0) <= IDENTITY_TAP_TOL || iszero(tap) is_zero_shift = abs(shift) <= ZERO_ANGLE_SHIFT_TOL if d["group_number"] == WindingGroupNumber.UNDEFINED @@ -1231,7 +1231,7 @@ end function get_branch_type_psse( d::Dict, ) - if d["br_r"] == 0.0 && d["br_x"] == 0.0 + if _is_near_zero_impedance_line(d) return DiscreteControlledACBranch end @@ -1256,10 +1256,8 @@ function get_branch_type_psse( is_zero_shift = abs(shift) <= ZERO_ANGLE_SHIFT_TOL if d["group_number"] == WindingGroupNumber.UNDEFINED || is_alpha_controllable - # Degenerate PST: controllable but shift is effectively zero → demote + # Degenerate PST: unrecognized vector group with near-zero shift → demote if is_zero_shift && !is_alpha_controllable - @warn "PhaseShiftingTransformer with near-zero shift ($(rad2deg(shift))°) normalised to $(is_identity_tap ? "Transformer2W" : "TapTransformer")" _group = - IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG return is_identity_tap ? Transformer2W : TapTransformer end return PhaseShiftingTransformer @@ -1284,8 +1282,8 @@ function _normalized_arc_key(f_bus::Int, t_bus::Int) end function _is_near_zero_impedance_line(d::Dict) - return abs(d["br_r"]) <= ZERO_IMPEDANCE_RESISTANCE_THRESHOLD && - abs(d["br_x"]) <= ZERO_IMPEDANCE_REACTANCE_THRESHOLD + return abs(d["br_r"]) < ZERO_IMPEDANCE_RESISTANCE_THRESHOLD && + abs(d["br_x"]) < ZERO_IMPEDANCE_REACTANCE_THRESHOLD end function _is_active_pti_branch(d::Dict) @@ -1371,6 +1369,29 @@ function _collect_existing_discrete_arc_keys(sys::System) return arc_keys end +# Return the first existing DiscreteControlledACBranch on `arc_key` whose +# available/branch_status match the expected values, or `nothing` if none exists. +function _find_equivalent_discrete_on_arc( + sys::System, + arc_key::Tuple{Int, Int}, + expected_available::Bool, + expected_status, +) + for br in get_components(DiscreteControlledACBranch, sys) + br_arc = get_arc(br) + br_arc_key = _normalized_arc_key( + get_number(get_from(br_arc)), + get_number(get_to(br_arc)), + ) + if br_arc_key == arc_key && + get_available(br) == expected_available && + get_branch_status(br) == expected_status + return br + end + end + return nothing +end + function make_branch( name::String, d::Dict, @@ -1859,26 +1880,32 @@ function read_branch!( _get_name(d, bus_f, bus_t) end - if branch_type_override == DiscreteControlledACBranch && - has_component(DiscreteControlledACBranch, sys, name) - existing = get_component(DiscreteControlledACBranch, sys, name) - existing_arc = get_arc(existing) - existing_arc_key = _normalized_arc_key( - get_number(get_from(existing_arc)), - get_number(get_to(existing_arc)), - ) + if branch_type_override == DiscreteControlledACBranch expected_available, expected_status = _expected_discrete_state(d, bus_f, bus_t) - if existing_arc_key == arc_key && - get_available(existing) == expected_available && - get_branch_status(existing) == expected_status - @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because equivalent DiscreteControlledACBranch '$name' already exists (same arc and operating state)." _group = + # Arc-key scan: catches duplicates regardless of name (e.g. a @BRANCH zero-impedance + # record that physically duplicates a switch parsed from the @SWITCH section). + arc_match = _find_equivalent_discrete_on_arc( + sys, arc_key, expected_available, expected_status, + ) + if !isnothing(arc_match) + @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because equivalent DiscreteControlledACBranch '$(get_name(arc_match))' already exists (same arc and operating state)." _group = IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG continue - else + end + + # Name-collision guard: a DiscreteControlledACBranch with this exact name already + # exists but on a different arc or with a different state — this is a data error. + if has_component(DiscreteControlledACBranch, sys, name) + existing = get_component(DiscreteControlledACBranch, sys, name) + existing_arc = get_arc(existing) + existing_arc_key = _normalized_arc_key( + get_number(get_from(existing_arc)), + get_number(get_to(existing_arc)), + ) throw( DataFormatError( - "Name collision for DiscreteControlledACBranch '$name' on arc $(arc_key[1])-$(arc_key[2]) with non-equivalent operating state. Existing available=$(get_available(existing)), status=$(get_branch_status(existing)); expected available=$expected_available, status=$expected_status.", + "Name collision for DiscreteControlledACBranch '$name': a component with this name already exists on arc $(existing_arc_key[1])-$(existing_arc_key[2]) (available=$(get_available(existing)), status=$(get_branch_status(existing))), which differs from the incoming arc $(arc_key[1])-$(arc_key[2]) (expected available=$expected_available, status=$expected_status).", ), ) end diff --git a/src/utils/IO/system_checks.jl b/src/utils/IO/system_checks.jl index 56520829a5..26dbace1d4 100644 --- a/src/utils/IO/system_checks.jl +++ b/src/utils/IO/system_checks.jl @@ -145,7 +145,7 @@ function check_parallel_branch_type_consistency(sys::System) # Arc key → [(branch_name, type_string)] arc_entries = Dict{Tuple{Int, Int}, Vector{Tuple{String, String}}}() for branch in get_components(ACBranch, sys) - hasmethod(get_arc, Tuple{typeof(branch)}) || continue + branch isa ThreeWindingTransformer && continue arc = get_arc(branch) from_num = get_number(get_from(arc)) to_num = get_number(get_to(arc)) diff --git a/test/test_parse_matpower.jl b/test/test_parse_matpower.jl index 846c6b8077..4b29276b29 100644 --- a/test/test_parse_matpower.jl +++ b/test/test_parse_matpower.jl @@ -107,3 +107,41 @@ end @test_logs (:error,) match_mode = :any test_parse(path) end end + +@testset "Branch-type tolerance normalizations" begin + function _mp_branch_dict(; tap = 1.0, shift = 0.0, transformer = true) + return Dict{String, Any}( + "tap" => tap, + "shift" => shift, + "transformer" => transformer, + ) + end + + # Non-transformer → Line + d = _mp_branch_dict(; tap = 1.0, shift = 0.0, transformer = false) + @test PowerSystems.get_branch_type_matpower(d) == Line + + # Tap well away from 1.0, no shift → TapTransformer + d = _mp_branch_dict(; tap = 1.05, shift = 0.0) + @test PowerSystems.get_branch_type_matpower(d) == TapTransformer + + # Tap inside IDENTITY_TAP_TOL, no shift → Transformer2W + d = _mp_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, shift = 0.0) + @test PowerSystems.get_branch_type_matpower(d) == Transformer2W + + # Tap == 0.0 alias with no shift → Transformer2W + d = _mp_branch_dict(; tap = 0.0, shift = 0.0) + @test PowerSystems.get_branch_type_matpower(d) == Transformer2W + + # Real phase shift → PhaseShiftingTransformer + d = _mp_branch_dict(; tap = 1.0, shift = 0.5236) # ~30° + @test PowerSystems.get_branch_type_matpower(d) == PhaseShiftingTransformer + + # Near-zero shift, identity tap → Transformer2W + d = _mp_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2) + @test PowerSystems.get_branch_type_matpower(d) == Transformer2W + + # Near-zero shift, non-identity tap → TapTransformer + d = _mp_branch_dict(; tap = 1.05, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2) + @test PowerSystems.get_branch_type_matpower(d) == TapTransformer +end diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index 062139c7cc..ae805e744a 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -593,3 +593,88 @@ end @test !haskey(geo_json, "x") @test !haskey(geo_json, "y") end + +# Helper to build the minimal branch dict needed by get_branch_type_psse. +function _psse_branch_dict(; + br_r = 0.01, br_x = 0.1, transformer = true, tap = 1.0, + shift = 0.0, COD1 = 0, +) + return Dict{String, Any}( + "br_r" => br_r, + "br_x" => br_x, + "transformer" => transformer, + "tap" => tap, + "shift" => shift, + "COD1" => COD1, + ) +end + +@testset "Branch-type tolerance normalizations" begin + # Non-transformer → Line + d = _psse_branch_dict(; transformer = false, tap = 0.0, shift = 0.0) + @test PowerSystems.get_branch_type_psse(d) == Line + + # Exact zero impedance → DiscreteControlledACBranch + d_zero = _psse_branch_dict(; br_r = 0.0, br_x = 0.0, transformer = false, tap = 0.0) + @test PowerSystems.get_branch_type_psse(d_zero) == DiscreteControlledACBranch + + # Near-zero impedance within tolerance → DiscreteControlledACBranch + d_near_zero = _psse_branch_dict(; + br_r = PowerSystems.ZERO_IMPEDANCE_RESISTANCE_THRESHOLD / 2, + br_x = PowerSystems.ZERO_IMPEDANCE_REACTANCE_THRESHOLD / 2, + transformer = false, tap = 0.0, + ) + @test PowerSystems.get_branch_type_psse(d_near_zero) == DiscreteControlledACBranch + + # Non-controllable (COD1=0) TapTransformer with tap well away from 1.0 → TapTransformer + d = _psse_branch_dict(; tap = 1.05, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == TapTransformer + + # Non-controllable TapTransformer with tap inside IDENTITY_TAP_TOL → Transformer2W + d = _psse_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == Transformer2W + + # Controllable (COD1=1) TapTransformer whose current tap is near 1.0 must NOT be demoted + d = _psse_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, COD1 = 1) + @test PowerSystems.get_branch_type_psse(d) == TapTransformer + + # Tap == 0.0 (identity alias) with no control → Transformer2W + d = _psse_branch_dict(; tap = 0.0, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == Transformer2W + + # PST with shift well outside zero tolerance → PhaseShiftingTransformer + d = _psse_branch_dict(; tap = 1.0, shift = 0.5, COD1 = 3) + @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer + + # Non-alpha-controllable (COD1=0) PST with near-zero shift and identity tap → Transformer2W + d = _psse_branch_dict(; + tap = 1.0, + shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, + COD1 = 0, + ) + @test PowerSystems.get_branch_type_psse(d) == Transformer2W + + # Non-alpha-controllable PST with near-zero shift but non-identity tap → TapTransformer + d = _psse_branch_dict(; + tap = 1.05, + shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, + COD1 = 0, + ) + @test PowerSystems.get_branch_type_psse(d) == TapTransformer + + # Alpha-controllable (COD1=3) PST with near-zero shift must NOT be demoted + d = _psse_branch_dict(; + tap = 1.0, + shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, + COD1 = 3, + ) + @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer + + # Transformer with X exactly at the threshold (i.e., not strictly near-zero) + # must NOT be converted — this models real impedance-correction transformers + d_threshold_tr = _psse_branch_dict(; + br_r = 0.0, br_x = PowerSystems.ZERO_IMPEDANCE_REACTANCE_THRESHOLD, + transformer = true, tap = 0.0, + ) + @test PowerSystems.get_branch_type_psse(d_threshold_tr) != DiscreteControlledACBranch +end From 9a7f17707e59fe690d4780618cfcce3bdd3e4e15 Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Wed, 10 Jun 2026 09:42:56 -0600 Subject: [PATCH 6/9] Revert "fix: resolve copilot comments on better parsing handler" This reverts commit c3bd767924c09aa88a5999b7cabcd7288cbd757d. --- src/PowerSystems.jl | 1 + src/parsers/power_models_data.jl | 69 ++++++++------------------ src/utils/IO/system_checks.jl | 2 +- test/test_parse_matpower.jl | 38 -------------- test/test_parse_psse.jl | 85 -------------------------------- 5 files changed, 23 insertions(+), 172 deletions(-) diff --git a/src/PowerSystems.jl b/src/PowerSystems.jl index a28c739223..b8e3df97b5 100644 --- a/src/PowerSystems.jl +++ b/src/PowerSystems.jl @@ -611,6 +611,7 @@ export check export check_component export check_components export check_ac_transmission_rate_values +export check_parallel_branch_type_consistency # From IS logging.jl, generate_struct_files.jl export configure_logging diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index 2bd0428d66..858b960806 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1205,7 +1205,7 @@ function get_branch_type_matpower( _add_vector_control_group(d, "shift", "group_number") - is_identity_tap = abs(tap - 1.0) <= IDENTITY_TAP_TOL || iszero(tap) + is_identity_tap = abs(tap - 1.0) <= IDENTITY_TAP_TOL || tap == 0.0 is_zero_shift = abs(shift) <= ZERO_ANGLE_SHIFT_TOL if d["group_number"] == WindingGroupNumber.UNDEFINED @@ -1231,7 +1231,7 @@ end function get_branch_type_psse( d::Dict, ) - if _is_near_zero_impedance_line(d) + if d["br_r"] == 0.0 && d["br_x"] == 0.0 return DiscreteControlledACBranch end @@ -1256,8 +1256,10 @@ function get_branch_type_psse( is_zero_shift = abs(shift) <= ZERO_ANGLE_SHIFT_TOL if d["group_number"] == WindingGroupNumber.UNDEFINED || is_alpha_controllable - # Degenerate PST: unrecognized vector group with near-zero shift → demote + # Degenerate PST: controllable but shift is effectively zero → demote if is_zero_shift && !is_alpha_controllable + @warn "PhaseShiftingTransformer with near-zero shift ($(rad2deg(shift))°) normalised to $(is_identity_tap ? "Transformer2W" : "TapTransformer")" _group = + IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG return is_identity_tap ? Transformer2W : TapTransformer end return PhaseShiftingTransformer @@ -1282,8 +1284,8 @@ function _normalized_arc_key(f_bus::Int, t_bus::Int) end function _is_near_zero_impedance_line(d::Dict) - return abs(d["br_r"]) < ZERO_IMPEDANCE_RESISTANCE_THRESHOLD && - abs(d["br_x"]) < ZERO_IMPEDANCE_REACTANCE_THRESHOLD + return abs(d["br_r"]) <= ZERO_IMPEDANCE_RESISTANCE_THRESHOLD && + abs(d["br_x"]) <= ZERO_IMPEDANCE_REACTANCE_THRESHOLD end function _is_active_pti_branch(d::Dict) @@ -1369,29 +1371,6 @@ function _collect_existing_discrete_arc_keys(sys::System) return arc_keys end -# Return the first existing DiscreteControlledACBranch on `arc_key` whose -# available/branch_status match the expected values, or `nothing` if none exists. -function _find_equivalent_discrete_on_arc( - sys::System, - arc_key::Tuple{Int, Int}, - expected_available::Bool, - expected_status, -) - for br in get_components(DiscreteControlledACBranch, sys) - br_arc = get_arc(br) - br_arc_key = _normalized_arc_key( - get_number(get_from(br_arc)), - get_number(get_to(br_arc)), - ) - if br_arc_key == arc_key && - get_available(br) == expected_available && - get_branch_status(br) == expected_status - return br - end - end - return nothing -end - function make_branch( name::String, d::Dict, @@ -1880,32 +1859,26 @@ function read_branch!( _get_name(d, bus_f, bus_t) end - if branch_type_override == DiscreteControlledACBranch + if branch_type_override == DiscreteControlledACBranch && + has_component(DiscreteControlledACBranch, sys, name) + existing = get_component(DiscreteControlledACBranch, sys, name) + existing_arc = get_arc(existing) + existing_arc_key = _normalized_arc_key( + get_number(get_from(existing_arc)), + get_number(get_to(existing_arc)), + ) expected_available, expected_status = _expected_discrete_state(d, bus_f, bus_t) - # Arc-key scan: catches duplicates regardless of name (e.g. a @BRANCH zero-impedance - # record that physically duplicates a switch parsed from the @SWITCH section). - arc_match = _find_equivalent_discrete_on_arc( - sys, arc_key, expected_available, expected_status, - ) - if !isnothing(arc_match) - @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because equivalent DiscreteControlledACBranch '$(get_name(arc_match))' already exists (same arc and operating state)." _group = + if existing_arc_key == arc_key && + get_available(existing) == expected_available && + get_branch_status(existing) == expected_status + @warn "Skipping near-zero-impedance Line normalization on arc $(arc_key[1])-$(arc_key[2]) because equivalent DiscreteControlledACBranch '$name' already exists (same arc and operating state)." _group = IS.LOG_GROUP_PARSING maxlog = PS_MAX_LOG continue - end - - # Name-collision guard: a DiscreteControlledACBranch with this exact name already - # exists but on a different arc or with a different state — this is a data error. - if has_component(DiscreteControlledACBranch, sys, name) - existing = get_component(DiscreteControlledACBranch, sys, name) - existing_arc = get_arc(existing) - existing_arc_key = _normalized_arc_key( - get_number(get_from(existing_arc)), - get_number(get_to(existing_arc)), - ) + else throw( DataFormatError( - "Name collision for DiscreteControlledACBranch '$name': a component with this name already exists on arc $(existing_arc_key[1])-$(existing_arc_key[2]) (available=$(get_available(existing)), status=$(get_branch_status(existing))), which differs from the incoming arc $(arc_key[1])-$(arc_key[2]) (expected available=$expected_available, status=$expected_status).", + "Name collision for DiscreteControlledACBranch '$name' on arc $(arc_key[1])-$(arc_key[2]) with non-equivalent operating state. Existing available=$(get_available(existing)), status=$(get_branch_status(existing)); expected available=$expected_available, status=$expected_status.", ), ) end diff --git a/src/utils/IO/system_checks.jl b/src/utils/IO/system_checks.jl index 26dbace1d4..56520829a5 100644 --- a/src/utils/IO/system_checks.jl +++ b/src/utils/IO/system_checks.jl @@ -145,7 +145,7 @@ function check_parallel_branch_type_consistency(sys::System) # Arc key → [(branch_name, type_string)] arc_entries = Dict{Tuple{Int, Int}, Vector{Tuple{String, String}}}() for branch in get_components(ACBranch, sys) - branch isa ThreeWindingTransformer && continue + hasmethod(get_arc, Tuple{typeof(branch)}) || continue arc = get_arc(branch) from_num = get_number(get_from(arc)) to_num = get_number(get_to(arc)) diff --git a/test/test_parse_matpower.jl b/test/test_parse_matpower.jl index 4b29276b29..846c6b8077 100644 --- a/test/test_parse_matpower.jl +++ b/test/test_parse_matpower.jl @@ -107,41 +107,3 @@ end @test_logs (:error,) match_mode = :any test_parse(path) end end - -@testset "Branch-type tolerance normalizations" begin - function _mp_branch_dict(; tap = 1.0, shift = 0.0, transformer = true) - return Dict{String, Any}( - "tap" => tap, - "shift" => shift, - "transformer" => transformer, - ) - end - - # Non-transformer → Line - d = _mp_branch_dict(; tap = 1.0, shift = 0.0, transformer = false) - @test PowerSystems.get_branch_type_matpower(d) == Line - - # Tap well away from 1.0, no shift → TapTransformer - d = _mp_branch_dict(; tap = 1.05, shift = 0.0) - @test PowerSystems.get_branch_type_matpower(d) == TapTransformer - - # Tap inside IDENTITY_TAP_TOL, no shift → Transformer2W - d = _mp_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, shift = 0.0) - @test PowerSystems.get_branch_type_matpower(d) == Transformer2W - - # Tap == 0.0 alias with no shift → Transformer2W - d = _mp_branch_dict(; tap = 0.0, shift = 0.0) - @test PowerSystems.get_branch_type_matpower(d) == Transformer2W - - # Real phase shift → PhaseShiftingTransformer - d = _mp_branch_dict(; tap = 1.0, shift = 0.5236) # ~30° - @test PowerSystems.get_branch_type_matpower(d) == PhaseShiftingTransformer - - # Near-zero shift, identity tap → Transformer2W - d = _mp_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2) - @test PowerSystems.get_branch_type_matpower(d) == Transformer2W - - # Near-zero shift, non-identity tap → TapTransformer - d = _mp_branch_dict(; tap = 1.05, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2) - @test PowerSystems.get_branch_type_matpower(d) == TapTransformer -end diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index ae805e744a..062139c7cc 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -593,88 +593,3 @@ end @test !haskey(geo_json, "x") @test !haskey(geo_json, "y") end - -# Helper to build the minimal branch dict needed by get_branch_type_psse. -function _psse_branch_dict(; - br_r = 0.01, br_x = 0.1, transformer = true, tap = 1.0, - shift = 0.0, COD1 = 0, -) - return Dict{String, Any}( - "br_r" => br_r, - "br_x" => br_x, - "transformer" => transformer, - "tap" => tap, - "shift" => shift, - "COD1" => COD1, - ) -end - -@testset "Branch-type tolerance normalizations" begin - # Non-transformer → Line - d = _psse_branch_dict(; transformer = false, tap = 0.0, shift = 0.0) - @test PowerSystems.get_branch_type_psse(d) == Line - - # Exact zero impedance → DiscreteControlledACBranch - d_zero = _psse_branch_dict(; br_r = 0.0, br_x = 0.0, transformer = false, tap = 0.0) - @test PowerSystems.get_branch_type_psse(d_zero) == DiscreteControlledACBranch - - # Near-zero impedance within tolerance → DiscreteControlledACBranch - d_near_zero = _psse_branch_dict(; - br_r = PowerSystems.ZERO_IMPEDANCE_RESISTANCE_THRESHOLD / 2, - br_x = PowerSystems.ZERO_IMPEDANCE_REACTANCE_THRESHOLD / 2, - transformer = false, tap = 0.0, - ) - @test PowerSystems.get_branch_type_psse(d_near_zero) == DiscreteControlledACBranch - - # Non-controllable (COD1=0) TapTransformer with tap well away from 1.0 → TapTransformer - d = _psse_branch_dict(; tap = 1.05, COD1 = 0) - @test PowerSystems.get_branch_type_psse(d) == TapTransformer - - # Non-controllable TapTransformer with tap inside IDENTITY_TAP_TOL → Transformer2W - d = _psse_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, COD1 = 0) - @test PowerSystems.get_branch_type_psse(d) == Transformer2W - - # Controllable (COD1=1) TapTransformer whose current tap is near 1.0 must NOT be demoted - d = _psse_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, COD1 = 1) - @test PowerSystems.get_branch_type_psse(d) == TapTransformer - - # Tap == 0.0 (identity alias) with no control → Transformer2W - d = _psse_branch_dict(; tap = 0.0, COD1 = 0) - @test PowerSystems.get_branch_type_psse(d) == Transformer2W - - # PST with shift well outside zero tolerance → PhaseShiftingTransformer - d = _psse_branch_dict(; tap = 1.0, shift = 0.5, COD1 = 3) - @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer - - # Non-alpha-controllable (COD1=0) PST with near-zero shift and identity tap → Transformer2W - d = _psse_branch_dict(; - tap = 1.0, - shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, - COD1 = 0, - ) - @test PowerSystems.get_branch_type_psse(d) == Transformer2W - - # Non-alpha-controllable PST with near-zero shift but non-identity tap → TapTransformer - d = _psse_branch_dict(; - tap = 1.05, - shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, - COD1 = 0, - ) - @test PowerSystems.get_branch_type_psse(d) == TapTransformer - - # Alpha-controllable (COD1=3) PST with near-zero shift must NOT be demoted - d = _psse_branch_dict(; - tap = 1.0, - shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, - COD1 = 3, - ) - @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer - - # Transformer with X exactly at the threshold (i.e., not strictly near-zero) - # must NOT be converted — this models real impedance-correction transformers - d_threshold_tr = _psse_branch_dict(; - br_r = 0.0, br_x = PowerSystems.ZERO_IMPEDANCE_REACTANCE_THRESHOLD, - transformer = true, tap = 0.0, - ) - @test PowerSystems.get_branch_type_psse(d_threshold_tr) != DiscreteControlledACBranch -end From 00003f06df7a29962879bc8e8b3b44df60afa86e Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Wed, 10 Jun 2026 10:47:59 -0600 Subject: [PATCH 7/9] Fix branch-type classification: guard zero-impedance check against transformers - Move `!d["transformer"]` guard into the initial zero-impedance DiscreteControlledACBranch check in `get_branch_type_psse`; transformer sections with zero impedance must remain transformers (Transformer2W), not become switches - Update "Test conversion zero impedance branch to switch" expectations from (6,4) to (3,1): the old values reflected broken behavior where zero-impedance transformer sections were erroneously classified as DiscreteControlledACBranch - Add targeted regression tests for tolerance-based branch-type normalization (PSS/E and Matpower): near-identity tap demotion to Transformer2W, near-zero shift demotion, controllable transformer exemptions, and zero-impedance non-transformer switch detection --- src/parsers/power_models_data.jl | 2 +- test/test_parse_matpower.jl | 38 +++++++++++++++++++ test/test_parse_psse.jl | 65 +++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index 858b960806..ea2cac6fd4 100644 --- a/src/parsers/power_models_data.jl +++ b/src/parsers/power_models_data.jl @@ -1231,7 +1231,7 @@ end function get_branch_type_psse( d::Dict, ) - if d["br_r"] == 0.0 && d["br_x"] == 0.0 + if !d["transformer"] && d["br_r"] == 0.0 && d["br_x"] == 0.0 return DiscreteControlledACBranch end diff --git a/test/test_parse_matpower.jl b/test/test_parse_matpower.jl index 846c6b8077..4b29276b29 100644 --- a/test/test_parse_matpower.jl +++ b/test/test_parse_matpower.jl @@ -107,3 +107,41 @@ end @test_logs (:error,) match_mode = :any test_parse(path) end end + +@testset "Branch-type tolerance normalizations" begin + function _mp_branch_dict(; tap = 1.0, shift = 0.0, transformer = true) + return Dict{String, Any}( + "tap" => tap, + "shift" => shift, + "transformer" => transformer, + ) + end + + # Non-transformer → Line + d = _mp_branch_dict(; tap = 1.0, shift = 0.0, transformer = false) + @test PowerSystems.get_branch_type_matpower(d) == Line + + # Tap well away from 1.0, no shift → TapTransformer + d = _mp_branch_dict(; tap = 1.05, shift = 0.0) + @test PowerSystems.get_branch_type_matpower(d) == TapTransformer + + # Tap inside IDENTITY_TAP_TOL, no shift → Transformer2W + d = _mp_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, shift = 0.0) + @test PowerSystems.get_branch_type_matpower(d) == Transformer2W + + # Tap == 0.0 alias with no shift → Transformer2W + d = _mp_branch_dict(; tap = 0.0, shift = 0.0) + @test PowerSystems.get_branch_type_matpower(d) == Transformer2W + + # Real phase shift → PhaseShiftingTransformer + d = _mp_branch_dict(; tap = 1.0, shift = 0.5236) # ~30° + @test PowerSystems.get_branch_type_matpower(d) == PhaseShiftingTransformer + + # Near-zero shift, identity tap → Transformer2W + d = _mp_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2) + @test PowerSystems.get_branch_type_matpower(d) == Transformer2W + + # Near-zero shift, non-identity tap → TapTransformer + d = _mp_branch_dict(; tap = 1.05, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2) + @test PowerSystems.get_branch_type_matpower(d) == TapTransformer +end diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index 062139c7cc..a66e186847 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -529,10 +529,10 @@ end "psse_14_zero_impedance_branch_test_system"; force_build = true, ) - @test length(get_components(DiscreteControlledACBranch, sys)) == 6 + @test length(get_components(DiscreteControlledACBranch, sys)) == 3 @test length( get_components(x -> get_r(x) == get_x(x) == 0.0, DiscreteControlledACBranch, sys), - ) == 4 + ) == 1 end @testset "Test threshold setting for zero impedance 3WT winding" begin @@ -593,3 +593,64 @@ end @test !haskey(geo_json, "x") @test !haskey(geo_json, "y") end + +# Minimal branch dict for get_branch_type_psse unit tests. +function _psse_branch_dict(; + br_r = 0.01, br_x = 0.1, transformer = true, tap = 1.0, + shift = 0.0, COD1 = 0, +) + return Dict{String, Any}( + "br_r" => br_r, + "br_x" => br_x, + "transformer" => transformer, + "tap" => tap, + "shift" => shift, + "COD1" => COD1, + ) +end + +@testset "Branch-type tolerance normalizations" begin + # Non-transformer → Line + d = _psse_branch_dict(; transformer = false, tap = 0.0, shift = 0.0) + @test PowerSystems.get_branch_type_psse(d) == Line + + # Exact-zero impedance, non-transformer → DiscreteControlledACBranch + d = _psse_branch_dict(; br_r = 0.0, br_x = 0.0, transformer = false, tap = 0.0) + @test PowerSystems.get_branch_type_psse(d) == DiscreteControlledACBranch + + # Transformer with zero impedance must NOT become DiscreteControlledACBranch + d = _psse_branch_dict(; br_r = 0.0, br_x = 0.0, transformer = true, tap = 0.0) + @test PowerSystems.get_branch_type_psse(d) != DiscreteControlledACBranch + + # Non-controllable (COD1=0) TapTransformer with tap well away from 1.0 → TapTransformer + d = _psse_branch_dict(; tap = 1.05, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == TapTransformer + + # Non-controllable TapTransformer with tap inside IDENTITY_TAP_TOL → Transformer2W + d = _psse_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == Transformer2W + + # Controllable (COD1=1) TapTransformer whose current tap is near 1.0 must NOT be demoted + d = _psse_branch_dict(; tap = 1.0 + PowerSystems.IDENTITY_TAP_TOL / 2, COD1 = 1) + @test PowerSystems.get_branch_type_psse(d) == TapTransformer + + # Tap == 0.0 (identity alias) with no control → Transformer2W + d = _psse_branch_dict(; tap = 0.0, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == Transformer2W + + # PST with shift well outside zero tolerance → PhaseShiftingTransformer + d = _psse_branch_dict(; tap = 1.0, shift = 0.5, COD1 = 3) + @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer + + # Non-alpha-controllable (COD1=0) PST with near-zero shift and identity tap → Transformer2W + d = _psse_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == Transformer2W + + # Non-alpha-controllable PST with near-zero shift but non-identity tap → TapTransformer + d = _psse_branch_dict(; tap = 1.05, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, COD1 = 0) + @test PowerSystems.get_branch_type_psse(d) == TapTransformer + + # Alpha-controllable (COD1=3) PST with near-zero shift must NOT be demoted + d = _psse_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, COD1 = 3) + @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer +end From b6157e0babfe29d0910fbf45b4f7b98168e04f93 Mon Sep 17 00:00:00 2001 From: mvelasqu Date: Wed, 10 Jun 2026 11:19:11 -0600 Subject: [PATCH 8/9] fix: run formatter --- test/test_parse_psse.jl | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index a66e186847..6ddbfc7207 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -643,14 +643,26 @@ end @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer # Non-alpha-controllable (COD1=0) PST with near-zero shift and identity tap → Transformer2W - d = _psse_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, COD1 = 0) + d = _psse_branch_dict(; + tap = 1.0, + shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, + COD1 = 0, + ) @test PowerSystems.get_branch_type_psse(d) == Transformer2W # Non-alpha-controllable PST with near-zero shift but non-identity tap → TapTransformer - d = _psse_branch_dict(; tap = 1.05, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, COD1 = 0) + d = _psse_branch_dict(; + tap = 1.05, + shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, + COD1 = 0, + ) @test PowerSystems.get_branch_type_psse(d) == TapTransformer # Alpha-controllable (COD1=3) PST with near-zero shift must NOT be demoted - d = _psse_branch_dict(; tap = 1.0, shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, COD1 = 3) + d = _psse_branch_dict(; + tap = 1.0, + shift = PowerSystems.ZERO_ANGLE_SHIFT_TOL / 2, + COD1 = 3, + ) @test PowerSystems.get_branch_type_psse(d) == PhaseShiftingTransformer end From cc9d540be68a0eee0a4d18f66a151ed802e51890 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:46:21 +0000 Subject: [PATCH 9/9] docs: add check_parallel_branch_type_consistency to public API docs --- docs/src/api/public.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/src/api/public.md b/docs/src/api/public.md index bc7edf3aa0..c8892b28ab 100644 --- a/docs/src/api/public.md +++ b/docs/src/api/public.md @@ -119,6 +119,10 @@ Private = false Filter = t -> t ∉ [System] ``` +```@docs +check_parallel_branch_type_consistency +``` + ## Advanced Component Selection The primary way to retrieve components in PowerSystems.jl is with the [`get_components`](@ref) and similar `get_*` methods above. The following `ComponentSelector` interface offers advanced, repeatable component selection primarily for multi-scenario post-processing analytics. See [`PowerAnalytics.jl`](https://sienna-platform.github.io/PowerAnalytics.jl/stable/).