diff --git a/src/BranchAdmittance.jl b/src/BranchAdmittance.jl new file mode 100644 index 000000000..fb76ce283 --- /dev/null +++ b/src/BranchAdmittance.jl @@ -0,0 +1,206 @@ +# ── Branch π-model admittance ──────────────────────────────────────────────── +# Compute a branch's π-model admittance as a +# `(g, b, g_fr, b_fr, g_to, b_to, tap, shift)` NamedTuple following the PowerModels +# convention: `g + im*b == 1/(r + im*x)` is the series admittance, `*_fr`/`*_to` are the +# from/to shunts, and `tap`/`shift` are the transformer ratio / phase shift (radians). +# +# This logic historically lived inline in PowerSimulations.jl's PowerModels translator +# (`get_branch_to_pm`). + +# Transformer ratio / phase-shift accessors. Default to a unit, shift-free branch. +_branch_tap(::PSY.ACTransmission) = 1.0 +_branch_tap(b::PSY.TapTransformer) = PSY.get_tap(b) +_branch_tap(b::PSY.PhaseShiftingTransformer) = PSY.get_tap(b) +_branch_shift(::PSY.ACTransmission) = 0.0 +_branch_shift(b::PSY.PhaseShiftingTransformer) = PSY.get_α(b) + +""" + branch_admittance(b::PSY.ACTransmission) -> NamedTuple + +π-model admittance `(g, b, g_fr, b_fr, g_to, b_to, tap, shift)` for a line or symmetric +branch, where `g + im*b == 1 / (r + im*x)` is the series admittance and the line charging +susceptance is split between the from/to shunts. +""" +function branch_admittance(b::PSY.ACTransmission) + ys = 1.0 / (PSY.get_r(b, PSY.SU) + PSY.get_x(b, PSY.SU) * im) + b_sh = PSY.get_b(b, PSY.SU) + return ( + g = real(ys), b = imag(ys), + g_fr = 0.0, b_fr = b_sh.from, g_to = 0.0, b_to = b_sh.to, + tap = 1.0, shift = 0.0, + ) +end + +""" + branch_admittance(b::Union{PSY.Transformer2W, PSY.TapTransformer, PSY.PhaseShiftingTransformer}) -> NamedTuple + +π-model admittance for a two-winding transformer. The magnetizing shunt is allocated to the +primary (from) side only; `tap`/`shift` carry the transformer ratio and phase shift. +""" +function branch_admittance( + b::Union{PSY.Transformer2W, PSY.TapTransformer, PSY.PhaseShiftingTransformer}, +) + ys = 1.0 / (PSY.get_r(b, PSY.SU) + PSY.get_x(b, PSY.SU) * im) + yt = PSY.get_primary_shunt(b, PSY.SU) + return ( + g = real(ys), b = imag(ys), + g_fr = real(yt), b_fr = imag(yt), g_to = 0.0, b_to = 0.0, + tap = _branch_tap(b), shift = _branch_shift(b), + ) +end + +""" + branch_admittance(segment, nr::NetworkReductionData) -> NamedTuple + +π-model admittance for a reduction-aggregated arc (a `BranchesSeries` chain or +`BranchesParallel` group), built from PNM's reduction-aware equivalent physical branch +parameters. Series/parallel equivalents of lines carry `tap == 1`. +""" +function branch_admittance(segment, nr::NetworkReductionData) + eb = get_equivalent_physical_branch_parameters(segment, nr) + ys = 1.0 / (get_equivalent_r(eb) + get_equivalent_x(eb) * im) + return ( + g = real(ys), b = imag(ys), + g_fr = get_equivalent_g_from(eb), b_fr = get_equivalent_b_from(eb), + g_to = get_equivalent_g_to(eb), b_to = get_equivalent_b_to(eb), + tap = get_equivalent_tap(eb), shift = get_equivalent_shift(eb), + ) +end + +# Flip a π-admittance tuple to the opposite orientation (from<->to). Reduced equivalents may +# be keyed by an arc whose orientation is reversed vs. the surviving branch's retained +# from->to; reorient so coefficients match (from_bus, to_bus). g/b are symmetric; from/to +# shunts swap; phase shift negates. Reduced line equivalents have tap == 1. +function _reverse_admittance(adm) + @assert adm.tap == 1.0 "Cannot reorient a reduced arc with a non-unit tap ($(adm.tap))." + return ( + g = adm.g, + b = adm.b, + g_fr = adm.g_to, + b_fr = adm.b_to, + g_to = adm.g_fr, + b_to = adm.b_fr, + tap = adm.tap, + shift = -adm.shift, + ) +end + +""" + reduced_arc_admittance(nr::NetworkReductionData, from_no::Int, to_no::Int) -> Union{NamedTuple, Nothing} + +Reduction-aware admittance for the retained arc `from_no -> to_no`. Returns the series/parallel +equivalent π-tuple (oriented from->to) when the arc was aggregated by a network reduction, or +`nothing` when the arc is direct (the caller falls back to the branch's own +[`branch_admittance`](@ref)). +""" +function reduced_arc_admittance(nr::NetworkReductionData, from_no::Int, to_no::Int) + series_map = get_series_branch_map(nr) + parallel_map = get_parallel_branch_map(nr) + arc = (from_no, to_no) + rev = (to_no, from_no) + if haskey(series_map, arc) + return branch_admittance(series_map[arc], nr) + elseif haskey(series_map, rev) + return _reverse_admittance(branch_admittance(series_map[rev], nr)) + elseif haskey(parallel_map, arc) + return branch_admittance(parallel_map[arc], nr) + elseif haskey(parallel_map, rev) + return _reverse_admittance(branch_admittance(parallel_map[rev], nr)) + end + return nothing +end + +# ── Three-winding transformer admittance ───────────────────────────────────── + +""" + three_winding_arcs(d::PSY.Transformer3W) -> Vector{<:NamedTuple} + +Decompose a `Transformer3W` into its three wye-model windings via +[`ThreeWindingTransformerWinding`](@ref), returning per-winding data: a naming `suffix`, the +star-point `arc` (for reduction-aware bus mapping), the winding `rating`, and the `winding` +object itself (for [`winding_admittance`](@ref)). +""" +function three_winding_arcs(d::PSY.Transformer3W) + star_arcs = ( + PSY.get_primary_star_arc(d), + PSY.get_secondary_star_arc(d), + PSY.get_tertiary_star_arc(d), + ) + out = NamedTuple[] + for i in 1:3 + w = ThreeWindingTransformerWinding(d, i) + push!( + out, + ( + suffix = "winding_$i", + arc = star_arcs[i], + rating = get_equivalent_rating(w), + winding = w, + ), + ) + end + return out +end + +# No phase shift for windings; only the (optional) winding tap matters for the π-model. +_winding_tap(::ThreeWindingTransformerWinding) = 1.0 +_winding_tap(w::ThreeWindingTransformerWinding{PSY.PhaseShiftingTransformer3W}) = + get_equivalent_tap(w) + +""" + winding_admittance(w::ThreeWindingTransformerWinding) -> NamedTuple + +Per-winding π-model admittance `(g, b, g_fr, b_fr, g_to, b_to, tap)` for one winding of a +three-winding transformer (shunt split from/to, no phase shift). +""" +function winding_admittance(w::ThreeWindingTransformerWinding) + ys = 1.0 / (get_equivalent_r(w) + get_equivalent_x(w) * im) + b_sh = get_equivalent_b(w) + return ( + g = real(ys), b = imag(ys), + g_fr = 0.0, b_fr = b_sh.from, g_to = 0.0, b_to = b_sh.to, + tap = _winding_tap(w), + ) +end + +# ── Branch flow limits ─────────────────────────────────────────────────────── + +""" + branch_flow_limits(branch) -> NamedTuple + +Directional flow limits in MVA (device units, `PSY.DU`): `(from_to::Float64, to_from::Float64)`. For symmetric +branches both fields equal the branch rating; `MonitoredLine` carries asymmetric limits. +""" +function branch_flow_limits end + +function branch_flow_limits(b::PSY.MonitoredLine) + fl = PSY.get_flow_limits(b, PSY.DU) + return (from_to = fl.from_to, to_from = fl.to_from) +end + +function branch_flow_limits( + b::Union{ + PSY.Line, + PSY.Transformer2W, + PSY.TapTransformer, + PSY.PhaseShiftingTransformer, + }, +) + r = PSY.get_rating(b, PSY.DU) + return (from_to = r, to_from = r) +end + +function branch_flow_limits(b::BranchesParallel) + r = get_equivalent_rating(b) + return (from_to = r, to_from = r) +end + +function branch_flow_limits(b::BranchesSeries) + r = get_equivalent_rating(b) + return (from_to = r, to_from = r) +end + +function branch_flow_limits(w::ThreeWindingTransformerWinding) + r = get_equivalent_rating(w) + return (from_to = r, to_from = r) +end diff --git a/src/PowerNetworkMatrices.jl b/src/PowerNetworkMatrices.jl index 885931f82..ddb7e664b 100644 --- a/src/PowerNetworkMatrices.jl +++ b/src/PowerNetworkMatrices.jl @@ -34,6 +34,11 @@ export YBUS_ELTYPE export get_sum_of_max_rating export get_single_element_contingency_rating export get_impedance_averaged_rating +export branch_admittance +export reduced_arc_admittance +export winding_admittance +export three_winding_arcs +export branch_flow_limits export apply_woodbury_correction export clear_all_caches! @@ -137,6 +142,7 @@ include("AdjacencyMatrix.jl") include("connectivity_checks.jl") include("subnetworks.jl") include("common.jl") +include("BranchAdmittance.jl") include("auto_tolerance.jl") include("BA_ABA_matrices.jl") include("virtual_factor_helpers.jl") diff --git a/test/test_branch_admittance.jl b/test/test_branch_admittance.jl new file mode 100644 index 000000000..849f8922e --- /dev/null +++ b/test/test_branch_admittance.jl @@ -0,0 +1,188 @@ +# Tests for the π-model branch admittance helpers (`branch_admittance`, +# `reduced_arc_admittance`, `winding_admittance`, `three_winding_arcs`, `branch_flow_limits`). +# Ported from PowerOperationsModels.jl's native DCP/ACP model tests, where this logic +# previously lived as private `_*` helpers before being promoted to PNM's public API. + +@testset "branch_admittance primitives" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") + line = first(PSY.get_components(PSY.Line, sys)) + a = PNM.branch_admittance(line) + r, x = PSY.get_r(line, PSY.SU), PSY.get_x(line, PSY.SU) + y = inv(complex(r, x)) + @test a.g ≈ real(y) + @test a.b ≈ imag(y) + @test a.tap == 1.0 + @test a.shift == 0.0 +end + +@testset "branch_flow_limits MonitoredLine" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_ml") + ml = first(PSY.get_components(PSY.MonitoredLine, sys)) + fl = PNM.branch_flow_limits(ml) + psy_fl = PSY.get_flow_limits(ml, PSY.DU) + @test fl.from_to == psy_fl.from_to + @test fl.to_from == psy_fl.to_from +end + +@testset "reduced arc admittance uses PNM series equivalent, not original branch" begin + # `case10_radial_series_reductions` is purpose-built to produce series arcs under the + # radial + degree-two reduction, exercising the same NetworkReductionData the build path + # stores on the network model. + sys = PSB.build_system(PSB.PSITestSystems, "case10_radial_series_reductions") + ybus = PNM.Ybus( + sys; + network_reductions = PNM.NetworkReduction[ + PNM.RadialReduction(), + PNM.DegreeTwoReduction(), + ], + ) + nr = deepcopy(PNM.get_network_reduction_data(ybus)) + @test !isempty(nr) + + series_map = PNM.get_series_branch_map(nr) + @test !isempty(series_map) # degree-2 reduction produces series arcs + + (from_no, to_no), chain = first(series_map) + resolved = PNM.reduced_arc_admittance(nr, from_no, to_no) + @test resolved !== nothing + expected = PNM.branch_admittance(chain, nr) + @test isapprox(resolved.b, expected.b; atol = 1e-9) + + # Non-triviality: the series equivalent is the MERGED admittance of the chain, so it must + # differ from any single constituent branch's own admittance. This is the whole point of + # leveraging the reduction-aware equivalent rather than a single branch's value. Compare + # against a plain `Line` member — PNM wrapper members (nested parallel/series segments, + # 3W windings) resolve through their dedicated `branch_admittance`/`winding_admittance` + # methods, not the single-arg physical-branch form. + members = collect(chain) + @test length(members) >= 2 + line_members = filter(m -> m isa PSY.Line, members) + if !isempty(line_members) + member_b = PNM.branch_admittance(line_members[1]).b + @test !isapprox(resolved.b, member_b; rtol = 1e-3) + end + + # Reversed-orientation arc exercises the `_reverse_admittance` path: series b is symmetric, + # from/to shunts swap, and any phase shift negates. + if !haskey(series_map, (to_no, from_no)) + reversed = PNM.reduced_arc_admittance(nr, to_no, from_no) + @test reversed !== nothing + @test isapprox(reversed.b, resolved.b; atol = 1e-9) + @test isapprox(reversed.b_fr, resolved.b_to; atol = 1e-9) + @test isapprox(reversed.shift, -resolved.shift; atol = 1e-12) + end + + # A direct (un-reduced) arc resolves to `nothing` — the caller falls back to the branch's + # own admittance. + @test PNM.reduced_arc_admittance(nr, -1, -2) === nothing +end + +@testset "Transformer3W winding_admittance and three_winding_arcs decomposition" begin + # Unit test the per-winding admittance helper against a real PNM + # `ThreeWindingTransformerWinding`: for a winding with series impedance R + jX the helper + # must return the series admittance 1/(R + jX), the winding's PNM shunt on the from/to + # sides, no phase shift, and (here) a unit tap. R/X are read back through PNM so the + # assertion is robust to per-unit base conversions. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_ml") + busD = PSY.get_component(PSY.ACBus, sys, "nodeD") + star_bus = PSY.ACBus(; + number = 103, + name = "Star_Bus_T3W", + available = true, + bustype = PSY.ACBusTypes.PQ, + angle = 0.0, + magnitude = 1.0, + voltage_limits = (min = 0.95, max = 1.05), + base_voltage = 230.0, + area = PSY.get_area(busD), + load_zone = PSY.get_load_zone(busD), + ) + PSY.add_component!(sys, star_bus) + sec_bus = PSY.ACBus(; + number = 101, + name = "Bus3WT_1", + available = true, + bustype = PSY.ACBusTypes.PQ, + angle = 0.0, + magnitude = 1.0, + voltage_limits = (min = 0.95, max = 1.05), + base_voltage = 230.0, + area = PSY.get_area(busD), + load_zone = PSY.get_load_zone(busD), + ) + PSY.add_component!(sys, sec_bus) + ter_bus = PSY.ACBus(; + number = 102, + name = "Bus3WT_2", + available = true, + bustype = PSY.ACBusTypes.PQ, + angle = 0.0, + magnitude = 1.0, + voltage_limits = (min = 0.95, max = 1.05), + base_voltage = 230.0, + area = PSY.get_area(busD), + load_zone = PSY.get_load_zone(busD), + ) + PSY.add_component!(sys, ter_bus) + transformer3w = PSY.Transformer3W(; + name = "Transformer3W_busD", + available = true, + primary_star_arc = PSY.Arc(; from = busD, to = star_bus), + secondary_star_arc = PSY.Arc(; from = sec_bus, to = star_bus), + tertiary_star_arc = PSY.Arc(; from = ter_bus, to = star_bus), + star_bus = star_bus, + active_power_flow_primary = 0.0, + reactive_power_flow_primary = 0.0, + active_power_flow_secondary = 0.0, + reactive_power_flow_secondary = 0.0, + active_power_flow_tertiary = 0.0, + reactive_power_flow_tertiary = 0.0, + r_primary = 0.01, + x_primary = 0.1, + r_secondary = 0.01, + x_secondary = 0.1, + r_tertiary = 0.01, + x_tertiary = 0.1, + r_12 = 0.01, + x_12 = 0.1, + r_23 = 0.01, + x_23 = 0.1, + r_13 = 0.01, + x_13 = 0.1, + base_power_12 = 100.0, + base_power_23 = 100.0, + base_power_13 = 100.0, + rating = nothing, + rating_primary = 1.0, + rating_secondary = 1.0, + rating_tertiary = 0.5, + ) + PSY.add_component!(sys, transformer3w) + + w = PNM.ThreeWindingTransformerWinding(transformer3w, 1) + adm = PNM.winding_admittance(w) + + r = PNM.get_equivalent_r(w) + x = PNM.get_equivalent_x(w) + y = inv(complex(r, x)) + @test isapprox(adm.g, real(y); atol = 1e-12) + @test isapprox(adm.b, imag(y); atol = 1e-12) + + b_sh = PNM.get_equivalent_b(w) + @test adm.g_fr == 0.0 + @test adm.b_fr == b_sh.from + @test adm.g_to == 0.0 + @test adm.b_to == b_sh.to + @test adm.tap == 1.0 + + # `three_winding_arcs` decomposes the device into its three windings, exposing the + # star-point arc, rating, and winding object the native builders consume. + arcs = PNM.three_winding_arcs(transformer3w) + @test length(arcs) == 3 + @test [a.suffix for a in arcs] == ["winding_1", "winding_2", "winding_3"] + @test arcs[1].arc == PSY.get_primary_star_arc(transformer3w) + @test arcs[2].arc == PSY.get_secondary_star_arc(transformer3w) + @test arcs[3].arc == PSY.get_tertiary_star_arc(transformer3w) + # Winding admittance computed from the decomposition matches the standalone helper. + @test PNM.winding_admittance(arcs[1].winding).b ≈ adm.b +end