From 045d204f2290ae4fc3fc6d0ab6cb0b40d2b5fcb1 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Mon, 24 Aug 2026 13:04:26 -0600 Subject: [PATCH] Unify the trait-vs-list pattern in power flow aux var provisioning Closes #193. `branch_aux_vars`/`bus_aux_vars` enumerated, per PowerFlowContainer type, which auxiliary variables that container provides, and the read-back guard in `calculate_aux_variable_value!` asked the question a second way via `in` against both lists. Replace both with a single dispatch surface: _pf_provides_aux_var(::Type{T}, pf_data)::Bool with a `false` fallback, so containers that provide nothing (PSSEExporter, and network models running no evaluator) keep their no-op behavior by inheriting it rather than by returning an empty list. The runtime-flag cases (loss and voltage-stability factors, gated on `get_calculate_*`) now take the same shape as the rest; only their right-hand side differs. The three DC containers collapse into one `PowerFlowData{<:AbstractDCPowerFlow}` method instead of three identical ones. `branch_aux_vars`/`bus_aux_vars` survive as thin derivations of the trait, since registration genuinely needs a list of which aux vars to create. They filter `POM.pf_aux_var_types(C)`, a new tuple-valued trait in POM giving the universe of power flow aux vars indexed by branch and by bus. The derivation is written so all of the type reasoning folds at compile time: the universe is a tuple literal, and `map` visits each element with its concrete `Type{T}` known, so every trait call resolves statically and the constant methods fold away. `provides` must be a named function with a `where {T}` parameter rather than the equivalent lambda -- Julia declines to specialize on an argument slot holding a `Type` unless a type parameter binds it, and with a lambda every DC container and the PSSEExporter left unfolded `::Bool` calls. Verified with `code_typed`: all six container types over both component types leave zero residual predicate calls and zero dynamic dispatch, against a fully dynamic `Filter`/`Generator`/`collect` chain before. Since the runtime path no longer reflects over the type tree, a new `PowerFlowAuxVariableType` missing from the tuples would silently never be registered. A new test closes that gap by asserting exhaustiveness via `IS.get_all_concrete_subtypes`. Verified list-for-list equivalence with the previous hardcoded lists for all five container types plus the `calculate_loss_factors` path. Co-Authored-By: Claude Opus 5 (1M context) --- ext/PowerFlowsExt/pf_input_mapping.jl | 97 +++++++++++++++++---------- ext/PowerFlowsExt/pf_solve_and_aux.jl | 4 +- src/core/auxiliary_variables.jl | 30 +++++++++ test/test_power_flow_in_the_loop.jl | 20 ++++++ 4 files changed, 114 insertions(+), 37 deletions(-) diff --git a/ext/PowerFlowsExt/pf_input_mapping.jl b/ext/PowerFlowsExt/pf_input_mapping.jl index 02fdfb0c..1295b369 100644 --- a/ext/PowerFlowsExt/pf_input_mapping.jl +++ b/ext/PowerFlowsExt/pf_input_mapping.jl @@ -267,42 +267,71 @@ function _add_two_terminal_elements_map!( return end -# Trait that determines what branch aux vars we can get from each PowerFlowContainer -branch_aux_vars(::PFS.ACPowerFlowData) = - [ - POM.PowerFlowBranchReactivePowerFromTo, - POM.PowerFlowBranchReactivePowerToFrom, - POM.PowerFlowBranchActivePowerFromTo, - POM.PowerFlowBranchActivePowerToFrom, - POM.PowerFlowBranchActivePowerLoss, - ] -branch_aux_vars(::PFS.ABAPowerFlowData) = - [POM.PowerFlowBranchActivePowerFromTo, POM.PowerFlowBranchActivePowerToFrom] -branch_aux_vars(::PFS.PTDFPowerFlowData) = - [POM.PowerFlowBranchActivePowerFromTo, POM.PowerFlowBranchActivePowerToFrom] -branch_aux_vars(::PFS.vPTDFPowerFlowData) = - [POM.PowerFlowBranchActivePowerFromTo, POM.PowerFlowBranchActivePowerToFrom] -branch_aux_vars(::PFS.PSSEExporter) = DataType[] - -# Same for bus aux vars. Loss/voltage-stability factors are registered ONLY when their -# `get_calculate_*` flag is set — the same flag under which `_get_pf_result` returns a -# non-`nothing` matrix (`PFS.get_loss_factors` / `get_voltage_stability_factors` are `nothing` -# otherwise). Keep these two conditions in lockstep so the read-back never indexes a `nothing`. -function bus_aux_vars(data::PFS.ACPowerFlowData) - vars = [POM.PowerFlowVoltageAngle, POM.PowerFlowVoltageMagnitude] - if PFS.get_calculate_loss_factors(data) - push!(vars, POM.PowerFlowLossFactors) - end - if PFS.get_calculate_voltage_stability_factors(data) - push!(vars, POM.PowerFlowVoltageStabilityFactors) - end - return vars +# ─── Which auxiliary variables can we read back from a given power flow? ────────────── +# `_pf_provides_aux_var(T, pf_data)` answers "does this power flow +# container compute a value for aux variable `T`". Both the registration path +# (`add_power_flow_data!`, via `branch_aux_vars`/`bus_aux_vars` below) and the read-back +# guard (`calculate_aux_variable_value!`) go through it. +# +# The fallback is `false`: a container with no specialization registers nothing and no-ops on +# read-back. +_pf_provides_aux_var(::Type{<:POM.PowerFlowAuxVariableType}, ::PFS.PowerFlowContainer) = + false + +# AC power flow solves the full branch flows and the full bus voltage state. +_pf_provides_aux_var(::Type{<:POM.BranchFlowAuxVariableType}, ::PFS.ACPowerFlowData) = true +_pf_provides_aux_var(::Type{POM.PowerFlowBranchActivePowerLoss}, ::PFS.ACPowerFlowData) = + true +_pf_provides_aux_var(::Type{POM.PowerFlowVoltageAngle}, ::PFS.ACPowerFlowData) = true +_pf_provides_aux_var(::Type{POM.PowerFlowVoltageMagnitude}, ::PFS.ACPowerFlowData) = true + +# Loss/voltage-stability factors are provided ONLY when their `get_calculate_*` flag is +# set — the same flag under which `_get_pf_result` returns a non-`nothing` matrix +# (`PFS.get_loss_factors` / `get_voltage_stability_factors` are `nothing` otherwise). Keep +# these two conditions in lockstep so the read-back never indexes a `nothing`. Runtime +# state, but the same trait shape: only the right-hand side differs. +_pf_provides_aux_var(::Type{POM.PowerFlowLossFactors}, data::PFS.ACPowerFlowData) = + PFS.get_calculate_loss_factors(data) +_pf_provides_aux_var( + ::Type{POM.PowerFlowVoltageStabilityFactors}, + data::PFS.ACPowerFlowData, +) = PFS.get_calculate_voltage_stability_factors(data) + +# Every DC power flow (ABA, PTDF, vPTDF) gives active branch flows, and only those. +_pf_provides_aux_var( + ::Type{POM.PowerFlowBranchActivePowerFromTo}, + ::PFS.PowerFlowData{<:PFS.AbstractDCPowerFlow}, +) = true +_pf_provides_aux_var( + ::Type{POM.PowerFlowBranchActivePowerToFrom}, + ::PFS.PowerFlowData{<:PFS.AbstractDCPowerFlow}, +) = true + +# ABA additionally solves for the bus angles; the PTDF formulations never form them. +_pf_provides_aux_var(::Type{POM.PowerFlowVoltageAngle}, ::PFS.ABAPowerFlowData) = true + +""" +The aux variable types over components of type `C` that `pf_data` provides, derived from +the `_pf_provides_aux_var` trait so there is no per-container list to keep in sync. + +Written so that all type logic inside happens at compile time: +1. `POM.pf_aux_var_types(C)` returns a tuple, so each element's type is known statically. +2. `map` ensures `_pf_provides_aux_var` resolves statically. +3. `provides` is a named function with a type parameter, not a lambda, so Julia specializes. +""" +function _provided_aux_vars( + pf_data::PFS.PowerFlowContainer, + ::Type{C}, +) where {C <: PSY.Component} + candidates = POM.pf_aux_var_types(C) + provides(::Type{T}) where {T} = _pf_provides_aux_var(T, pf_data) + provided = map(provides, candidates) + return DataType[candidates[i] for i in eachindex(candidates) if provided[i]] end -bus_aux_vars(::PFS.ABAPowerFlowData) = [POM.PowerFlowVoltageAngle] -bus_aux_vars(::PFS.PTDFPowerFlowData) = DataType[] -bus_aux_vars(::PFS.vPTDFPowerFlowData) = DataType[] -bus_aux_vars(::PFS.PSSEExporter) = DataType[] +branch_aux_vars(pf_data::PFS.PowerFlowContainer) = + _provided_aux_vars(pf_data, PSY.ACBranch) +bus_aux_vars(pf_data::PFS.PowerFlowContainer) = _provided_aux_vars(pf_data, PSY.ACBus) # TODO: Needs update for MultiTerminal HVDC _get_branch_component_tuples(sys::PSY.System) = [ diff --git a/ext/PowerFlowsExt/pf_solve_and_aux.jl b/ext/PowerFlowsExt/pf_solve_and_aux.jl index ed7c87e6..a8e03d11 100644 --- a/ext/PowerFlowsExt/pf_solve_and_aux.jl +++ b/ext/PowerFlowsExt/pf_solve_and_aux.jl @@ -149,9 +149,7 @@ function IOM.calculate_aux_variable_value!( # Skip the aux vars that the current power flow isn't meant to update pf_e_data = latest_solved_power_flow_evaluation_data(container) pf_data = IOM.get_inner_data(pf_e_data) - key_type = IOM.get_entry_type(key) - (key_type in branch_aux_vars(pf_data) || key_type in bus_aux_vars(pf_data)) || - return + _pf_provides_aux_var(IOM.get_entry_type(key), pf_data) || return IOM.calculate_aux_variable_value!(container, key, system, pf_e_data) return end diff --git a/src/core/auxiliary_variables.jl b/src/core/auxiliary_variables.jl index fcb35f17..029669bf 100644 --- a/src/core/auxiliary_variables.jl +++ b/src/core/auxiliary_variables.jl @@ -88,6 +88,36 @@ convert_output_to_natural_units( }, ) = true +""" +The `PowerFlowAuxVariableType`s that are defined over components of type `C` — i.e. whose +values are indexed by branch or by bus. This is the complete universe of power flow +auxiliary variables; which *subset* of it a particular power flow evaluator actually +provides is the `_pf_provides_aux_var` trait in the `PowerFlowsExt`. + +Returns a tuple rather than a `Vector` deliberately: callers iterate it with `map`, so each +element keeps its concrete `Type{T}` and the `_pf_provides_aux_var` calls resolve at +compile time. Adding a `PowerFlowAuxVariableType` means adding it here and giving it +`_pf_provides_aux_var` methods; `test_power_flow_in_the_loop.jl` asserts by reflection that +no concrete subtype is missing from these tuples, so a forgotten entry fails in CI rather +than silently never registering. +""" +function pf_aux_var_types end + +pf_aux_var_types(::Type{PSY.ACBranch}) = ( + PowerFlowBranchReactivePowerFromTo, + PowerFlowBranchReactivePowerToFrom, + PowerFlowBranchActivePowerFromTo, + PowerFlowBranchActivePowerToFrom, + PowerFlowBranchActivePowerLoss, +) + +pf_aux_var_types(::Type{PSY.ACBus}) = ( + PowerFlowVoltageAngle, + PowerFlowVoltageMagnitude, + PowerFlowLossFactors, + PowerFlowVoltageStabilityFactors, +) + "Whether the auxiliary variable is calculated using a `PowerFlowEvaluationModel`" # Default is_from_evaluator(::Type{<:AuxVariableType}) = false is in IOM interfaces.jl is_from_evaluator(::Type{<:PowerFlowAuxVariableType}) = true diff --git a/test/test_power_flow_in_the_loop.jl b/test/test_power_flow_in_the_loop.jl index 917570ca..331d019f 100644 --- a/test/test_power_flow_in_the_loop.jl +++ b/test/test_power_flow_in_the_loop.jl @@ -1,3 +1,23 @@ +@testset "pf_aux_var_types enumerates every PowerFlowAuxVariableType" begin + # `_provided_aux_vars` walks the `pf_aux_var_types` tuples rather than reflecting over + # the type tree at runtime, so that the `_pf_provides_aux_var` trait calls resolve and + # fold at compile time. This test guards the gap that trade opens: a new + # `PowerFlowAuxVariableType` left out of the tuples would silently never be registered + # by any power flow evaluator. + branch_types = POM.pf_aux_var_types(PSY.ACBranch) + bus_types = POM.pf_aux_var_types(PSY.ACBus) + declared = union(Set(branch_types), Set(bus_types)) + defined = Set(IS.get_all_concrete_subtypes(POM.PowerFlowAuxVariableType)) + + @test setdiff(defined, declared) == Set{DataType}() # a type nothing would register + @test setdiff(declared, defined) == Set{DataType}() # a stale/removed entry + # No type may be claimed as both branch- and bus-indexed. + @test isempty(intersect(Set(branch_types), Set(bus_types))) + # Tuples, not vectors: the compile-time folding depends on it. + @test branch_types isa Tuple + @test bus_types isa Tuple +end + @testset "AC Power Flow in the loop with headroom-proportional slack" begin system = build_system(PSITestSystems, "c_sys5_uc")