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/). diff --git a/src/PowerSystems.jl b/src/PowerSystems.jl index 44b9aa3172..dc1208c914 100644 --- a/src/PowerSystems.jl +++ b/src/PowerSystems.jl @@ -614,6 +614,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 6392947656..27746715ae 100644 --- a/src/definitions.jl +++ b/src/definitions.jl @@ -578,6 +578,15 @@ 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 +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 # Absolute threshold below which a shunt admittance component (conductance or # susceptance) is treated as zero for capability detection, so negligible diff --git a/src/parsers/power_models_data.jl b/src/parsers/power_models_data.jl index 0eb486a671..ea2cac6fd4 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 @@ -1217,12 +1231,13 @@ 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 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 + # 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 + end return TapTransformer elseif !is_tap_controllable && d["group_number"] != WindingGroupNumber.UNDEFINED return Transformer2W @@ -1248,15 +1279,110 @@ 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 _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 _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" + return overrides + 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"]) + _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) + + 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 + 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 + 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) + _is_active_discrete_branch(br) || continue + 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, 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) @@ -1304,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), @@ -1693,11 +1819,36 @@ 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) + existing_discrete_arc_keys = _collect_existing_discrete_arc_keys(sys) 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) + # 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) && + (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) @@ -1707,7 +1858,41 @@ function read_branch!( else _get_name(d, bus_f, bus_t) end - value = make_branch(name, d, bus_f, bus_t, source_type; kwargs...) + + 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) + + 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( + 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 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..6ddbfc7207 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,76 @@ 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