From 7b67cd94fd84ac69c1fac9fba57d4d58d3c328d9 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Tue, 22 Sep 2026 09:08:42 -0600 Subject: [PATCH 01/14] Track PNM's correctness branch Pairs PowerFlows with PowerNetworkMatrices `jd/psy6_correctness`. No source change is needed: the two nr-less overloads that branch had removed -- `branch_admittance(b)` and `ybus_branch_entries(br)` -- are restored there, and the one PowerFlows call site for either, `_build_controlled_device_set`'s `PNM.branch_admittance(branch)` in src/discrete_control/control_metadata.jl, resolves again. `ybus_branch_entries` had already moved to the nr form here in 37f7ac2, for the reason that commit gives: a flow recomputed from solved voltages must be compared against the admittance the Ybus was actually stamped with. That stays as it is. The restored overload is for callers with no reduction in hand, which that one is not. --- Project.toml | 2 +- test/Project.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 1c47291a..b9bb2d54 100644 --- a/Project.toml +++ b/Project.toml @@ -40,7 +40,7 @@ InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/ PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems # 5.11, which this line (5.10.0) does not satisfy. -PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "psy6"} +PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "jd/psy6_correctness"} InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} diff --git a/test/Project.toml b/test/Project.toml index 8f0d325b..cf920a02 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -42,7 +42,7 @@ PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems # 5.11, which this line (5.10.0) does not satisfy. -PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "psy6"} +PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "jd/psy6_correctness"} # PSY's own [sources] pins for these are ignored once PSY is a dependency rather than the # root project; the test env needs its own pins so Pkg can resolve PSY's unregistered OpenAPI deps. # PSCB's own [sources] pin for this is ignored once PSCB is a dependency rather than the root From a54bba60b4cec490b55c9b4d605517ef4db606af Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Tue, 22 Sep 2026 10:28:12 -0600 Subject: [PATCH 02/14] Point the docs env at PNM's correctness branch too The root and test envs moved with `Track PNM's correctness branch`; docs/Project.toml kept its own `[sources]` at PNM psy6, so the docs gate on this branch was building against the other branch's PNM. `[sources]` is not inherited from a dev'd dependency, which is why this file repeats the pins at all and why it has to be kept in step by hand. Docs build against PNM jd/psy6_correctness: exit 0, no errors. --- docs/Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Project.toml b/docs/Project.toml index 746d5a53..b73593ab 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -25,7 +25,7 @@ PowerFlows = {path = ".."} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems # 5.11, which this line (5.10.0) does not satisfy. -PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "psy6"} +PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "jd/psy6_correctness"} PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl.git"} # PSCB's own [sources] pin for this is ignored once PSCB is a dependency rather than the root From 0d1a7efd53fcbc65b39ea05193ccb09ae9f34475 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Tue, 22 Sep 2026 16:15:34 -0600 Subject: [PATCH 03/14] test: replace malformed @test_broken; make AC reduce-then-solve tests reduce The nested-reduction @test_broken indexed the `:ok` Symbol, so it threw and recorded Broken regardless of behavior; the expanders were already fixed in #437. Assert branch names, flows against an unreduced solve (AC, DC, PTDF) and interior-bus write-back instead. The AC reduce-then-solve testsets built `pf` without `network_reductions`, so they compared unreduced vs unreduced. Build the reductions into `pf`, guard both helpers against a pf that does not carry the reductions it claims, and drop the dead commented-out Ward block. Radial stays convergence-only: PNM's radial reduction is exact only under the DC approximation. --- test/test_nested_reduction_flow_reporting.jl | 96 +++++++++---------- test/test_reduced_ac_power_flow.jl | 26 +++-- .../test_utils/validate_reduced_power_flow.jl | 2 + 3 files changed, 57 insertions(+), 67 deletions(-) diff --git a/test/test_nested_reduction_flow_reporting.jl b/test/test_nested_reduction_flow_reporting.jl index a2d29da0..824904ec 100644 --- a/test/test_nested_reduction_flow_reporting.jl +++ b/test/test_nested_reduction_flow_reporting.jl @@ -1,29 +1,16 @@ #= Flow reporting for reduction aggregates that contain other reduction aggregates. -`DegreeTwoReduction` deliberately builds two nested shapes (PNM `degree_two_reduction.jl`, -and PNM's own `test_nested_reduction_aggregates.jl`): +`DegreeTwoReduction` builds three nested shapes (PNM `degree_two_reduction.jl`, and PNM's own +`test_nested_reduction_aggregates.jl`): - a parallel group as a chain segment -- `BranchesSeries` holding a `BranchesParallel` - sibling chains in parallel -- `BranchesParallel{BranchesSeries}` + - both at once -- a `BranchesParallel{BranchesSeries}` whose chains + themselves carry a parallel segment -and the two compose, giving a `BranchesParallel{BranchesSeries}` whose chains themselves -carry a parallel segment. - -PowerFlows expands an aggregate arc back to its physical branches in two places, and both -descend only into the first shape: - - - AC: `_compute_segment_flows` (`post_processing.jl`), which special-cases - `segment isa AbstractBranchesParallel` inside the `BranchesSeries` method but hands every - member of a `AbstractBranchesParallel` straight to `_segment_flow_entry`. - - DC/PTDF: `_distribute_arc_flows`, with the same asymmetry. - -The first shape is covered here as a passing test. For `series_in_parallel` the reporting -and write-back paths no longer throw; only the per-branch expansion of the nested chains -(the reported flow names) remains `@test_broken`. Real -datasets rarely nest this far -- ACTIVSg2000 under `DegreeTwoReduction` has 410 parallel -groups, none of them parallel-of-chains, and 7 chains with a parallel segment -- so this is -documented rather than fixed. +AC, DC and PTDF branch-flow reporting, and AC write-back, expand every nested aggregate to +its physical branches for all three shapes. =# # Minimal systems that produce one nested aggregate each. Buses 1 and 2 carry injections, so @@ -135,17 +122,6 @@ function _nested_reduction_data(sys) ) end -"""Run `f` and report `:ok` or the exception, so a currently-throwing reporting path can be -pinned with `@test_broken` without the throw escaping the testset.""" -function _reporting_outcome(f) - try - f() - return :ok - catch e - return e - end -end - _ac_pf(; kwargs...) = PF.ACPowerFlow(; network_reductions = _degree_two(), kwargs...) @testset "nested aggregates: parallel group inside a chain" begin @@ -191,10 +167,8 @@ _ac_pf(; kwargs...) = PF.ACPowerFlow(; network_reductions = _degree_two(), kwarg @test isapprox(PSY.get_angle(b3), full_b3[1, :θ]; atol = 1e-6) end -# Both remaining shapes fail the same way and for the same reason: the expanders treat every -# member of a parallel group as a physical branch, so a `BranchesSeries` member is never -# descended into. `:series_in_parallel` shows the defect needs only two levels of nesting; -# `:nested` is the three-level case. +# `:series_in_parallel` nests a chain inside a parallel group (two levels); `:nested` adds a +# parallel segment inside one of those chains (three levels). _NESTED_PARALLEL_SHAPES = (:series_in_parallel, :nested) @testset "nested aggregates: chains inside a parallel group ($shape)" for shape in @@ -217,29 +191,47 @@ _NESTED_PARALLEL_SHAPES = (:series_in_parallel, :nested) Set(["L13", "L32", "L14", "L42"]) end - # AC: the per-branch expansion of the chains is what remains broken (next assertion). - ac_outcome = _reporting_outcome( - () -> solve_power_flow(_ac_pf(), sys, PF.FlowReporting.BRANCH_FLOWS), - ) - @test ac_outcome === :ok - @test_broken Set(ac_outcome["flow_results"].flow_name) == expected_names + # Every physical branch is reported, and reported flows match an unreduced solve. + reduced = solve_power_flow(_ac_pf(), sys, PF.FlowReporting.BRANCH_FLOWS)["flow_results"] + full = + solve_power_flow(PF.ACPowerFlow(), sys, PF.FlowReporting.BRANCH_FLOWS)["flow_results"] + @test Set(reduced.flow_name) == expected_names + joined = DataFrames.innerjoin(reduced, full; on = :flow_name, makeunique = true) + @test size(joined, 1) == length(expected_names) + for row in eachrow(joined) + @test isapprox(row.P_from_to, row.P_from_to_1; atol = 1e-3) + @test isapprox(row.Q_from_to, row.Q_from_to_1; atol = 1e-3) + end - for pf in ( - PF.DCPowerFlow(; network_reductions = _degree_two()), - PF.PTDFDCPowerFlow(; network_reductions = _degree_two()), - ) - dc_outcome = _reporting_outcome( - () -> solve_power_flow(pf, sys, PF.FlowReporting.BRANCH_FLOWS), - ) - @test dc_outcome === :ok + for PFType in (PF.DCPowerFlow, PF.PTDFDCPowerFlow) + df = solve_power_flow( + PFType(; network_reductions = _degree_two()), + sys, + PF.FlowReporting.BRANCH_FLOWS, + )["1"]["flow_results"] + full_df = + solve_power_flow(PFType(), sys, PF.FlowReporting.BRANCH_FLOWS)["1"]["flow_results"] + @test Set(df.flow_name) == expected_names + joined = DataFrames.innerjoin(df, full_df; on = :flow_name, makeunique = true) + @test size(joined, 1) == length(expected_names) + for row in eachrow(joined) + @test isapprox(row.P_from_to, row.P_from_to_1; atol = 1e-3) + end end + # The chain interiors' bus voltages are recovered on write-back. sys2 = _nested_reduction_system(shape) - store_outcome = _reporting_outcome(() -> solve_and_store_power_flow!(_ac_pf(), sys2)) - @test store_outcome === :ok + solve_and_store_power_flow!(_ac_pf(), sys2) + full_bus = solve_power_flow(PF.ACPowerFlow(), sys2)["bus_results"] + for name in ("b3", "b4") + bus = PSY.get_component(PSY.ACBus, sys2, name) + full_row = filter(row -> row.bus_number == PSY.get_number(bus), full_bus) + @test isapprox(PSY.get_magnitude(bus), full_row[1, :Vm]; atol = 1e-6) + @test isapprox(PSY.get_angle(bus), full_row[1, :θ]; atol = 1e-6) + end - # Arc-level reporting does not throw, but silently reports only the equivalent arc: the - # whole nest collapses to a single 1-2 row rather than the physical branches. + # Arc-level reporting reports the equivalent arc by design: the whole nest collapses to + # a single 1-2 row rather than the physical branches. arc_df = solve_power_flow(_ac_pf(), sys)["flow_results"] @test size(arc_df, 1) == 1 @test arc_df[1, :bus_from] == 1 && arc_df[1, :bus_to] == 2 diff --git a/test/test_reduced_ac_power_flow.jl b/test/test_reduced_ac_power_flow.jl index 5da93bcb..4293f597 100644 --- a/test/test_reduced_ac_power_flow.jl +++ b/test/test_reduced_ac_power_flow.jl @@ -9,6 +9,9 @@ const UNSUPPORTED = (PNM.WardReduction, PF.ACPowerFlow{PF.TrustRegionACPowerFlow}), ], ) +# PNM's radial reduction is exact only under the DC approximation; AC drops the leaf +# branch's losses/Q and the radial bus's voltage regulation, so retained-bus results +# aren't expected to match the unreduced solve (convergence-only check). const NOT_EQUIVALENT = Set( [ @@ -34,9 +37,12 @@ ac_reduction_types = Dict{String, Vector{PNM.NetworkReduction}}( unreduced = PF.PowerFlowData(pf_unreduced, sys) PF.solve_power_flow!(unreduced) @assert all(unreduced.converged) - pf = ACPowerFlow{PF.TrustRegionACPowerFlow}(; correct_bustypes = true) for (k, v) in ac_reduction_types isempty(v) && continue # no reduction at all. + pf = ACPowerFlow{PF.TrustRegionACPowerFlow}(; + correct_bustypes = true, + network_reductions = deepcopy(v), + ) if any([(typeof(nr), typeof(pf)) in UNSUPPORTED for nr in v]) @warn "Skipping unsupported combination" continue @@ -54,9 +60,12 @@ end @testset "all reductions on psse_14_network_reduction_test_system" begin sys = PSB.build_system(PSSEParsingTestSystems, "psse_14_network_reduction_test_system") - pf = ACPowerFlow{PF.TrustRegionACPowerFlow}(; correct_bustypes = true) for (k, v) in ac_reduction_types + pf = ACPowerFlow{PF.TrustRegionACPowerFlow}(; + correct_bustypes = true, + network_reductions = deepcopy(v), + ) if any([(typeof(nr), typeof(pf)) in UNSUPPORTED for nr in v]) @warn "Skipping unsupported combination" continue @@ -66,19 +75,6 @@ end @test all(result.converged) end end - - # not yet implemented. - #= - @testset "ward reduction" begin - study_buses = [101, 114, 110, 111] - result = test_reduced_power_flow( - pf, - sys, - PNM.NetworkReduction[PNM.WardReduction(study_buses)], - ) - @test all(result.converged) broken = true - end - =# end @testset "system + power flow solver calls" begin diff --git a/test/test_utils/validate_reduced_power_flow.jl b/test/test_utils/validate_reduced_power_flow.jl index baa15b7c..69ae0f35 100644 --- a/test/test_utils/validate_reduced_power_flow.jl +++ b/test/test_utils/validate_reduced_power_flow.jl @@ -4,6 +4,7 @@ function test_reduced_power_flow( nrs::Vector{PNM.NetworkReduction}, ) data = PF.PowerFlowData(pf, sys) + @test map(typeof, PF.get_network_reductions(pf)) == map(typeof, nrs) if pf isa PF.ACPowerFlow PF.solve_power_flow!(data; pf = pf) else @@ -53,6 +54,7 @@ function validate_reduced_power_flow( unreduced_solved_data::PF.PowerFlowData, ) data = PF.PowerFlowData(pf, sys) + @test map(typeof, PF.get_network_reductions(pf)) == map(typeof, nrs) if pf isa PF.ACPowerFlow PF.solve_power_flow!(data; pf = pf) else From 0d2bd9c07e01362775d545258b86c4ef1853e3e6 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 22 Sep 2026 19:31:07 -0600 Subject: [PATCH 04/14] Export v35 switched-shunt Si as a 0/1 block status Si is a whole-block status, but the exporter wrote `number_engaged` into it. Since PowerFlowFileParser.jl#59 that holds step counts (an in-service block engages all Ni steps), so blocks came out as `S2 = 2`, `S3 = 3`, which is not valid PSS/E. A partly engaged block cannot be spelled in the blocks at all; BINIT still carries the exact total. Pin PSCB to lk/switched-shunt-solved-case (PowerSystemCaseBuilder.jl#227) so the case25 round-trip compares `nothing` against `nothing` for discretely switched shunts instead of PSCB's 0.0 default. Revert the pin to psy6 once #227 merges. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/psse_export.jl | 4 +++- test/Project.toml | 2 +- test/test_psse_export.jl | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/psse_export.jl b/src/psse_export.jl index 2e11e0ac..7b285523 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -2909,7 +2909,9 @@ function _build_switched_shunt_steps_v35( N_vals = [] B_vals = [] for (N, B) in zip(steps, increases) - push!(S_vals, get(engaged, length(S_vals) + 1, 1)) + # Si is a whole-block status (0/1), not a step count; a partly engaged block can + # only be expressed through BINIT. + push!(S_vals, get(engaged, length(S_vals) + 1, 1) != 0 ? 1 : 0) push!(N_vals, N) push!(B_vals, imag(B) * base_power) end diff --git a/test/Project.toml b/test/Project.toml index cf920a02..59154777 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -37,7 +37,7 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" PowerFlows = {path = ".."} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} # PSCB/parser branches carry the PSS/E import-contract fixes the round-trip tests need -PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} +PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "lk/switched-shunt-solved-case"} PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index 80801936..bc729d88 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -675,6 +675,27 @@ end @test PSY.get_regulated_bus_number(shunt2) == 7 end +@testset "PSSE Exporter: v35 switched shunt Si is a 0/1 block status" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + shunt = PSY.SwitchedAdmittance(; + name = "shunt_1", + available = true, + bus = b1, + number_engaged = [3, 1, 0], + number_of_steps = [3, 2, 4], + Y_increase = [0.05im, 0.02im, 0.01im], + ) + S, N, _ = PF._build_switched_shunt_steps_v35( + shunt, + PSY.get_number_of_steps(shunt), + PSY.get_Y_increase(shunt), + 100.0, + ) + @test S[1:3] == [1, 1, 0] + @test N[1:3] == [3, 2, 4] +end + @testset "PSSE Exporter: phase-shift control_limits round-trip degrees/radians (v33)" begin # PSS/E RMA/RMI are degrees for phase-shift CODs (ACTIVE_POWER_FLOW here); PSY stores # `control_limits` in radians for those objectives. The exporter must write degrees, and From 0a207c0887c5d5fee8017fb49637a8363b32e10e Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Wed, 23 Sep 2026 08:04:32 -0600 Subject: [PATCH 05/14] Apply psy6 review fixes; run tests with ParallelTestRunner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the verified PF-side findings of the 2026-09-22 psy6 review: PSS/E export (LCC SETVL in MW, droop VSC DCSET sign, RATE/SBASE, solution records), discrete-control sensitivity and batched refresh, polar/rect/mixed/FD NR cache reuse, DC loss factors and backend resolution, post-processing and redistribution, LCC/VSC loss handling, typed SolutionParameters replacing the solver_settings Dict, and concretely typed kernels. rect_finalize_bus_injections! adds back only the constant-power (REF) or non-impedance (PV) withdrawal, since the residual nets constant current and Y_bus_eff holds constant Z, plus the HVDC term. FD's single-π test now uses PNM's arc-keyed has_single_pi_equivalent instead of a ComplexF32 re-check with its own tolerance. Tests run one worker per file under ParallelTestRunner with a shared test/includes.jl. The case16/case25 PSS/E round-trips still fail on LCC active_power_flow: PSB-built systems store transfer_setpoint per-unit although PSY documents MW (upstream). --- .claude/CLAUDE.md | 16 +- .../benchmarks/discrete_control_scaling.jl | 6 +- .../formulation_solver_comparison.jl | 2 +- scripts/benchmarks/method_comparison.jl | 4 +- .../profiling/profile_power_flow_solvers.jl | 16 +- src/PowerFlowData.jl | 42 +- src/PowerFlows.jl | 9 +- .../HessianSolver/fixed_structure_CHOLMOD.jl | 1 - src/RobustHomotopy/homotopy_hessian.jl | 423 ++++++++++----- src/ac_power_flow_jacobian.jl | 11 +- src/ac_power_flow_residual.jl | 78 +-- src/common.jl | 42 +- src/definitions.jl | 1 + src/discrete_control/control_continuation.jl | 126 +++-- src/discrete_control/control_metadata.jl | 31 +- src/discrete_control/control_sensitivity.jl | 77 ++- src/fast_decoupled_matrices.jl | 52 +- src/fast_decoupled_method.jl | 146 +++++- src/lcc_utils.jl | 6 +- src/levenberg-marquardt.jl | 184 ++++--- src/linear_solver_backend.jl | 60 ++- src/mixed_cpb_power_flow_jacobian.jl | 4 +- src/mixed_cpb_power_flow_residual.jl | 4 +- src/post_processing.jl | 230 +++++--- src/power_flow_method.jl | 412 ++++++++++----- src/power_flow_types.jl | 71 ++- src/powersystems_utils.jl | 9 +- src/psse_export.jl | 133 ++--- src/psse_solution_records.jl | 109 ++-- src/rectangular_ci_power_flow_jacobian.jl | 4 +- src/rectangular_ci_power_flow_residual.jl | 4 +- src/rectangular_ci_setup.jl | 14 +- src/residual_condition_diagnostics.jl | 46 +- src/solution_parameters.jl | 167 +++--- src/solve_ac_power_flow.jl | 87 ++- src/solve_dc_power_flow.jl | 200 +++++-- test/PowerFlowsTests.jl | 144 ----- test/Project.toml | 2 +- test/includes.jl | 93 ++++ test/load_tests.jl | 32 -- test/performance/fd_benchmark.jl | 6 +- test/performance/performance_test.jl | 14 +- test/runtests.jl | 53 +- test/test_ac_nr_allocations.jl | 49 +- test/test_aqua.jl | 19 + test/test_area_interchange_enrollment.jl | 64 --- test/test_area_interchange_solve.jl | 47 +- test/test_dc_power_flow.jl | 50 ++ test/test_discrete_control.jl | 120 ++++- test/test_fast_decoupled.jl | 216 +++++++- test/test_gradient_descent_ac_power_flow.jl | 2 +- test/test_hvdc.jl | 52 ++ test/test_iterative_methods.jl | 80 +-- test/test_jacobian.jl | 44 -- test/test_lcc_discrete_control.jl | 61 +-- test/test_mixed_cpb_flat_start.jl | 10 +- test/test_mixed_cpb_jacobian.jl | 6 +- test/test_mixed_cpb_lcc.jl | 6 +- test/test_mixed_cpb_polar_parity.jl | 105 +--- test/test_mixed_cpb_power_flow.jl | 32 +- test/test_nr_cache_reuse.jl | 159 +++++- test/test_pardiso_backend.jl | 6 +- test/test_post_processing.jl | 68 +++ test/test_psi_utils.jl | 8 + test/test_psse_export.jl | 188 ++++++- test/test_psse_solution_records.jl | 69 +++ test/test_rectangular_ci_jacobian.jl | 8 +- test/test_rectangular_ci_lcc.jl | 14 +- test/test_rectangular_ci_polar_parity.jl | 141 ++--- test/test_rectangular_ci_power_flow.jl | 81 ++- test/test_reduced_ac_power_flow.jl | 127 +++++ test/test_residual_condition_diagnostics.jl | 27 +- test/test_solution_parameters.jl | 40 +- test/test_solve_power_flow.jl | 24 +- test/test_utils/common.jl | 10 +- test/test_utils/cross_file_fixtures.jl | 496 ++++++++++++++++++ test/test_vsc_limits.jl | 14 +- test/test_vsc_power_flow.jl | 206 ++++---- test/test_vsc_results.jl | 8 +- 79 files changed, 3853 insertions(+), 1975 deletions(-) delete mode 100644 test/PowerFlowsTests.jl create mode 100644 test/includes.jl delete mode 100644 test/load_tests.jl create mode 100644 test/test_aqua.jl create mode 100644 test/test_utils/cross_file_fixtures.jl diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 21f11e5d..edda99a8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -65,9 +65,9 @@ Exported solver-model types and functions (see `src/PowerFlows.jl`): **MCPB (mixed current-power-balance).** PQ buses use divided current balance (imag-first row order); PV buses use real-power balance + `|V|²` constraint with **only 2 vars/bus** (no Q state — the key difference from rectangular's 3). REF = (P_gen, Q_gen). System size is exactly 2n. Status: opt-in, NOT default; do not deprecate rectangular. Validated to polar parity. Performance: for NR/TR ≈ rectangular (no net win); for **LM, Mixed decisively beats Rectangular** (rectangular-LM fails to converge at ~10k buses; Mixed-LM converges like Polar-LM with the smallest 2n state). Jacobian kernels are called as concrete top-level functions, never stored as abstract `::Function` fields (that forces dynamic dispatch on the hot path). -**`validate_voltage_magnitudes`** exists for polar, rectangular, and mixed. For rect/mixed it checks squared bounds (`e²+f² ∈ [min²,max²]`) for PQ and PV (PV `(e,f)` are real state vars and `|V|²` can drift mid-iteration before the constraint row pins it); REF skipped. Toggle via `solver_settings[:validate_voltage_magnitudes]`. +**`validate_voltage_magnitudes`** exists for polar, rectangular, and mixed. For rect/mixed it checks squared bounds (`e²+f² ∈ [min²,max²]`) for PQ and PV (PV `(e,f)` are real state vars and `|V|²` can drift mid-iteration before the constraint row pins it); REF skipped. Toggle via the `validate_voltage_magnitudes` keyword of the formulation constructor (a `SolutionParameters` field; the old `solver_settings` Dict is gone). -**Linear-solver backends (PNM-owned).** PowerFlows no longer hand-rolls a KLU cache — KLU is **not** a direct dependency. Backends come from PNM (`PNM.KLULinSolveCache{Tv,Ti}`, `PNM.AAFactorCache` for AppleAccelerate) plus a PowerFlows-defined `PardisoLinSolveCache` in `ext/PowerFlowsPardisoExt.jl`. Selection = PNM preference default + per-solve kwarg (AC: `solver_settings[:linear_solver]`; DC: kwarg). +**Linear-solver backends (PNM-owned).** PowerFlows no longer hand-rolls a KLU cache — KLU is **not** a direct dependency. Backends come from PNM (`PNM.KLULinSolveCache{Tv,Ti}`, `PNM.AAFactorCache` for AppleAccelerate) plus a PowerFlows-defined `PardisoLinSolveCache` in `ext/PowerFlowsPardisoExt.jl`. Selection = PNM preference default + the `linear_solver` keyword (AC: a `SolutionParameters` field, resolved to a `String` at construction; DC: per-solve kwarg). `"Dense"` is rejected at resolution time. - Index width gates the AppleAccelerate path: `INDEX_TYPE = @static Sys.isapple() ? Int64 : Int32` (drives `J_INDEX_TYPE`/`REC_INDEX_TYPE`). AA's Apple `libSparse` ABI needs Int64 `columnStarts`; KLU uses Int32 elsewhere. Any cache-type `Union` must list BOTH `KLULinSolveCache{Float64,Int32}` and `{…,Int64}` (PNM's DC ABA factorization is always Int64 — omitting it MethodErrors on Linux). - The KLU and AA backends do NOT share generic functions: `PNM.solve!/full_factor!/symbolic_factor!/numeric_refactor!/tsolve!` are KLU-only (`.KLUWrapper`); AA's live in `PNM.AccelerateWrapper.*`. PowerFlows defines local dispatch over both cache types. `AAFactorCache` is Int-only with NO transpose solve, so voltage-stability factors (need Aᵀ\b) stay KLU-only. - MKLPardiso is x86_64-only; `resolve_linear_solver_backend` rejects it when `Sys.ARCH !== :x86_64`; Pardiso tests gate on `Pardiso.mkl_is_available()`. @@ -88,7 +88,7 @@ Exported solver-model types and functions (see `src/PowerFlows.jl`): ## Commands (verified against this clone) -This package uses **ReTest** and a `test/Project.toml` env (deps incl. PowerSystemCaseBuilder, ReTest, Pardiso, Aqua). Read the `sienna-test-environment` skill for the shared rules; PowerFlows specifics: +This package uses **ParallelTestRunner** and a `test/Project.toml` env (deps incl. PowerSystemCaseBuilder, ParallelTestRunner, Pardiso, Aqua) — one worker process per `test_*.jl` file, sharing nothing but `test/includes.jl`'s preamble. Read the `sienna-test-environment` skill for the shared rules; PowerFlows specifics: ```sh # Compile-check between edits (package env, fast): @@ -96,7 +96,7 @@ julia --project -e 'using PowerFlows' # One-time per clone: make --project=test resolve PowerFlows to the WORKING TREE # (else it can resolve the registered copy in ~/.julia/packages and run stale source, -# and new test/test_*.jl files are invisible to the glob in test/PowerFlowsTests.jl): +# and new test/test_*.jl files are invisible to the glob in test/runtests.jl): julia --project=test -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd()))' # Verify (must print the working-tree path, not ~/.julia/packages/...): julia --project=test -e 'import Pkg; println(Base.find_package("PowerFlows"))' @@ -104,8 +104,10 @@ julia --project=test -e 'import Pkg; println(Base.find_package("PowerFlows"))' # Run full suite: julia --project=test test/runtests.jl -# Run a filtered subset via ReTest: -julia --project=test -e 'using PowerFlows; include("test/PowerFlowsTests.jl"); using .PowerFlowsTests, ReTest; retest(PowerFlowsTests, r"")' +# Run a subset filtered by FILE name (startswith), cap parallelism, or list discoverable tests: +julia --project=test test/runtests.jl test_dc_power_flow +julia --project=test test/runtests.jl --jobs=4 +julia --project=test test/runtests.jl --list # Docs: julia --project=docs docs/make.jl @@ -114,7 +116,7 @@ julia --project=docs docs/make.jl julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' ``` -ReTest runs the whole suite and reports failures at the end (does not abort on first failure). Note `runtests.jl` aborts the whole run at the first exception outside a `@test`; "suite green" means the run REACHED the final `Main.PowerFlowsTests | ` summary with no Error column. Under recent PSY/IS, `PSY.System("file.raw")` may not parse PSS/E raw — use the PowerSystemCaseBuilder `PowerFlowFileParser` path for raw inputs in tests. +Each test file runs as its own testset in its own worker process; the runner reports pass/fail per file and does not abort the whole run on one file's failure. Under recent PSY/IS, `PSY.System("file.raw")` may not parse PSS/E raw — use the PowerSystemCaseBuilder `PowerFlowFileParser` path for raw inputs in tests. ## Auto-generated files / do-not-edit diff --git a/scripts/benchmarks/discrete_control_scaling.jl b/scripts/benchmarks/discrete_control_scaling.jl index 41e2e94d..fb30cffe 100644 --- a/scripts/benchmarks/discrete_control_scaling.jl +++ b/scripts/benchmarks/discrete_control_scaling.jl @@ -67,7 +67,7 @@ function _add_feeder!(sys::PSY.System, ref::PSY.ACBus, k::Int) control_objective = PSY.TransformerControlObjective.VOLTAGE))) PSY.add_component!(sys, PSY.SwitchedAdmittance(; name = "shunt_$k", available = true, bus = b_sh, - Y = 0.0 + 0.0im, initial_status = [0], number_of_steps = [4], + number_engaged = [0], number_of_steps = [4], Y_increase = [0.0 + 0.05im], admittance_limits = (min = 0.9, max = 1.1))) return nothing end @@ -91,8 +91,8 @@ end # 0-iteration warm-start early return). function _perturb_loads!(sys::PSY.System, rng) for ld in PSY.get_components(PSY.PowerLoad, sys) - base = PSY.get_active_power(ld) - PSY.set_active_power!(ld, base * (1.0 + 0.1 * (rand(rng) - 0.5))) + base = PSY.get_active_power(ld, PSY.SU) + PSY.set_active_power!(ld, base * (1.0 + 0.1 * (rand(rng) - 0.5)) * PSY.SU) end return nothing end diff --git a/scripts/benchmarks/formulation_solver_comparison.jl b/scripts/benchmarks/formulation_solver_comparison.jl index 3115e44f..1d98681d 100644 --- a/scripts/benchmarks/formulation_solver_comparison.jl +++ b/scripts/benchmarks/formulation_solver_comparison.jl @@ -98,7 +98,7 @@ function run_system(group, name, build_kwargs, extra_settings) merge(Dict{Symbol, Any}(:validate_voltage_magnitudes => false), extra_settings) end - pf = F{S}(; correct_bustypes = true, solver_settings = settings) + pf = F{S}(; correct_bustypes = true, solution_parameters = SolutionParameters(; settings...)) bench(pf, sys, "$fname / $sname") end return diff --git a/scripts/benchmarks/method_comparison.jl b/scripts/benchmarks/method_comparison.jl index 01d3453e..f37258f5 100644 --- a/scripts/benchmarks/method_comparison.jl +++ b/scripts/benchmarks/method_comparison.jl @@ -27,7 +27,7 @@ const PSY = PowerSystems const PF = PowerFlows # ───────────────────────────────────────────────────────────────────────────── -# Custom logger that intercepts the @info messages emitted by the solver +# Custom logger that intercepts the @debug convergence messages emitted by the solver # and extracts iteration count and residual norms. # ───────────────────────────────────────────────────────────────────────────── mutable struct MetricsCapture @@ -183,7 +183,7 @@ function run_trial(sys, solver_type, solver_settings, x_solved, n, bus_types, K; # Build PF object pf = ACPowerFlow{solver_type}(; correct_bustypes = true, - solver_settings = solver_settings, + solution_parameters = PF.SolutionParameters(; solver_settings...), ) data = quietly(() -> PF.PowerFlowData(pf, sys)) diff --git a/scripts/profiling/profile_power_flow_solvers.jl b/scripts/profiling/profile_power_flow_solvers.jl index 0e62b216..66e8d57f 100644 --- a/scripts/profiling/profile_power_flow_solvers.jl +++ b/scripts/profiling/profile_power_flow_solvers.jl @@ -138,15 +138,17 @@ println(" branches = ", length(PSY.get_components(PSY.ACBranch, SYS))) println(" backends = ", join(available_backends(), ", ")) println() -# (label, solver type, extra solver_settings merged into the pf). The FD entries exercise both -# the polar :decoupled B′/B″ loop and the formulation-agnostic :fixed_jacobian (frozen J) loop. +# (label, solver type, extra SolutionParameters settings merged into the pf). The FD entries +# exercise both the polar :decoupled B′/B″ loop and the formulation-agnostic :fixed_jacobian +# (frozen J) loop — selected via the FastDecoupledACPowerFlow variant TYPE PARAMETER, not a +# settings key. const AC_SOLVERS = [ ("AC-NewtonRaphson", PF.NewtonRaphsonACPowerFlow, Dict{Symbol, Any}()), ("AC-TrustRegion", PF.TrustRegionACPowerFlow, Dict{Symbol, Any}()), - ("AC-FastDecoupled-decoupled", PF.FastDecoupledACPowerFlow, - Dict{Symbol, Any}(:fd_variant => :decoupled)), - ("AC-FastDecoupled-fixedjac", PF.FastDecoupledACPowerFlow, - Dict{Symbol, Any}(:fd_variant => :fixed_jacobian)), + ("AC-FastDecoupled-decoupled", PF.FastDecoupledACPowerFlow{PF.FDDecoupled}, + Dict{Symbol, Any}()), + ("AC-FastDecoupled-fixedjac", PF.FastDecoupledACPowerFlow{PF.FDFixedJacobian}, + Dict{Symbol, Any}()), ] # ───────────────────────────────────────────────────────────────────────────── @@ -174,7 +176,7 @@ function profile_ac(label, solver, backend, extra_settings = Dict{Symbol, Any}() settings = merge(Dict{Symbol, Any}(:linear_solver => backend), extra_settings) pf = ACPowerFlow{solver}(; correct_bustypes = true, - solver_settings = settings, + solution_parameters = PF.SolutionParameters(; settings...), ) # Build data ONCE and reuse it across (re-flat-started) solves, so the full-solve diff --git a/src/PowerFlowData.jl b/src/PowerFlowData.jl index f7383e21..f4888642 100644 --- a/src/PowerFlowData.jl +++ b/src/PowerFlowData.jl @@ -163,15 +163,24 @@ struct PowerFlowData{ # are not reallocated. Lazily populated in place on the first solve (the `Base.Ref` avoids # reconstructing `data`). Holds a [`SolverCache`](@ref) — an abstract supertype forward-declared # in `power_flow_types.jl` so the field type resolves before the concrete subtypes are defined. - # Two TYPE-DISJOINT subtypes share this one slot: - # * DC path (`ABA`/PTDF data): a [`DCSolverCache`](@ref) holding the factored network matrix + - # backend (the invalidation key — rebuild when either changes, see - # `_get_or_build_solver_cache!`), the `PFLinearSolverCache`, and the per-solve scratch. - # * AC path, FastDecoupled solver (`ACPowerFlowData`): a `FastDecoupledCache` holding the - # factored B′ (once per data/scheme/backend) and per-PQ-set factored B″ submatrices - # (see `_get_or_build_fd_cache!`). - # Each getter dispatches on the cached subtype, so an empty slot or a cross-use fails loudly - # (a `MethodError`) instead of being silently mis-read — no sentinel tag needed. + # Several TYPE-DISJOINT subtypes share this one slot: + # * DC path (`ABA`/PTDF/vPTDF data): a [`DCSolverCache`](@ref) holding the factored network + # matrix + backend (the invalidation key — rebuild when either changes, see + # `_dc_solve!`), the `PFLinearSolverCache`, and the per-solve scratch. + # * PTDF only, before its first solve: a `DCScratchStage` holding scratch built at + # construction (while the source `Ybus` is still in scope), promoted to a + # `DCSolverCache` on the first solve once a backend is chosen. + # * AC path, FastDecoupled solver (`ACPowerFlowData`): a `FastDecoupledCache` (`:decoupled`) + # or `FDFixedJacobianCache` (`:fixed_jacobian`), holding the factored B′/frozen-Jacobian + # factorization (see `_get_or_build_fd_cache!` / `_get_or_build_fdj_cache!`). + # * AC path, rect/mixed NR/TR (`ACPowerFlowData`): a `RectMixedNRCache` + # (`power_flow_method.jl`) holding the linear-solver factorization and state-vector + # buffers, reused when the rebuilt Jacobian's pattern matches (see + # `_get_or_build_rect_mixed_cache!`); unlike the FD caches, a cross-use from this slot's + # other AC-path subtypes rebuilds rather than erroring — an ordinary solver-type switch on + # the same `data`, not a program bug. + # Except where noted above, a getter dispatching on the cached subtype fails loudly (a + # `MethodError`) on an empty slot or a cross-use, instead of being silently mis-read. solver_cache::Base.RefValue{Union{Nothing, SolverCache}} controlled_devices::Union{Nothing, ControlledDeviceSet} # Memoized NR/TR AC-Jacobian sparse structure. Its OWN slot (not `solver_cache`) because the @@ -847,13 +856,26 @@ function PowerFlowData( aux_network_matrix = PNM.ABA_Matrix(ybus; factorize = true) # `get_arc_axis(data)`/`get_bus_lookup(data)` read the PTDF (metadata) matrix for this method. arc_bus_incidence = _signed_arc_bus_incidence(ybus, power_network_matrix) - return make_and_initialize_power_flow_data( + # Built from the SAME `ybus` as the PTDF matrix so its bus/arc axes match without a + # permutation; staged into scratch below. + ba_matrix = PNM.BA_Matrix(ybus) + if PNM.get_arc_axis(ba_matrix) != PNM.get_arc_axis(power_network_matrix) || + PNM.get_bus_axis(ba_matrix) != PNM.get_bus_axis(power_network_matrix) + error( + "PNM.BA_Matrix and PNM.PTDF built from the same Ybus have different axes; " * + "this is a bug in PowerNetworkMatrices.", + ) + end + data = make_and_initialize_power_flow_data( pf, sys, power_network_matrix, aux_network_matrix; arc_bus_incidence = arc_bus_incidence, ) + # Stage the scratch now, while `ba_matrix` is in scope; the first solve promotes it. + data.solver_cache[] = DCScratchStage(_make_dc_scratch(data; ba = ba_matrix.data)) + return data end # DC Power Flow Data with virtual PTDF matrix diff --git a/src/PowerFlows.jl b/src/PowerFlows.jl index e2f5f066..7d0b518e 100644 --- a/src/PowerFlows.jl +++ b/src/PowerFlows.jl @@ -109,13 +109,16 @@ include("power_flow_setup.jl") include("power_flow_method.jl") include("fast_decoupled_matrices.jl") include("fast_decoupled_method.jl") -include("levenberg-marquardt.jl") -include("gradient_descent_ac_power_flow.jl") -include("post_processing.jl") +# RobustHomotopy's HessianSolver machinery (FixedStructureCHOLMOD, the JᵀJ nzval-pair +# cache) precedes levenberg-marquardt.jl because LMWorkspace reuses both as a struct +# field type and via the shared JᵀJ refill helpers. include("RobustHomotopy/HessianSolver/hessian_solver.jl") include("RobustHomotopy/HessianSolver/KLU_hessian_solver.jl") include("RobustHomotopy/HessianSolver/fixed_structure_CHOLMOD.jl") include("RobustHomotopy/HessianSolver/cholesky_solver.jl") include("RobustHomotopy/homotopy_hessian.jl") include("RobustHomotopy/robust_homotopy_method.jl") +include("levenberg-marquardt.jl") +include("gradient_descent_ac_power_flow.jl") +include("post_processing.jl") end diff --git a/src/RobustHomotopy/HessianSolver/fixed_structure_CHOLMOD.jl b/src/RobustHomotopy/HessianSolver/fixed_structure_CHOLMOD.jl index 0bdc60b9..a2fb0bd4 100644 --- a/src/RobustHomotopy/HessianSolver/fixed_structure_CHOLMOD.jl +++ b/src/RobustHomotopy/HessianSolver/fixed_structure_CHOLMOD.jl @@ -53,4 +53,3 @@ end Base.size(mat::FixedStructureCHOLMOD) = size(mat._mat) Base.getindex(mat::FixedStructureCHOLMOD, I...) = getindex(mat._mat, I...) Base.setindex!(mat::FixedStructureCHOLMOD, v, I...) = setindex!(mat._mat, v, I...) -Base.eltype(mat::FixedStructureCHOLMOD) = eltype(mat._mat) diff --git a/src/RobustHomotopy/homotopy_hessian.jl b/src/RobustHomotopy/homotopy_hessian.jl index d19ac3e6..7abfa2ac 100644 --- a/src/RobustHomotopy/homotopy_hessian.jl +++ b/src/RobustHomotopy/homotopy_hessian.jl @@ -11,27 +11,95 @@ struct HomotopyHessian # (avoids a per-call sparse `setindex!` on those diagonals). Jt_R::Vector{Float64} pq_diag_nz::Vector{Int} + # nzval-offset caches for _update_hessian_matrix_values!, built once at construction + # (bus types are fixed for the life of a Hessian); see _build_hessian_edge_nz_cache. + # Each row of edge_nz/diag_nz holds an Hv.nzval index, or 0 when that term's + # write is structurally absent for the given bus-type combination. + edge_i::Vector{Int} + edge_k::Vector{Int} + edge_nz::Matrix{Int} # 11 x n_edges + diag_nz::Matrix{Int} # 4 x n_buses + diag_accum::Matrix{Float64} # 4 x n_buses scratch for the per-bus diagonal sums + # JᵀJ refill cache: for each hess.Hv nzval index, the pairs of J.Jv.nzval offsets + # (same row) whose product sums to that entry; see _build_jtj_nz_cache. + jtj_p1::Vector{Int} + jtj_p2::Vector{Int} + jtj_offsets::Vector{Int} # length nnz(Hv) + 1 end -"""Does `A += B' * B`, in a way that preserves the sparse structure of `A`, if possible. -A workaround for the fact that Julia seems to run `dropzeros!(A)` automatically if I just -do `A .+= B' * B`.""" -function A_plus_eq_BT_B!(A::SparseMatrixCSC, B::SparseMatrixCSC) - M = B' * B # shouldn't this be allocating too? - IS.@assert_op M.colptr == A.colptr - IS.@assert_op M.rowval == A.rowval - A.nzval .+= M.nzval +"""Refill `Hv.nzval[e] += dot(J[:,i], J[:,j])` for every structural entry `Hv[i,j]`, +using the row-pair cache from `_build_jtj_nz_cache` (built once per solve since J's +sparsity pattern is fixed). Replaces rebuilding `J' * J` from scratch every call.""" +function _refresh_JtJ!( + Hv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + p1::Vector{Int}, + p2::Vector{Int}, + offsets::Vector{Int}, +) + Jnz = Jv.nzval + Hnz = SparseArrays.nonzeros(Hv) + @inbounds for e in eachindex(Hnz) + s = 0.0 + for k in offsets[e]:(offsets[e + 1] - 1) + s += Jnz[p1[k]] * Jnz[p2[k]] + end + Hnz[e] += s + end return end +"""Build the once-per-construction row-pair cache for [`_refresh_JtJ!`](@ref): for +each nzval index of `Hv` (whose pattern is the fixed pattern of `J' * J`), the +`Jv.nzval` offset pairs `(a, b)` with the same row, one from column `i` and one from +column `j`, so that `Hv[i,j] = sum(Jv.nzval[a] * Jv.nzval[b] for (a,b) in pairs)`. +Both `Jv.rowval` ranges are sorted, so this is a merge, mirroring how sparse +matrix-matrix multiplication derives its numeric phase from a fixed symbolic pattern.""" +function _build_jtj_nz_cache( + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + Hv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, +) + nnzH = length(SparseArrays.nonzeros(Hv)) + offsets = Vector{Int}(undef, nnzH + 1) + p1 = Int[] + p2 = Int[] + Jrv = SparseArrays.rowvals(Jv) + offsets[1] = 1 + for col in 1:size(Hv, 2) + for hv_idx in SparseArrays.nzrange(Hv, col) + i = Hv.rowval[hv_idx] + ra, rb = SparseArrays.nzrange(Jv, i), SparseArrays.nzrange(Jv, col) + a, b = first(ra), first(rb) + enda, endb = last(ra), last(rb) + while a <= enda && b <= endb + row_a, row_b = Jrv[a], Jrv[b] + if row_a == row_b + push!(p1, a) + push!(p2, b) + a += 1 + b += 1 + elseif row_a < row_b + a += 1 + else + b += 1 + end + end + offsets[hv_idx + 1] = length(p1) + 1 + end + end + return p1, p2, offsets +end + """Compute value of gradient and Hessian at x.""" function (hess::HomotopyHessian)(x::Vector{Float64}, t_k::Float64, time_step::Int) hess.pfResidual(x, time_step) Rv = hess.pfResidual.Rv hess.J(time_step) Jv = hess.J.Jv - _update_hessian_matrix_values!(hess.Hv, Rv, hess.data, time_step) - A_plus_eq_BT_B!(hess.Hv, Jv) + _update_hessian_matrix_values!( + hess.Hv, Rv, hess.data, time_step, + hess.edge_i, hess.edge_k, hess.edge_nz, hess.diag_nz, hess.diag_accum) + _refresh_JtJ!(hess.Hv, Jv, hess.jtj_p1, hess.jtj_p2, hess.jtj_offsets) Hvnz = SparseArrays.nonzeros(hess.Hv) Hvnz .*= t_k # (1−t) homotopy term on the PQ |V| diagonal. @@ -186,8 +254,6 @@ function HomotopyHessian(data::ACPowerFlowData, time_step::Int) # the maximal pattern. We then restore J.Jv's original nzval — some # entries (e.g. LCC angle-constraint diagonals of 1.0) are set at # structure creation and not rewritten by subsequent J(time_step) calls. - # The per-call IS.@assert_op in A_plus_eq_BT_B! guards against any future - # change in Julia that would drop structural zeros at runtime. original_J_nzval = copy(SparseArrays.nonzeros(J.Jv)) fill!(SparseArrays.nonzeros(J.Jv), 1.0) Hv = J.Jv' * J.Jv @@ -211,9 +277,83 @@ function HomotopyHessian(data::ACPowerFlowData, time_step::Int) _nz_index(Hv, 2 * b - 1, 2 * b - 1) for b in 1:nbuses if bus_types[b] == PSY.ACBusTypes.PQ ] + edge_i, edge_k, edge_nz, diag_nz = + _build_hessian_edge_nz_cache(Hv, data, time_step) + jtj_p1, jtj_p2, jtj_offsets = _build_jtj_nz_cache(J.Jv, Hv) return HomotopyHessian( data, pfResidual, J, PQ_V_mags, zeros(n_state), Hv, - zeros(n_state), pq_diag_nz) + zeros(n_state), pq_diag_nz, + edge_i, edge_k, edge_nz, diag_nz, zeros(4, nbuses), + jtj_p1, jtj_p2, jtj_offsets) +end + +_has_theta(bt::PSY.ACBusTypes.Value) = bt == PSY.ACBusTypes.PQ || bt == PSY.ACBusTypes.PV + +"""Build the once-per-construction nzval-offset caches that drive +[`_update_hessian_matrix_values!`](@ref): for every ordered neighbor pair +`(i, k)` (i != k) and for every bus `i`, the `Hv.nzval` index of each term the +fill loop writes, or `0` when that term's bus-type combination means the write +is skipped (matching the loop's own conditionals exactly). Bus types are fixed +for the life of a `HomotopyHessian`, so this is safe to compute once and reuse +every call, replacing a sparse `setindex!` (binary search) with a direct +`Hvnz[idx] += val` per write.""" +function _build_hessian_edge_nz_cache( + Hv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + data::ACPowerFlowData, + time_step::Int, +) + num_buses = first(size(data.bus_type)) + bus_type = view(data.bus_type, :, time_step) + edge_i = Int[] + edge_k = Int[] + for i in 1:num_buses + for k in data.neighbors[i] + i == k && continue + push!(edge_i, i) + push!(edge_k, k) + end + end + n_edges = length(edge_i) + edge_nz = zeros(Int, 11, n_edges) + for e in 1:n_edges + i, k = edge_i[e], edge_k[e] + bt_i, bt_k = bus_type[i], bus_type[k] + has_θi, has_θk = _has_theta(bt_i), _has_theta(bt_k) + pq_i = bt_i == PSY.ACBusTypes.PQ + pq_k = bt_k == PSY.ACBusTypes.PQ + has_θk && (edge_nz[1, e] = _nz_index(Hv, 2 * k, 2 * k)) + if pq_k + edge_nz[2, e] = _nz_index(Hv, 2 * k - 1, 2 * k) + edge_nz[3, e] = _nz_index(Hv, 2 * k, 2 * k - 1) + end + if has_θi && has_θk + edge_nz[4, e] = _nz_index(Hv, 2 * i, 2 * k) + edge_nz[5, e] = _nz_index(Hv, 2 * k, 2 * i) + end + if pq_i && has_θk + edge_nz[6, e] = _nz_index(Hv, 2 * i - 1, 2 * k) + edge_nz[7, e] = _nz_index(Hv, 2 * k, 2 * i - 1) + end + if pq_k && has_θi + edge_nz[8, e] = _nz_index(Hv, 2 * i, 2 * k - 1) + edge_nz[9, e] = _nz_index(Hv, 2 * k - 1, 2 * i) + end + if pq_k && pq_i + edge_nz[10, e] = _nz_index(Hv, 2 * i - 1, 2 * k - 1) + edge_nz[11, e] = _nz_index(Hv, 2 * k - 1, 2 * i - 1) + end + end + diag_nz = zeros(Int, 4, num_buses) + for i in 1:num_buses + bt_i = bus_type[i] + _has_theta(bt_i) && (diag_nz[1, i] = _nz_index(Hv, 2 * i, 2 * i)) + if bt_i == PSY.ACBusTypes.PQ + diag_nz[2, i] = _nz_index(Hv, 2 * i, 2 * i - 1) + diag_nz[3, i] = _nz_index(Hv, 2 * i - 1, 2 * i) + diag_nz[4, i] = _nz_index(Hv, 2 * i - 1, 2 * i - 1) + end + end + return edge_i, edge_k, edge_nz, diag_nz end """ @@ -275,148 +415,151 @@ function _update_hessian_matrix_values!( Hv::SparseArrays.SparseMatrixCSC{Float64, J_INDEX_TYPE}, F_value::Vector{Float64}, data::ACPowerFlowData, - time_step::Int64) + time_step::Int64, + edge_i::Vector{Int}, + edge_k::Vector{Int}, + edge_nz::Matrix{Int}, + diag_nz::Matrix{Int}, + diag_accum::Matrix{Float64}) Yb = data.power_network_matrix.data Vm = view(data.bus_magnitude, :, time_step) θ = view(data.bus_angles, :, time_step) num_buses = first(size(data.bus_type)) - SparseArrays.nonzeros(Hv) .= 0.0 - for i in 1:num_buses - bt_i = data.bus_type[i, time_step] - Pi_θiθi, Qi_θiθi = 0.0, 0.0 - Pi_Viθi, Qi_Viθi = 0.0, 0.0 - has_θi = (bt_i == PSY.ACBusTypes.PQ) || (bt_i == PSY.ACBusTypes.PV) - for k in data.neighbors[i] - if i != k - bt_k = data.bus_type[k, time_step] - Gik, Bik = real(Yb[i, k]), imag(Yb[i, k]) - has_θk = (bt_k == PSY.ACBusTypes.PQ) || (bt_k == PSY.ACBusTypes.PV) - # the partials where all 3 indices are different vanish - # naively count 8 with 2 distinct indices: {∂Vₖ, ∂θₖ} x {∂Vₖ, ∂θₖ, ∂Vᵢ, ∂θᵢ} - # but can reduce to 6: ∂²/∂Vₖ∂θₖ = ∂²/∂θₖ∂Vₖ, and ∂²Δ{Pᵢ, Qᵢ}/∂²Vₖ is 0. - # start with the 4 involving ∂θₖ, then do remaining the 2 involving ∂Vₖ - if has_θk - # ∂²Δ{Pᵢ, Qᵢ}/∂²θₖ - Pi_θkθk = - Vm[i] * Vm[k] * ( # = Vm[k] * Qi_θkVk - -Gik * cos(θ[i] - θ[k]) - - - Bik * sin(θ[i] - θ[k]) - ) - Qi_θkθk = - Vm[i] * Vm[k] * ( # = -Vm[k] * Pi_θkVk - -Gik * sin(θ[i] - θ[k]) - + - Bik * cos(θ[i] - θ[k]) - ) - θkθks = Pi_θkθk * F_value[2 * i - 1] + Qi_θkθk * F_value[2 * i] - Hv[2 * k, 2 * k] += θkθks - end - if bt_k == PSY.ACBusTypes.PQ - # ∂²Δ{Pᵢ, Qᵢ}/∂θₖ∂Vₖ - Pi_θkVk = Vm[i] * ( - Gik * sin(θ[i] - θ[k]) - - - Bik * cos(θ[i] - θ[k]) - ) - Qi_θkVk = Vm[i] * ( - -Gik * cos(θ[i] - θ[k]) - - - Bik * sin(θ[i] - θ[k]) - ) - θkVks = Pi_θkVk * F_value[2 * i - 1] + Qi_θkVk * F_value[2 * i] - Hv[2 * k - 1, 2 * k] += θkVks - Hv[2 * k, 2 * k - 1] += θkVks - end - if has_θi - Pi_θkθi = - Vm[i] * Vm[k] * ( - Gik * cos(θ[i] - θ[k]) + - Bik * sin(θ[i] - θ[k]) - ) - Qi_θkθi = - Vm[i] * Vm[k] * ( - Gik * sin(θ[i] - θ[k]) - - - Bik * cos(θ[i] - θ[k]) - ) - # contribution towards sum in ∂²Δ{Pᵢ, Qᵢ}/∂θᵢ∂θᵢ - Pi_θiθi -= Pi_θkθi - Qi_θiθi -= Qi_θkθi - if has_θk - # ∂²Δ{Pᵢ, Qᵢ}/∂θₖ∂θᵢ - θiθks = Pi_θkθi * F_value[2 * i - 1] + Qi_θkθi * F_value[2 * i] - Hv[2 * i, 2 * k] += θiθks - Hv[2 * k, 2 * i] += θiθks - end - end - if bt_i == PSY.ACBusTypes.PQ - Pi_θkVi = Vm[k] * ( # = Vm[k] * Qi_VkVi - Gik * sin(θ[i] - θ[k]) - - - Bik * cos(θ[i] - θ[k]) - ) - Qi_θkVi = Vm[k] * ( # = -Vm[k] * Pi_VkVi - -Gik * cos(θ[i] - θ[k]) - - - Bik * sin(θ[i] - θ[k]) - ) - # contribution towards sum in ∂²Δ{Pᵢ, Qᵢ}/∂θᵢ∂Vᵢ - Pi_Viθi -= Pi_θkVi - Qi_Viθi -= Qi_θkVi - if has_θk - # ∂²Δ{Pᵢ, Qᵢ}/∂θₖ∂Vᵢ - Viθks = Pi_θkVi * F_value[2 * i - 1] + Qi_θkVi * F_value[2 * i] - Hv[2 * i - 1, 2 * k] += Viθks - Hv[2 * k, 2 * i - 1] += Viθks - end - end - if bt_k == PSY.ACBusTypes.PQ && has_θi - # ∂²Δ{Pᵢ, Qᵢ}/∂Vₖ∂θᵢ - Pi_Vkθi = Vm[i] * ( # = -Vm[i] * Qi_VkVi - -Gik * sin(θ[i] - θ[k]) - + - Bik * cos(θ[i] - θ[k]) - ) - Qi_Vkθi = Vm[i] * ( # = Vm[i] * Pi_VkVi - Gik * cos(θ[i] - θ[k]) - + - Bik * sin(θ[i] - θ[k]) - ) - θiVks = Pi_Vkθi * F_value[2 * i - 1] + Qi_Vkθi * F_value[2 * i] - Hv[2 * i, 2 * k - 1] += θiVks - Hv[2 * k - 1, 2 * i] += θiVks - end - if bt_k == PSY.ACBusTypes.PQ && bt_i == PSY.ACBusTypes.PQ - # ∂²Δ{Pᵢ, Qᵢ}/∂Vₖ∂Vᵢ - Pi_VkVi = Gik * cos(θ[i] - θ[k]) + Bik * sin(θ[i] - θ[k]) - Qi_VkVi = Gik * sin(θ[i] - θ[k]) - Bik * cos(θ[i] - θ[k]) - ViVks = Pi_VkVi * F_value[2 * i - 1] + Qi_VkVi * F_value[2 * i] - Hv[2 * i - 1, 2 * k - 1] += ViVks - Hv[2 * k - 1, 2 * i - 1] += ViVks - end + Hvnz = SparseArrays.nonzeros(Hv) + Hvnz .= 0.0 + fill!(diag_accum, 0.0) + @inbounds for e in eachindex(edge_i) + i, k = edge_i[e], edge_k[e] + bt_i, bt_k = data.bus_type[i, time_step], data.bus_type[k, time_step] + Gik, Bik = real(Yb[i, k]), imag(Yb[i, k]) + has_θi, has_θk = _has_theta(bt_i), _has_theta(bt_k) + # the partials where all 3 indices are different vanish + # naively count 8 with 2 distinct indices: {∂Vₖ, ∂θₖ} x {∂Vₖ, ∂θₖ, ∂Vᵢ, ∂θᵢ} + # but can reduce to 6: ∂²/∂Vₖ∂θₖ = ∂²/∂θₖ∂Vₖ, and ∂²Δ{Pᵢ, Qᵢ}/∂²Vₖ is 0. + # start with the 4 involving ∂θₖ, then do remaining the 2 involving ∂Vₖ + if has_θk + # ∂²Δ{Pᵢ, Qᵢ}/∂²θₖ + Pi_θkθk = + Vm[i] * Vm[k] * ( # = Vm[k] * Qi_θkVk + -Gik * cos(θ[i] - θ[k]) + - + Bik * sin(θ[i] - θ[k]) + ) + Qi_θkθk = + Vm[i] * Vm[k] * ( # = -Vm[k] * Pi_θkVk + -Gik * sin(θ[i] - θ[k]) + + + Bik * cos(θ[i] - θ[k]) + ) + θkθks = Pi_θkθk * F_value[2 * i - 1] + Qi_θkθk * F_value[2 * i] + Hvnz[edge_nz[1, e]] += θkθks + end + if bt_k == PSY.ACBusTypes.PQ + # ∂²Δ{Pᵢ, Qᵢ}/∂θₖ∂Vₖ + Pi_θkVk = Vm[i] * ( + Gik * sin(θ[i] - θ[k]) + - + Bik * cos(θ[i] - θ[k]) + ) + Qi_θkVk = Vm[i] * ( + -Gik * cos(θ[i] - θ[k]) + - + Bik * sin(θ[i] - θ[k]) + ) + θkVks = Pi_θkVk * F_value[2 * i - 1] + Qi_θkVk * F_value[2 * i] + Hvnz[edge_nz[2, e]] += θkVks + Hvnz[edge_nz[3, e]] += θkVks + end + if has_θi + Pi_θkθi = + Vm[i] * Vm[k] * ( + Gik * cos(θ[i] - θ[k]) + + Bik * sin(θ[i] - θ[k]) + ) + Qi_θkθi = + Vm[i] * Vm[k] * ( + Gik * sin(θ[i] - θ[k]) + - + Bik * cos(θ[i] - θ[k]) + ) + # contribution towards sum in ∂²Δ{Pᵢ, Qᵢ}/∂θᵢ∂θᵢ + diag_accum[1, i] -= Pi_θkθi + diag_accum[2, i] -= Qi_θkθi + if has_θk + # ∂²Δ{Pᵢ, Qᵢ}/∂θₖ∂θᵢ + θiθks = Pi_θkθi * F_value[2 * i - 1] + Qi_θkθi * F_value[2 * i] + Hvnz[edge_nz[4, e]] += θiθks + Hvnz[edge_nz[5, e]] += θiθks end end - # now, do the diagonal terms that depend only on i: these are sums [except for ∂²Vᵢ], - # but we've been accumulating the sums as we go. - + if bt_i == PSY.ACBusTypes.PQ + Pi_θkVi = Vm[k] * ( # = Vm[k] * Qi_VkVi + Gik * sin(θ[i] - θ[k]) + - + Bik * cos(θ[i] - θ[k]) + ) + Qi_θkVi = Vm[k] * ( # = -Vm[k] * Pi_VkVi + -Gik * cos(θ[i] - θ[k]) + - + Bik * sin(θ[i] - θ[k]) + ) + # contribution towards sum in ∂²Δ{Pᵢ, Qᵢ}/∂θᵢ∂Vᵢ + diag_accum[3, i] -= Pi_θkVi + diag_accum[4, i] -= Qi_θkVi + if has_θk + # ∂²Δ{Pᵢ, Qᵢ}/∂θₖ∂Vᵢ + Viθks = Pi_θkVi * F_value[2 * i - 1] + Qi_θkVi * F_value[2 * i] + Hvnz[edge_nz[6, e]] += Viθks + Hvnz[edge_nz[7, e]] += Viθks + end + end + if bt_k == PSY.ACBusTypes.PQ && has_θi + # ∂²Δ{Pᵢ, Qᵢ}/∂Vₖ∂θᵢ + Pi_Vkθi = Vm[i] * ( # = -Vm[i] * Qi_VkVi + -Gik * sin(θ[i] - θ[k]) + + + Bik * cos(θ[i] - θ[k]) + ) + Qi_Vkθi = Vm[i] * ( # = Vm[i] * Pi_VkVi + Gik * cos(θ[i] - θ[k]) + + + Bik * sin(θ[i] - θ[k]) + ) + θiVks = Pi_Vkθi * F_value[2 * i - 1] + Qi_Vkθi * F_value[2 * i] + Hvnz[edge_nz[8, e]] += θiVks + Hvnz[edge_nz[9, e]] += θiVks + end + if bt_k == PSY.ACBusTypes.PQ && bt_i == PSY.ACBusTypes.PQ + # ∂²Δ{Pᵢ, Qᵢ}/∂Vₖ∂Vᵢ + Pi_VkVi = Gik * cos(θ[i] - θ[k]) + Bik * sin(θ[i] - θ[k]) + Qi_VkVi = Gik * sin(θ[i] - θ[k]) - Bik * cos(θ[i] - θ[k]) + ViVks = Pi_VkVi * F_value[2 * i - 1] + Qi_VkVi * F_value[2 * i] + Hvnz[edge_nz[10, e]] += ViVks + Hvnz[edge_nz[11, e]] += ViVks + end + end + # now, do the diagonal terms that depend only on i: these are sums [except for ∂²Vᵢ], + # accumulated above as the edge loop ran. + @inbounds for i in 1:num_buses + bt_i = data.bus_type[i, time_step] # ∂²Δ{Pᵢ, Qᵢ}/∂²θᵢ: PQ and PV - if has_θi - θiθis = Pi_θiθi * F_value[2 * i - 1] + Qi_θiθi * F_value[2 * i] - Hv[2 * i, 2 * i] += θiθis + if _has_theta(bt_i) + θiθis = + diag_accum[1, i] * F_value[2 * i - 1] + diag_accum[2, i] * F_value[2 * i] + Hvnz[diag_nz[1, i]] += θiθis end - # ∂²Δ{Pᵢ, Qᵢ}/∂Vᵢ∂θᵢ and ∂²Δ{Pᵢ, Qᵢ}/∂²Vᵢ: PQ only. if bt_i == PSY.ACBusTypes.PQ - Viθis = Pi_Viθi * F_value[2 * i - 1] + Qi_Viθi * F_value[2 * i] - Hv[2 * i, 2 * i - 1] += Viθis - Hv[2 * i - 1, 2 * i] += Viθis + Viθis = + diag_accum[3, i] * F_value[2 * i - 1] + diag_accum[4, i] * F_value[2 * i] + Hvnz[diag_nz[2, i]] += Viθis + Hvnz[diag_nz[3, i]] += Viθis Pi_ViVi = 2 * real(Yb[i, i]) Qi_ViVi = -2 * imag(Yb[i, i]) ViVis = Pi_ViVi * F_value[2 * i - 1] + Qi_ViVi * F_value[2 * i] - Hv[2 * i - 1, 2 * i - 1] += ViVis + Hvnz[diag_nz[4, i]] += ViVis end end _update_hessian_lcc_contributions!(Hv, F_value, data, time_step) diff --git a/src/ac_power_flow_jacobian.jl b/src/ac_power_flow_jacobian.jl index f5584d6f..51153ffb 100644 --- a/src/ac_power_flow_jacobian.jl +++ b/src/ac_power_flow_jacobian.jl @@ -13,8 +13,8 @@ and can be called as a function at the same time. Calling the instance as a func - `subnetworks::Dict{Int64, Vector{Int64}}`: Subnetwork mapping from REF bus to bus list (from the `ACPowerFlowResidual`). Used for the distributed slack Jacobian entries. - `independent_ref::Set{Int}`: Multi-swing REF bus indices, from `_multi_swing_ref_indices`. Computed once at construction because the Q-limit loop only flips PV↔PQ, never REF. """ -struct ACPowerFlowJacobian - data::ACPowerFlowData +struct ACPowerFlowJacobian{D <: ACPowerFlowData} + data::D Jv::SparseArrays.SparseMatrixCSC{Float64, J_INDEX_TYPE} # This is the Jacobian matrix, updated in place by `_update_jacobian_matrix_values!` bus_slack_participation_factors::SparseVector{Float64, Int} subnetworks::Dict{Int64, Vector{Int64}} @@ -666,10 +666,11 @@ function _set_entries_for_vsc( # Pre-zero the shared ∂KCL/∂|V_ac| slots before accumulating: two converters can share BOTH # the DC node and the AC bus (parallel converters), in which case `sparse` merged their # structural slots into one — an `=` write would clobber the first converter's contribution. + # Unconditional (not gated on the bus currently being PQ): the slot is always structurally + # allocated (see below), and a bus that was PQ on a previous call but is PV now must have its + # stale contribution cleared here, since the accumulation loop below only writes it for PQ. for c in 1:nconv - if data.bus_type[dcn.converter_ac_bus_ix[c], time_step] == PSY.ACBusTypes.PQ - Jv[base + dcn.converter_dc_node_ix[c], 2 * dcn.converter_ac_bus_ix[c] - 1] = 0.0 - end + Jv[base + dcn.converter_dc_node_ix[c], 2 * dcn.converter_ac_bus_ix[c] - 1] = 0.0 end for c in 1:nconv ix = dcn.converter_ac_bus_ix[c] diff --git a/src/ac_power_flow_residual.jl b/src/ac_power_flow_residual.jl index 5ef0eaf8..b4bfb515 100644 --- a/src/ac_power_flow_residual.jl +++ b/src/ac_power_flow_residual.jl @@ -14,8 +14,8 @@ A struct to keep track of the residuals in the Newton-Raphson AC power flow calc - `P_slack_buf::Vector{Float64}`: Scratch buffer of length `n_buses` used by `_update_residual_values!` to write the per-subnetwork slack distribution in place, avoiding a per-iteration allocation when indexing `bus_slack_participation_factors` by `subnetwork_buses`. - `validate_indices::Vector{Int}`: precomputed `x`-indices of PQ-bus |V| entries for the per-iteration voltage-magnitude diagnostic. """ -struct ACPowerFlowResidual - data::ACPowerFlowData +struct ACPowerFlowResidual{D <: ACPowerFlowData} + data::D Rv::Vector{Float64} P_net::Vector{Float64} Q_net::Vector{Float64} @@ -45,57 +45,63 @@ Create an instance of `ACPowerFlowResidual` for a given time step. """ function ACPowerFlowResidual(data::ACPowerFlowData, time_step::Int64) n_buses = first(size(data.bus_type)) - P_net = Vector{Float64}(undef, n_buses) - Q_net = Vector{Float64}(undef, n_buses) - - P_net_set = zeros(Float64, n_buses) bus_type = view(data.bus_type, :, time_step) # ref_bus is set to the first REF bus found - will be used for the total slack power subnetworks = _find_subnetworks_for_reference_buses(data.power_network_matrix.data, bus_type) - - for ix in 1:n_buses - P_net[ix] = - data.bus_active_power_injections[ix, time_step] - - get_bus_active_power_total_withdrawals(data, ix, time_step) + - data.bus_hvdc_net_power[ix, time_step] - Q_net[ix] = - data.bus_reactive_power_injections[ix, time_step] - - get_bus_reactive_power_total_withdrawals(data, ix, time_step) - P_net_set[ix] = P_net[ix] - end - validate_indices = _pq_validate_indices(bus_type) - bus_slack_participation_factors = _build_bus_slack_participation_factors(data, bus_type, subnetworks, time_step) - bus_active_constant_I = - copy(view(data.bus_active_power_constant_current_withdrawals, :, time_step)) - bus_reactive_constant_I = - copy(view(data.bus_reactive_power_constant_current_withdrawals, :, time_step)) - bus_active_constant_Z = - copy(view(data.bus_active_power_constant_impedance_withdrawals, :, time_step)) - bus_reactive_constant_Z = - copy(view(data.bus_reactive_power_constant_impedance_withdrawals, :, time_step)) - - return ACPowerFlowResidual( + residual = ACPowerFlowResidual( data, Vector{Float64}(undef, 2 * n_buses + state_tail_length(data, get_dc_network(data))), - P_net, - Q_net, - P_net_set, + Vector{Float64}(undef, n_buses), + Vector{Float64}(undef, n_buses), + Vector{Float64}(undef, n_buses), bus_slack_participation_factors, subnetworks, - bus_active_constant_I, - bus_reactive_constant_I, - bus_active_constant_Z, - bus_reactive_constant_Z, + Vector{Float64}(undef, n_buses), + Vector{Float64}(undef, n_buses), + Vector{Float64}(undef, n_buses), + Vector{Float64}(undef, n_buses), Vector{Float64}(undef, n_buses), validate_indices, ) + _refresh_residual_setpoints!(residual, data, time_step) + return residual +end + +# Fills `P_net`/`Q_net`/`P_net_set` and the four constant-I/Z withdrawal vectors from `data` at +# `time_step`, in place. `P_net` is (re)set to the freshly computed value, not accumulated onto — +# the PQ ZIP path in `_update_residual_values!` telescopes onto whatever is here, so every caller +# (construction, `_refresh_polar_residual!`'s cache reuse, the sensitivity context's per-pass +# refresh) must rebuild it fresh from `data`, not fold onto a stale value. +function _refresh_residual_setpoints!( + residual::ACPowerFlowResidual, data::ACPowerFlowData, time_step::Int64, +) + @inbounds for ix in eachindex(residual.P_net) + p = + data.bus_active_power_injections[ix, time_step] - + get_bus_active_power_total_withdrawals(data, ix, time_step) + + data.bus_hvdc_net_power[ix, time_step] + residual.P_net[ix] = p + residual.P_net_set[ix] = p + residual.Q_net[ix] = + data.bus_reactive_power_injections[ix, time_step] - + get_bus_reactive_power_total_withdrawals(data, ix, time_step) + end + residual.bus_active_constant_I .= + view(data.bus_active_power_constant_current_withdrawals, :, time_step) + residual.bus_reactive_constant_I .= + view(data.bus_reactive_power_constant_current_withdrawals, :, time_step) + residual.bus_active_constant_Z .= + view(data.bus_active_power_constant_impedance_withdrawals, :, time_step) + residual.bus_reactive_constant_Z .= + view(data.bus_reactive_power_constant_impedance_withdrawals, :, time_step) + return end """ diff --git a/src/common.jl b/src/common.jl index 913d645c..bc165861 100644 --- a/src/common.jl +++ b/src/common.jl @@ -84,11 +84,21 @@ end # `solved_admittance` replaces the engaged blocks; it is a pure susceptance. _switched_admittance(solved::Float64, ::Vector{Int}, ::Vector{Complex{Float64}}) = im * solved -_switched_admittance( +# PSY defaults `number_engaged` to `Int[]` regardless of block count; that alone is legal (0 engaged). +function _switched_admittance( ::Nothing, engaged::Vector{Int}, y_increase::Vector{Complex{Float64}}, -) = sum(engaged .* y_increase; init = 0.0 + 0.0im) +) + isempty(engaged) && return 0.0 + 0.0im + length(engaged) == length(y_increase) || throw( + DimensionMismatch( + "SwitchedAdmittance has $(length(y_increase)) blocks but " * + "number_engaged has $(length(engaged)) entries.", + ), + ) + return sum(engaged .* y_increase; init = 0.0 + 0.0im) +end function _get_withdrawals!( pf::PowerFlowEvaluationModel, @@ -482,6 +492,16 @@ function my_mul_mt( return Y end +# PTDF row for `arc`, read from the cache to avoid the copy `A[arc, :]` makes; the first +# read of a row computes and caches it. +function _ptdf_cached_row(A::PNM.VirtualPTDF, cache, arc_lookup, arc) + row_ix = arc_lookup[arc] + if haskey(cache, row_ix) + return cache[row_ix] + end + return A[arc, :] +end + # TODO: Consider Moving method to PNM to avoid type piracy. This is a performance optimization to avoid allocating a new matrix for each call to my_mul_mt. """In-place A*X → Y where X is a matrix. Pre-allocated Y avoids per-call allocation.""" function my_mul_mt!( @@ -489,17 +509,10 @@ function my_mul_mt!( A::PNM.VirtualPTDF, X::Matrix{Float64}, ) - # Access cache directly to avoid allocation from A[arc, :] indexing cache = PNM.get_ptdf_data(A) - arc_lookup = PNM.get_arc_lookup(A) # maps arc tuple → row index + arc_lookup = PNM.get_arc_lookup(A) for (i, arc) in enumerate(A.axes[1]) - row_ix = arc_lookup[arc] - # On first solve, cache may be empty - use getindex to trigger computation - if haskey(cache, row_ix) - row_i = cache[row_ix] - else - row_i = A[arc, :] - end + row_i = _ptdf_cached_row(A, cache, arc_lookup, arc) mul!(view(Y, i, :), X', row_i) end return @@ -783,3 +796,10 @@ siground(x::Float64) = round(x; sigdigits = 3) signorm(x::Vector{Float64}; p::Real = 2) = siground(LinearAlgebra.norm(x, p)) print_signorms(x::Vector{Float64}; intro::String = "", ps::Vector{Float64} = [2]) = @info "$intro norm: " * join(["$(signorm(x; p = p)) [L$p]" for p in ps], ", ") + +# Whether `Jv` has the sparsity pattern a factorization cache (fields `m`, `n`, `colptr`, +# `rowval`) was built for, so its symbolic factorization can be reused. +function _same_sparsity(cache, Jv::SparseMatrixCSC) + return size(Jv, 1) == cache.m && size(Jv, 2) == cache.n && + Jv.colptr == cache.colptr && Jv.rowval == cache.rowval +end diff --git a/src/definitions.jl b/src/definitions.jl index 982b1fd6..0fb1be27 100644 --- a/src/definitions.jl +++ b/src/definitions.jl @@ -64,6 +64,7 @@ const LCC_sinϕ_TOLERANCE = 1e-8 # if sin(ϕ) < this, treat dQ/dV as zero to avo const LCC_SMALL_ANGLE_THRESHOLD = deg2rad(5) # warn if converged LCC thyristor angle α_r/α_i falls outside (this, π/2 − this) const DEFAULT_NR_MAX_ITER = 50 # default maxIterations for the NR power flow +const UNSET_MAX_ITERATIONS = -1 # SolutionParameters.maxIterations sentinel: resolved to the solver's default at model construction; never a legitimate iteration count const DEFAULT_NR_TOL = 1e-9 # default tolerance for the NR power flow const DEFAULT_REFINEMENT_THRESHOLD = 5e-2 # do refinement if relative error > 5%. const DEFAULT_REFINEMENT_MAX_ITER = 10 # how many times to try iterative refinement diff --git a/src/discrete_control/control_continuation.jl b/src/discrete_control/control_continuation.jl index ac930b8e..567501a7 100644 --- a/src/discrete_control/control_continuation.jl +++ b/src/discrete_control/control_continuation.jl @@ -163,16 +163,22 @@ end # ── Linearized plant sensitivities ─────────────────────────────────────────────────────── # Differentiating F(x,p)=0 at the converged base: dx/dp = −J⁻¹·(∂F/∂p), and dy/dp is the -# controlled-bus voltage component — no perturbation, one triangular solve per device on the base -# NR solve's reused factorization instead of a full nonlinear solve. Polar + voltage-device (tap/shunt/FACTS) -# only (state layout x[2b−1]=Vm, x[2b]=Va and ∂F/∂p are polar-specific); rect/mixed formulations -# fall back to the FD `_plant_sign`. Signs are validated against the FD probe in the tests. - -# Sensitivity context: residual+Jacobian built at the CURRENT converged base state and numerically -# factored (reusing the base NR solve's persisted symbolic factorization). Built ONCE per -# continuation (probe phase) and thereafter kept current by `_refresh_sensitivity_context!` -# (values-only, no rebuild) after each batched-pass joint solve. `nothing` ⇒ the linear path is -# unavailable (non-polar formulation, or the base Jacobian is singular) and the caller uses FD probes. +# controlled-bus voltage component — no perturbation, one triangular solve per device against +# a Jacobian factored just for this purpose, instead of a full nonlinear solve. Available for +# every AC formulation (polar, rectangular, mixed) with a voltage-controlling device +# (tap/shunt/FACTS); `_plant_sign`'s FD probe is the fallback only when the base Jacobian is +# singular. Signs are validated against the FD probe in the tests. + +# Sensitivity context: residual+Jacobian built at the CURRENT converged base state, with their +# own fresh symbolic + numeric factorization (NOT the main NR solve's persisted one — see +# `_nr_linear_solver_cache!`). Built ONCE per continuation (probe phase) and thereafter kept +# current by `_refresh_sensitivity_context!` (values-only, no rebuild) after each batched-pass +# joint solve, or replaced by a fresh build (`_refresh_or_rebuild_context`) when that refresh +# is impossible (a bus-type change invalidated the persisted structure, or the numeric +# refactor went singular). A singular base Jacobian gives `FiniteDifferenceProbes()` instead, +# and every consumer dispatches on the two types rather than testing for absence. +struct FiniteDifferenceProbes end + struct _SensitivityContext{C, R, JT} lin_cache::C residual::R # persisted; re-evaluated in place by _refresh_sensitivity_context! @@ -183,7 +189,8 @@ struct _SensitivityContext{C, R, JT} # factors/validate_indices are computed ONCE at construction from bus_type and are NOT # recomputed by the residual functor — a Q-limit PV→PQ flip elsewhere in the network after # this ctx was built silently stales that structure. `_refresh_sensitivity_context!` checks - # this snapshot and refuses to reuse a ctx whose bus-type pattern has since changed. + # this snapshot and refuses to reuse a ctx whose bus-type pattern has since changed; + # `_refresh_or_rebuild_context` then builds a fresh one instead of giving up on batching. bus_type::Vector{PSY.ACBusTypes.Value} end @@ -192,7 +199,7 @@ end # refactor into the persisted objects replaces a full rebuild — UNLESS a PV↔PQ flip has changed # the slack-participation/subnetwork layout the persisted `residual` baked in at construction # (see `_SensitivityContext.bus_type`); that case, and a singular refactor, both return `false` -# so the caller falls back to the sequential path (which rebuilds fresh). +# so the caller (`_refresh_or_rebuild_context`) rebuilds fresh instead of reusing this ctx. # # `P_net`/`Q_net`/`P_net_set` and the four constant-I/Z withdrawal vectors are all captured from # `data` ONCE at `ACPowerFlowResidual` construction and are NOT independently re-read by the @@ -427,10 +434,11 @@ function _step_device!( return abs(Δp) end -# One device's plant sign: the linear sensitivity when a polar `ctx` is live and the family has an -# analytic form (tap/shunt/FACTS), else the FD probe (non-polar formulations, singular base). +# One device's plant sign: the linear sensitivity when a `ctx` is live (available on every AC +# formulation for tap/shunt/FACTS), else the FD probe (singular base). _probe_one_sign( - d, data, ts::Int, pf, scratch_snap::ControlStateSnapshot, ::Nothing; kwargs..., + d, data, ts::Int, pf, scratch_snap::ControlStateSnapshot, ::FiniteDifferenceProbes; + kwargs..., ) = _plant_sign(d, data, ts, pf, scratch_snap; kwargs...) function _probe_one_sign( d, data, ts::Int, pf, scratch_snap::ControlStateSnapshot, @@ -446,7 +454,7 @@ end # effect is below the gain floor (e.g. a PV-pinned bus, sensitivity 0) — stepping them would rail. function _probe_device_signs!( devices, offset::Int, dVdp::Vector{Float64}, frozen::Vector{Bool}, - ctx::Union{Nothing, _SensitivityContext}, data, ts::Int, pf, + ctx::Union{_SensitivityContext, FiniteDifferenceProbes}, data, ts::Int, pf, scratch_snap::ControlStateSnapshot; kwargs..., ) for (i, d) in enumerate(devices) @@ -539,19 +547,34 @@ function _refresh_gains_group!( return end +# Dispatches the post-solve refresh onto the rebuilt context: a live context refreshes every +# moved device's gain and reports itself accepted; a Jacobian-singular fallback reports nothing +# to accept, so the caller rolls the pass back. +function _accept_refreshed_context!( + ctx::_SensitivityContext, set::ControlledDeviceSet, n_taps::Int, off_facts::Int, + data, ts::Int, frozen::Vector{Bool}, dVdp::Vector{Float64}, did_move::Vector{Bool}, +) + _refresh_gains_group!(set.taps, 0, data, ts, frozen, dVdp, did_move, ctx) + _refresh_gains_group!(set.shunts, n_taps, data, ts, frozen, dVdp, did_move, ctx) + _refresh_gains_group!(set.facts, off_facts, data, ts, frozen, dVdp, did_move, ctx) + return true +end +_accept_refreshed_context!(::FiniteDifferenceProbes, set, n_taps, off_facts, data, ts, + frozen, dVdp, did_move) = false + # One batched pass over the voltage-device groups (taps, shunts, FACTS). Returns -# `(settled, converged)`: `converged=false` means the joint solve failed (or, rarely, the -# post-solve sensitivity refresh hit a singular Jacobian) and the pass was fully rolled back — -# the caller must run the sequential path for this pass. `ctx` is the ONE -# `_SensitivityContext` persisted for the whole continuation (built once in -# `_control_continuation!`); a converged joint solve refreshes it in place instead of rebuilding. +# `(settled, converged, ctx)`: `converged=false` means the joint solve failed (or the +# post-solve sensitivity refresh AND rebuild both failed) and the pass was fully rolled back — +# the caller must run the sequential path for this pass. `ctx` in is the persisted +# `_SensitivityContext` from the previous pass; `ctx` out is it refreshed in place or rebuilt +# (`_refresh_or_rebuild_context`), and the caller carries it into the next pass. function _batched_pass!( set::ControlledDeviceSet, n_taps::Int, n_shunts::Int, data, ts::Int, S::Float64, pf, snap::ControlStateSnapshot, frozen::Vector{Bool}, dVdp::Vector{Float64}, osc::Vector{Int}, prev_sign::Vector{Int}, n_shared::Vector{Int}, p_prev::Vector{Float64}, did_move::Vector{Bool}, ctx::_SensitivityContext; kwargs..., -) +)::Tuple{Bool, Bool, Union{_SensitivityContext, FiniteDifferenceProbes}} fill!(did_move, false) _capture_state!(snap, data, ts) # A failed+rolled-back batched attempt must leave NO trace, so the sequential fallback is the @@ -568,12 +591,13 @@ function _batched_pass!( _refresh_facts_limits!(set.facts, off_facts, data, ts, frozen) moved |= _apply_targets_group!(set.facts, off_facts, data, ts, S, frozen, dVdp, osc, prev_sign, n_shared, p_prev, did_move) - moved || return true, true # nothing wanted to move ⇒ settled, no solve needed - if _ctrl_solve!(pf, data, ts; kwargs...) && _refresh_sensitivity_context!(ctx, data, ts) - _refresh_gains_group!(set.taps, 0, data, ts, frozen, dVdp, did_move, ctx) - _refresh_gains_group!(set.shunts, n_taps, data, ts, frozen, dVdp, did_move, ctx) - _refresh_gains_group!(set.facts, off_facts, data, ts, frozen, dVdp, did_move, ctx) - return false, true # moved + converged ⇒ not settled + moved || return true, true, ctx # nothing wanted to move ⇒ settled, no solve needed + if _ctrl_solve!(pf, data, ts; kwargs...) + refreshed = _refresh_or_rebuild_context(ctx, pf, data, ts; kwargs...) + if _accept_refreshed_context!( + refreshed, set, n_taps, off_facts, data, ts, frozen, dVdp, did_move) + return false, true, refreshed # moved + converged ⇒ not settled + end end _rollback_targets_group!(set.taps, 0, data, ts, p_prev, did_move) _rollback_targets_group!(set.shunts, n_taps, data, ts, p_prev, did_move) @@ -582,7 +606,8 @@ function _batched_pass!( copyto!(osc, osc0) copyto!(prev_sign, prev0) copyto!(frozen, frozen0) - return false, false # joint solve or sensitivity refresh failed ⇒ run sequential path + # joint solve failed, or every rebuild attempt hit a singular Jacobian ⇒ sequential path + return false, false, ctx end function _count_controlled_buses!(counts::Dict{Int, Int}, devices) @@ -621,26 +646,39 @@ function _step_voltage_groups!( end # One continuation pass. Voltage devices go through the batched (one-solve) path when it is -# enabled and its joint solve converges, else the sequential path (which fully preserves the -# backtracking/freeze behavior). Returns whether the whole pass settled. `ctx` is the -# persisted sensitivity context (non-`nothing` iff `use_batched`); passed through unchanged -# so `_batched_pass!` can refresh it in place. +# enabled, `ctx` is live, and its joint solve converges, else the sequential path (which fully +# preserves the backtracking/freeze behavior). Returns `(settled, ctx)`: `ctx` may come back +# refreshed, rebuilt, or fallen back to `FiniteDifferenceProbes()` — the caller must carry it +# into the next pass. A `FiniteDifferenceProbes` pass always goes sequential. function _control_pass!( set::ControlledDeviceSet, n_taps::Int, n_shunts::Int, use_batched::Bool, data, ts::Int, S::Float64, pf, scratch_snap::ControlStateSnapshot, frozen::Vector{Bool}, dVdp::Vector{Float64}, osc::Vector{Int}, prev_sign::Vector{Int}, n_shared::Vector{Int}, p_prev::Vector{Float64}, did_move::Vector{Bool}, - ctx::Union{Nothing, _SensitivityContext}; kwargs..., -) + ctx::_SensitivityContext; kwargs..., +)::Tuple{Bool, Union{_SensitivityContext, FiniteDifferenceProbes}} if use_batched - s, converged = + s, converged, ctx = _batched_pass!(set, n_taps, n_shunts, data, ts, S, pf, scratch_snap, frozen, dVdp, osc, prev_sign, n_shared, p_prev, did_move, ctx; kwargs...) - converged && return s + converged && return s, ctx end - return _step_voltage_groups!(set, n_taps, n_shunts, data, ts, S, pf, scratch_snap, + settled = _step_voltage_groups!(set, n_taps, n_shunts, data, ts, S, pf, scratch_snap, + frozen, dVdp, osc, prev_sign, n_shared; kwargs...) + return settled, ctx +end +function _control_pass!( + set::ControlledDeviceSet, n_taps::Int, n_shunts::Int, use_batched::Bool, + data, ts::Int, S::Float64, pf, scratch_snap::ControlStateSnapshot, + frozen::Vector{Bool}, dVdp::Vector{Float64}, + osc::Vector{Int}, prev_sign::Vector{Int}, n_shared::Vector{Int}, + p_prev::Vector{Float64}, did_move::Vector{Bool}, + ctx::FiniteDifferenceProbes; kwargs..., +)::Tuple{Bool, Union{_SensitivityContext, FiniteDifferenceProbes}} + settled = _step_voltage_groups!(set, n_taps, n_shunts, data, ts, S, pf, scratch_snap, frozen, dVdp, osc, prev_sign, n_shared; kwargs...) + return settled, ctx end function _control_continuation!( @@ -681,9 +719,7 @@ function _control_continuation!( # is `snap_and_restore!`'s `pre`, which allocates its own. scratch_snap = _snapshot_state(data, ts) - # Build the linearized-sensitivity context ONCE (one numeric factorization reusing the base NR - # solve's symbolic factor); all device probes below are then triangular solves against it. `nothing` - # for non-polar formulations or a singular base ⇒ each probe falls back to the FD solve. + # Built once; every device probe below is a triangular solve against it. ctx = _sensitivity_context(pf, data, ts; kwargs...) _probe_device_signs!( set.taps, @@ -724,9 +760,9 @@ function _control_continuation!( # once). Intermediate stages solve at CONTROL_STAGE_TOL; full tol only at the final stage and # snap/restore, and never looser than a user-supplied tol. user_tol = Float64(get(kwargs, :tol, DEFAULT_NR_TOL)) - # Gated on `_refreshable`, not just `ctx`'s presence: a formulation can supply analytic - # sensitivities before it supplies a per-pass refresh, and batching without one would read - # a stale Jacobian after the first device move. + # False only when the base Jacobian was singular (`ctx` is a `FiniteDifferenceProbes` + # fallback with no per-pass refresh); batching then would read a stale Jacobian after the + # first device move. use_batched = _supports_batched_refresh(ctx) p_prev = zeros(n_dev) did_move = fill(false, n_dev) @@ -739,7 +775,7 @@ function _control_continuation!( end settled = false for _ in 1:MAX_CONTROL_PASSES_PER_STAGE - settled = _control_pass!( + settled, ctx = _control_pass!( set, n_taps, n_shunts, use_batched, data, ts, S, pf, scratch_snap, frozen, dVdp, osc, prev_sign, n_shared, p_prev, did_move, ctx; diff --git a/src/discrete_control/control_metadata.jl b/src/discrete_control/control_metadata.jl index c06d0f58..ef8b6226 100644 --- a/src/discrete_control/control_metadata.jl +++ b/src/discrete_control/control_metadata.jl @@ -110,14 +110,19 @@ function _voltage_controlled_tap_candidates(sys) end """Tap-control metadata for one regulating `PSY.TransformerCircuit`, of either arity. -`control_limits` is already in tap-ratio units (the PSS/E parser scales RMI1/RMA1 by WINDV2); +`control_limits` is used directly as the tap-ratio band `[pmin, pmax]`, but PSS/E's RMI1/RMA1 +bound WINDV1 while `PSY.get_tap` stores the ratio WINDV1/WINDV2; `TransformerCircuit` has no +WINDV2-equivalent field, so this band is wrong by a factor of WINDV2 whenever WINDV2 != 1 for +the parsed transformer (the correct band would be `control_limits ./ WINDV2`). Fixing this +needs a data-model change upstream (PFFP/PSY), not here. `get_regulated_bus_number` is 0 for local (to-bus) control.""" function _tap_metadata(circuit::PSY.TransformerCircuit, to_bus::Int) lims = PSY.get_control_limits(circuit) reg = PSY.get_regulated_bus_number(circuit) cbus = to_bus if !iszero(reg) - cbus = reg + # The sign marks the regulation side (PSS/E CONT<0); the bus number itself is |reg|. + cbus = abs(reg) end # The tap is held anywhere inside the VMA/VMI band and regulates toward its midpoint on # an excursion — the same posture as a switched shunt's VSWLO/VSWHI. @@ -139,18 +144,17 @@ end _solved_flag(::Nothing) = false _solved_flag(::Float64) = true -_shunt_baseline(solved::Float64, ::Vector{Int}, ::Vector{Float64}) = solved -_shunt_baseline(::Nothing, engaged::Vector{Int}, dB::Vector{Float64}) = - sum(engaged .* dB; init = 0.0) - function _shunt_susceptance_model( name::String, solved::Union{Nothing, Float64}, + y_increase::Vector{Complex{Float64}}, steps::Vector{Int}, dB::Vector{Float64}, engaged::Vector{Int}, ) - current = _shunt_baseline(solved, engaged, dB) + # `_switched_admittance` (common.jl) is the one baseline calculation; a shunt's + # susceptance is a pure imaginary admittance, so `imag` recovers it. + current = imag(_switched_admittance(solved, engaged, y_increase)) b_min = sum(min.(steps .* dB, 0.0); init = 0.0) b_max = sum(max.(steps .* dB, 0.0); init = 0.0) if !(b_min - BOUNDS_TOLERANCE <= current <= b_max + BOUNDS_TOLERANCE) @@ -179,6 +183,7 @@ function build_controlled_device_set( reverse_bus_search_map::Dict{Int, Int} = Dict{Int, Int}(), n_time_steps::Int = 1, ) + nrd = PNM.get_network_reduction_data(ybus) taps = ControlledTap[] for (name, branch, circuit, device_name, circuit_index) in _voltage_controlled_tap_candidates(sys) @@ -211,8 +216,11 @@ function build_controlled_device_set( _validate_tap(name, md.pmin, md.pmax, md.ntp) || continue _validate_vset("ControlledTap", name, md.vset) || continue # PNM owns the π-model, including the r == x == 0 floor that a hand-built - # `1/(r + jx)` would miss (a jumper under tap control would yield `Inf`). - adm = PNM.branch_admittance(branch) + # `1/(r + jx)` would miss (a jumper under tap control would yield `Inf`), and the + # `nrd`-aware method applies the same impedance-correction factor the assembled + # Ybus was stamped with — the uncorrected component form would drift from the + # actual matrix the moment a correction table is in play. + adm = PNM.branch_admittance(branch, nrd) yt = complex(adm.g, adm.b) tap0 = adm.tap if !(md.pmin - BOUNDS_TOLERANCE <= tap0 <= md.pmax + BOUNDS_TOLERANCE) @@ -289,10 +297,11 @@ function build_controlled_device_set( _validate_vset("ControlledSwitchedShunt", name, vset) || continue solved = PSY.get_solved_admittance(sa) steps = PSY.get_number_of_steps(sa) - dB = imag.(PSY.get_Y_increase(sa)) + y_increase = PSY.get_Y_increase(sa) + dB = imag.(y_increase) engaged = PSY.get_number_engaged(sa) current_b, bmin, bmax = _shunt_susceptance_model( - name, solved, steps, dB, engaged) + name, solved, y_increase, steps, dB, engaged) _validate_shunt(name, bmin, bmax, steps, dB) || continue push!( shunts, diff --git a/src/discrete_control/control_sensitivity.jl b/src/discrete_control/control_sensitivity.jl index b081ab4e..037b803d 100644 --- a/src/discrete_control/control_sensitivity.jl +++ b/src/discrete_control/control_sensitivity.jl @@ -11,8 +11,8 @@ function _sensitivity_residual_jacobian(::ACPolarPowerFlow, data, ts::Int) return residual, J end -# No analytic form here ⇒ caller falls back to FD probes. -_sensitivity_context(::AbstractACPowerFlow, data, ts::Int; kwargs...) = nothing +# `pf` is always one of the three formulations below — every `AbstractACPowerFlow` subtype +# is one of them — so there is no broader fallback method here. function _sensitivity_context( pf::Union{ACPolarPowerFlow, ACRectangularPowerFlow, ACMixedPowerFlow}, data, @@ -27,8 +27,9 @@ function _sensitivity_context( numeric_refactor!(lin_cache, J.Jv) catch e e isa LinearAlgebra.SingularException || rethrow() - return # singular base Jacobian ⇒ fall back to FD probes + return FiniteDifferenceProbes() end + _singular_base_solve(lin_cache, J) && return FiniteDifferenceProbes() # One registration per continuation — `_refresh_sensitivity_context!` reuses this # topology-invariant factorization on every batched pass without counting again. _count_symbolic_factor!(data) @@ -37,44 +38,31 @@ function _sensitivity_context( lin_cache, residual, J, zeros(n), zeros(n), copy(view(data.bus_type, :, ts))) end -# Gates `use_batched`: a formulation with analytic sensitivities but no refresh stays -# sequential — batching it would read a stale Jacobian after the first device move. -_supports_batched_refresh(::Nothing) = false -_supports_batched_refresh(ctx::_SensitivityContext) = _refreshable(ctx.residual) -# Default false: opting into batching needs a `_refresh_sensitivity_context!` method AND -# flipping this explicitly. -_refreshable(::Any) = false -_refreshable(::ACPowerFlowResidual) = true +# KLU throws on a genuinely singular matrix (caught above), but AppleAccelerate and +# MKLPardiso silently return finite garbage — the repo's backend-agnostic guard against +# that is the relative-residual check `_set_Δx_nr!`/`_do_refinement!` already apply to the +# main NR solve; reuse it here on a synthetic probe solve rather than trusting only +# `SingularException`. +function _singular_base_solve(lin_cache::PFLinearSolverCache, J) + n = size(J.Jv, 1) + probe = ones(n) + sol = copy(probe) + solve!(lin_cache, sol) + sv = StateVectorCache(sol, probe) + residual = _do_refinement!( + sv, J.Jv, lin_cache, DEFAULT_REFINEMENT_THRESHOLD, DEFAULT_REFINEMENT_EPS) + return !isfinite(residual) || residual > DEFAULT_REFINEMENT_THRESHOLD +end + +# Gates `use_batched`: only a live context (every formulation has a `_refresh_sensitivity_context!` +# method) supports the batched per-pass refresh; the FD-probe fallback does not. +_supports_batched_refresh(::FiniteDifferenceProbes) = false +_supports_batched_refresh(::_SensitivityContext) = true # `_update_residual_values!`'s PQ case telescopes `P_net` from the residual's LAST evaluation, so # these must be rebuilt fresh from `data` before every evaluation or the correction drifts. function _refresh_residual_inputs!(residual::ACPowerFlowResidual, data, ts::Int)::Bool - copyto!( - residual.bus_active_constant_I, - view(data.bus_active_power_constant_current_withdrawals, :, ts), - ) - copyto!( - residual.bus_reactive_constant_I, - view(data.bus_reactive_power_constant_current_withdrawals, :, ts), - ) - copyto!( - residual.bus_active_constant_Z, - view(data.bus_active_power_constant_impedance_withdrawals, :, ts), - ) - copyto!( - residual.bus_reactive_constant_Z, - view(data.bus_reactive_power_constant_impedance_withdrawals, :, ts), - ) - @inbounds for ix in eachindex(residual.P_net) - residual.P_net[ix] = - data.bus_active_power_injections[ix, ts] - - get_bus_active_power_total_withdrawals(data, ix, ts) + - data.bus_hvdc_net_power[ix, ts] - residual.Q_net[ix] = - data.bus_reactive_power_injections[ix, ts] - - get_bus_reactive_power_total_withdrawals(data, ix, ts) - residual.P_net_set[ix] = residual.P_net[ix] - end + _refresh_residual_setpoints!(residual, data, ts) return true end @@ -98,7 +86,18 @@ function _refresh_sensitivity_context!(ctx::_SensitivityContext, data, ts::Int): e isa LinearAlgebra.SingularException || rethrow() return false end - return true + return !_singular_base_solve(ctx.lin_cache, ctx.J) +end + +# `_refresh_sensitivity_context!` refuses in-place reuse across a PV/PQ Q-limit flip (its +# baked-in subnetwork/slack layout goes stale) or a numeric refactor that turns singular. +# Rebuild fresh (a new base-state factorization, counted like any other) instead of leaving +# batching disabled for the rest of the continuation, as a stale `ctx` never replaced would. +function _refresh_or_rebuild_context( + ctx::_SensitivityContext, pf, data, ts::Int; kwargs..., +) + _refresh_sensitivity_context!(ctx, data, ts) && return ctx + return _sensitivity_context(pf, data, ts; kwargs...) end # ∂Y/∂p of the from-side terms (t_c = p·cis(α)); Y_tt = yt is p-independent (see `_branch_terms`). @@ -175,8 +174,6 @@ end const _RectOrMixedResidual = Union{ACRectangularCIResidual, ACMixedCPBResidual} -_refreshable(::_RectOrMixedResidual) = true - # `Y_bus_eff` must be rebuilt fresh from `data` each refresh, not re-folded onto the old copy — # tap/shunt moves edit the source Y-bus and withdrawals in place. The structure check catches a # `fold_zip_constant_z!`-inserted diagonal a plain `nonzeros` copy would silently misalign; on diff --git a/src/fast_decoupled_matrices.jl b/src/fast_decoupled_matrices.jl index e07d7edd..f56d5166 100644 --- a/src/fast_decoupled_matrices.jl +++ b/src/fast_decoupled_matrices.jl @@ -134,6 +134,11 @@ Read per-branch π-model parameters from PowerNetworkMatrices and take the per-b residual against the reconstructed arc self-terms. See the file header for the stamp convention. The near-zero-reactance cap lives in `_fd_series`, applied only on the resistance-drop stamp path, so these parameters and the restamp stay at their true values. + +An arc with no single-π equivalent (`|Yft| ≠ |Ytf|`, e.g. a degree-two chain over a mixed +phase-shift/impedance parallel group) gets a symmetrized fallback branch (`ys = -(Yft+Ytf)/2`, +unit tap) instead of throwing. B′/B″ only accelerate the Newton step, so this approximation +cannot corrupt the converged solution, only (rarely) the FD convergence rate on that arc. """ function _arc_params(data::ACPowerFlowData) ybus = get_power_network_matrix(data) @@ -143,6 +148,12 @@ function _arc_params(data::ACPowerFlowData) arcs = PNM.get_arc_axis(nrd) nbus = size(Yb, 1) + Yft = ybus.arc_admittance_from_to + Ytf = ybus.arc_admittance_to_from + Yft_d = Yft.data + Ytf_d = Ytf.data + yft_arc_lookup = PNM.get_arc_lookup(Yft) + # One π branch per arc is the rule; only a parallel group that mixes phase-shift angles with # impedance angles emits more, so `length(arcs)` sizes these exactly on every ordinary # network and is a lower bound otherwise. @@ -164,23 +175,46 @@ function _arc_params(data::ACPowerFlowData) for arc in arcs f = bus_lookup[first(arc)] t = bus_lookup[last(arc)] - for eb in PNM.arc_equivalent_branches(nrd, arc) - x_b = PNM.get_equivalent_x(eb) - ys_b = 1 / complex(PNM.get_equivalent_r(eb), x_b) - τ = PNM.get_equivalent_tap(eb) * cis(PNM.get_equivalent_shift(eb)) - yfr_b = - complex(PNM.get_equivalent_g_from(eb), PNM.get_equivalent_b_from(eb)) - yto_b = complex(PNM.get_equivalent_g_to(eb), PNM.get_equivalent_b_to(eb)) + if PNM.has_single_pi_equivalent(nrd, arc) + for eb in PNM.arc_equivalent_branches(nrd, arc) + x_b = PNM.get_equivalent_x(eb) + ys_b = 1 / complex(PNM.get_equivalent_r(eb), x_b) + τ = PNM.get_equivalent_tap(eb) * cis(PNM.get_equivalent_shift(eb)) + yfr_b = + complex(PNM.get_equivalent_g_from(eb), PNM.get_equivalent_b_from(eb)) + yto_b = complex(PNM.get_equivalent_g_to(eb), PNM.get_equivalent_b_to(eb)) + + push!(from, f) + push!(to, t) + push!(tau, τ) + push!(ys, ys_b) + push!(xs, x_b) + push!(y_fr, yfr_b) + push!(y_to, yto_b) + + self_acc[f] += ys_b / abs2(τ) + yfr_b + self_acc[t] += ys_b + yto_b + end + else + a = yft_arc_lookup[arc] + yft = ComplexF64(Yft_d[a, t]) + ytf = ComplexF64(Ytf_d[a, f]) + yff = ComplexF64(Yft_d[a, f]) + ytt = ComplexF64(Ytf_d[a, t]) + ys_b = -(yft + ytf) / 2 + x_b = imag(1 / ys_b) + yfr_b = yff - ys_b + yto_b = ytt - ys_b push!(from, f) push!(to, t) - push!(tau, τ) + push!(tau, one(ComplexF64)) push!(ys, ys_b) push!(xs, x_b) push!(y_fr, yfr_b) push!(y_to, yto_b) - self_acc[f] += ys_b / abs2(τ) + yfr_b + self_acc[f] += ys_b + yfr_b self_acc[t] += ys_b + yto_b end end diff --git a/src/fast_decoupled_method.jl b/src/fast_decoupled_method.jl index 3f3f2e94..b6abca35 100644 --- a/src/fast_decoupled_method.jl +++ b/src/fast_decoupled_method.jl @@ -42,7 +42,7 @@ system rejects them) and the `FDDecoupled`-is-polar-only constraint is enforced """ function _validate_fd_handoff_solver(handoff_solver) if !( - handoff_solver === nothing || + handoff_solver === NoHandoff || handoff_solver === NewtonRaphsonACPowerFlow || handoff_solver === TrustRegionACPowerFlow || handoff_solver === LevenbergMarquardtACPowerFlow @@ -50,7 +50,7 @@ function _validate_fd_handoff_solver(handoff_solver) throw( ArgumentError( "FastDecoupled: unsupported handoff_solver $(handoff_solver). Must be " * - "nothing (pure FD), NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow, or " * + "NoHandoff (pure FD), NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow, or " * "LevenbergMarquardtACPowerFlow.", ), ) @@ -72,7 +72,7 @@ function _newton_power_flow( time_step::Int64; tol = DEFAULT_NR_TOL, maxIterations = DEFAULT_FD_MAX_ITER, - handoff_solver = nothing, + handoff_solver = NoHandoff, handoff_tol = DEFAULT_FD_HANDOFF_TOL, refreeze_on_stall = DEFAULT_FD_REFREEZE_ON_STALL, fd_non_divergent = DEFAULT_FD_NON_DIVERGENT, @@ -277,9 +277,15 @@ _fd_v_state_indices(::ACMixedCPBResidual) = Int[] # ===================================================================================== """The FD-stage exit tolerance: the loose `handoff_tol` when a handoff will polish the result to -the real `tol`, else `tol` itself (pure FD).""" -_fd_stage_tol(handoff_solver, tol, handoff_tol) = - handoff_solver === nothing ? tol : handoff_tol +the real `tol`, else `tol` itself (pure FD). Dispatches on `handoff_solver` (a `Type`) rather +than branching on an `isnothing`/`===` check.""" +_fd_stage_tol(::Type{NoHandoff}, tol, handoff_tol) = tol +_fd_stage_tol(::Type{<:ACPowerFlowSolverType}, tol, handoff_tol) = handoff_tol + +"""Whether a configured [`FastDecoupledACPowerFlow`](@ref) handoff solver requires the +formulation Jacobian to be assembled (the :decoupled loop otherwise skips it).""" +_fd_needs_handoff_jacobian(::Type{NoHandoff}) = false +_fd_needs_handoff_jacobian(::Type{<:ACPowerFlowSolverType}) = true # The `Jv` argument for `_finalize_power_flow`: the assembled Jacobian values when the :decoupled # driver built `J`, or `nothing` when it skipped it (no handoff, no loss/vstab factors). @@ -287,14 +293,15 @@ _fd_finalize_jv(::Nothing) = nothing _fd_finalize_jv(J) = J.Jv """ - _fd_maybe_handoff!(pf, sv, residual, J, time_step, handoff_solver, tol, linear_solver, + _fd_maybe_handoff!(handoff_solver, pf, sv, residual, J, time_step, tol, linear_solver, solver_name, fd_iters) -> (converged::Bool, handoff_iters::Int) Run the opt-in handoff solver (`NewtonRaphsonACPowerFlow` / `TrustRegionACPowerFlow` / `LevenbergMarquardtACPowerFlow`) from the current FD state `sv.x` for final refinement to the -real `tol`. No-op (returns the current convergence status and `0` handoff iterations) when -`handoff_solver === nothing` or the FD state already meets `tol`. Otherwise refreshes the -formulation Jacobian VALUES at the current FD state and calls the matching inner method: +real `tol`. Dispatches on `handoff_solver` (a `Type`): the [`NoHandoff`](@ref) method is a no-op +(returns the current convergence status and `0` handoff iterations); the general +`ACPowerFlowSolverType` method also no-ops when the FD state already meets `tol`, else refreshes +the formulation Jacobian VALUES at the current FD state and calls the matching inner method: NR/TR via the shared `_run_power_flow_method(::StateVectorCache, ::PFLinearSolverCache, ...)`; LM via its workspace-based `_run_power_flow_method(x0::Vector, ::LMWorkspace, ...)` adapter. All paths mutate `sv.x` / `residual` / `J` in place (the SAME objects the FD loop used), so the @@ -302,28 +309,39 @@ caller's subsequent `J(time_step)` / `_finalize_*` see the refined solution. `fd `solver_name` are used only for the `@info` handoff log line. """ function _fd_maybe_handoff!( + ::Type{NoHandoff}, + pf::AbstractACPowerFlow{<:FastDecoupledACPowerFlow}, + sv::StateVectorCache, + residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, + J::Union{Nothing, ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + time_step::Int64, + tol::Float64, + linear_solver::Union{Nothing, AbstractString}, + solver_name::String, + fd_iters::Int, +) + return (norm(residual.Rv, Inf) < tol, 0) +end + +function _fd_maybe_handoff!( + handoff_solver::Type{<:ACPowerFlowSolverType}, pf::AbstractACPowerFlow{<:FastDecoupledACPowerFlow}, sv::StateVectorCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{Nothing, ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, time_step::Int64, - handoff_solver, tol::Float64, linear_solver::Union{Nothing, AbstractString}, solver_name::String, fd_iters::Int, ) - # `J === nothing` only when `handoff_solver === nothing` (the :decoupled driver builds `J` - # whenever a handoff is configured), so the early return below fires before any `J` deref. fd_met_tol = norm(residual.Rv, Inf) < tol - if handoff_solver === nothing || fd_met_tol - return (fd_met_tol, 0) - end + fd_met_tol && return (fd_met_tol, 0) J(time_step) # refresh Jacobian VALUES at current FD state if handoff_solver === LevenbergMarquardtACPowerFlow # LM's inner method takes the raw state vector + an LMWorkspace (a different signature # from NR/TR) and mutates x0 in place; see src/levenberg-marquardt.jl. - ws = LMWorkspace(J.Jv; marquardt_scaling = _default_marquardt_scaling(pf)) + ws = LMWorkspace(J.Jv; marquardt_scaling = _default_marquardt_scaling(typeof(pf))) converged, i2 = _run_power_flow_method( time_step, sv.x, residual, J, ws; tol, maxIterations = DEFAULT_NR_MAX_ITER, λ_0 = DEFAULT_λ_0, @@ -342,6 +360,80 @@ function _fd_maybe_handoff!( return (converged, i2) end +# Factor-once caching for the `:fixed_jacobian` loop: reuse the symbolic factorization across +# solves whose frozen Jacobian has the same sparsity pattern; rebuild on any pattern change +# (area-interchange relax, LCC state, or a bus-type switch all can move it). + +""" + FDJCacheKey + +Invalidation key for an [`FDFixedJacobianCache`](@ref): network identity and linear-solver +backend. The Jacobian pattern itself is checked separately, against the cache's stored +`colptr`/`rowval`. +""" +struct FDJCacheKey + ybus_id::UInt + backend_id::DataType +end + +""" + FDFixedJacobianCache <: SolverCache + +Holds the last frozen Jacobian's sparsity pattern (`colptr`, `rowval`, size) and its factored +[`PFLinearSolverCache`](@ref). `factor_count` counts full factorizations, for testability. +""" +mutable struct FDFixedJacobianCache <: SolverCache + key::FDJCacheKey + colptr::Vector{J_INDEX_TYPE} + rowval::Vector{J_INDEX_TYPE} + m::Int + n::Int + linear_cache::PFLinearSolverCache + factor_count::Int +end + +# Same (key, pattern) ⇒ reuse: refresh the frozen values in place and return the existing cache. +# Anything else (no cache yet, a different network/backend, or a pattern change) rebuilds. +function _get_or_build_fdj_cache!( + ::Nothing, + data::ACPowerFlowData, + key::FDJCacheKey, + backend, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, +) + return _build_fdj_cache!(data, key, backend, Jv) +end + +function _get_or_build_fdj_cache!( + cache::FDFixedJacobianCache, + data::ACPowerFlowData, + key::FDJCacheKey, + backend, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, +) + if cache.key == key && _same_sparsity(cache, Jv) + numeric_refactor!(cache.linear_cache, Jv) + return cache + else + return _build_fdj_cache!(data, key, backend, Jv) + end +end + +function _build_fdj_cache!( + data::ACPowerFlowData, + key::FDJCacheKey, + backend, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, +) + lcache = make_linear_solver_cache(backend, Jv) + full_factor!(lcache, Jv) + cache = FDFixedJacobianCache( + key, copy(Jv.colptr), copy(Jv.rowval), size(Jv, 1), size(Jv, 2), lcache, 1, + ) + data.solver_cache[] = cache + return cache +end + """ _fd_fixed_jacobian_power_flow(pf, data, time_step; ...) -> Bool @@ -363,7 +455,7 @@ function _fd_fixed_jacobian_power_flow( fd_vm_abort::Float64 = DEFAULT_FD_VM_ABORT, fd_ndvfct::Float64 = DEFAULT_FD_NDVFCT, fd_max_step_halvings::Int = DEFAULT_FD_MAX_STEP_HALVINGS, - handoff_solver = nothing, + handoff_solver = NoHandoff, handoff_tol::Float64 = DEFAULT_FD_HANDOFF_TOL, validate_voltage_magnitudes::Bool = DEFAULT_VALIDATE_VOLTAGES, vm_validation_range::MinMax = DEFAULT_VALIDATION_RANGE, @@ -399,8 +491,12 @@ function _fd_fixed_jacobian_power_flow( # constructors do not, so call it here unconditionally (cheap, once). J(time_step) backend = resolve_linear_solver_backend(linear_solver) - cache = make_linear_solver_cache(backend, J.Jv) - full_factor!(cache, J.Jv) # factor the frozen J ONCE + # Reuse the frozen Jacobian's symbolic factorization when its pattern is unchanged; see + # `FDFixedJacobianCache`. + fdj_key = FDJCacheKey(objectid(data.power_network_matrix), typeof(backend)) + fdj_cache = + _get_or_build_fdj_cache!(data.solver_cache[], data, fdj_key, backend, J.Jv) + cache = fdj_cache.linear_cache sv = StateVectorCache(x0_init, residual.Rv) v_state_idx = _fd_v_state_indices(residual) @@ -519,7 +615,7 @@ function _fd_fixed_jacobian_power_flow( # handoff is disabled or the FD state already met `tol`. Threads handoff iters into # the reported count so finalize happens ONCE, on the refined state. converged, i2 = _fd_maybe_handoff!( - pf, sv, residual, J, time_step, handoff_solver, tol, linear_solver, + handoff_solver, pf, sv, residual, J, time_step, tol, linear_solver, solver_name, i, ) @@ -1091,7 +1187,7 @@ half-steps with an exact residual re-evaluation after EACH half-step (the mid-cy prevents convergence cycling — do NOT skip it). Shared WP2 safeguards (non-divergent backtracking with best-state restore, BLOWUP, DVLIM, V≈0 abort) protect the documented FD failure modes. The FD stage converges on `‖Rv‖∞ < stage_tol`, where `stage_tol` -is the real `tol` for pure FD (`handoff_solver === nothing`) or the loose `handoff_tol` when an +is the real `tol` for pure FD (`handoff_solver === NoHandoff`) or the loose `handoff_tol` when an opt-in handoff (WP4) is configured — the handoff then refines to the real `tol` (`_fd_maybe_handoff!`). @@ -1115,7 +1211,7 @@ function _fd_decoupled_power_flow( fd_vm_abort::Float64 = DEFAULT_FD_VM_ABORT, fd_ndvfct::Float64 = DEFAULT_FD_NDVFCT, fd_max_step_halvings::Int = DEFAULT_FD_MAX_STEP_HALVINGS, - handoff_solver = nothing, + handoff_solver = NoHandoff, handoff_tol::Float64 = DEFAULT_FD_HANDOFF_TOL, validate_voltage_magnitudes::Bool = DEFAULT_VALIDATE_VOLTAGES, vm_validation_range::MinMax = DEFAULT_VALIDATION_RANGE, @@ -1138,7 +1234,7 @@ function _fd_decoupled_power_flow( # voltage-stability factors — otherwise skip the full sparse-Jacobian allocation + evaluation # entirely (a per-solve, per-time-step saving; see `_initialize_residual_x0`). need_jacobian = - handoff_solver !== nothing || + _fd_needs_handoff_jacobian(handoff_solver) || get_calculate_loss_factors(data) || get_calculate_voltage_stability_factors(data) if need_jacobian @@ -1368,7 +1464,7 @@ function _fd_decoupled_power_flow( # is disabled or the FD state already met `tol`. Threads handoff iters into the reported # count so finalize happens ONCE, on the refined state. converged, handoff_iters = _fd_maybe_handoff!( - pf, sv, residual, J, time_step, handoff_solver, tol, linear_solver, solver_name, + handoff_solver, pf, sv, residual, J, time_step, tol, linear_solver, solver_name, i, ) i += handoff_iters diff --git a/src/lcc_utils.jl b/src/lcc_utils.jl index bdda660f..aeb2f0c0 100644 --- a/src/lcc_utils.jl +++ b/src/lcc_utils.jl @@ -799,9 +799,9 @@ function initialize_LCCParameters!( # for DC power flow calculations, LCC arc flows are known from quantities from setup. for (i, lcc_branch) in enumerate(lccs) # it's an LCC, so flow can't be reversed; rhs will error if it is. - (P_from_to, P_to_from, _) = get_hvdc_power_loss(lcc_branch, sys) - data.lcc.arc_active_power_flow_from_to[i, :] .= P_from_to - data.lcc.arc_active_power_flow_to_from[i, :] .= P_to_from + (P_dc, P_loss, _) = get_hvdc_power_loss(lcc_branch, sys) + data.lcc.arc_active_power_flow_from_to[i, :] .= P_dc + data.lcc.arc_active_power_flow_to_from[i, :] .= -(P_dc - P_loss) end return end diff --git a/src/levenberg-marquardt.jl b/src/levenberg-marquardt.jl index 84755265..8b93457e 100644 --- a/src/levenberg-marquardt.jl +++ b/src/levenberg-marquardt.jl @@ -1,22 +1,23 @@ """Pre-allocated workspace for the Levenberg-Marquardt solver. -Holds the augmented matrix `[J; √λ·D]` with a fixed sparsity pattern, a mapping -to update its entries in-place, and a cached QR factorization. `D` is the -Marquardt column scaling (identity when disabled).""" +Solves each trial step from the normal equations `N = JᵀJ + λ·D²`. `N`'s +sparsity pattern is fixed for the life of a solve, so its CHOLMOD symbolic +factorization is computed once and reused across every λ update and +iteration; only the numeric factorization reruns. `D` is the Marquardt column +scaling (identity when disabled), floored to keep every entry positive, which +makes `N` positive definite whenever `λ > 0`. `_lm_qr_fallback` solves the +augmented system `[J; √λ·D]` by QR instead, for the rare case `N` is not +positive definite.""" mutable struct LMWorkspace - A::SparseMatrixCSC{Float64, Int64} - # Indices into A.nzval for the J block entries (same order as J.Jv.nzval) - j_nzval_indices::Vector{Int} - # Indices into A.nzval for the √λ diagonal entries (length n) - λ_diag_indices::Vector{Int} - # SPQR: enables a cached symbolic factorization with in-place numeric - # updates on the augmented [J; √λ·I]; the J^TJ normal-equations form is - # less stable for the rectangular system. - # Cached QR factorization - F::SparseArrays.SPQR.QRSparse{Float64, Int64} - # Preallocated augmented RHS [-Rv; 0] (length m + n); bottom n stay zero. - b::Vector{Float64} - # Marquardt diagonal scaling (length n). All-ones ⇒ √λ·I. + N::SparseMatrixCSC{Float64, J_INDEX_TYPE} # JᵀJ + λ·D², fixed pattern + jtj_p1::Vector{Int} + jtj_p2::Vector{Int} + jtj_offsets::Vector{Int} # see _build_jtj_nz_cache + diag_nz::Vector{Int} # N.nzval index of each diagonal entry i + mat::FixedStructureCHOLMOD{Float64, J_INDEX_TYPE} + F::SparseArrays.CHOLMOD.Factor{Float64, J_INDEX_TYPE} + rhs::Vector{Float64} # -Jᵀ·Rv, length n + # Marquardt diagonal scaling (length n). All-ones ⇒ λ·I. D::Vector{Float64} marquardt_scaling::Bool # Per-iteration scratch: temp_x = Rv + J·Δx (m); x_trial = x + Δx (n). @@ -24,48 +25,32 @@ mutable struct LMWorkspace x_trial::Vector{Float64} end -"""Build the augmented matrix `[J; D]` once, recording which `A.nzval` entries -correspond to J values vs the damping diagonal.""" +"""Build the fixed-pattern normal-equations matrix `N = JᵀJ` (values zeroed) +once, its CHOLMOD symbolic factorization, and the JᵀJ row-pair refill cache.""" function LMWorkspace( Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}; marquardt_scaling::Bool = false, ) m, n = size(Jv) - # Convert J to Int64 indices for SPQR compatibility, then vcat with identity. - Jv64 = SparseMatrixCSC{Float64, Int64}( - Jv.m, Jv.n, - Vector{Int64}(Jv.colptr), - Vector{Int64}(Jv.rowval), - copy(Jv.nzval), - ) - Iλ = sparse(Int64.(1:n), Int64.(1:n), ones(n), n, n) - A = vcat(Jv64, Iλ) - - # Identify which A.nzval entries come from J vs the diagonal. - j_nzval_indices = Vector{Int}(undef, length(Jv.nzval)) - λ_diag_indices = Vector{Int}(undef, n) + # Force the maximal J'*J pattern the same way HomotopyHessian does, then + # restore Jv's real values (see homotopy_hessian.jl's HomotopyHessian ctor). + original_nzval = copy(Jv.nzval) + fill!(Jv.nzval, 1.0) + N = Jv' * Jv + SparseArrays.nonzeros(N) .= 0.0 + copyto!(Jv.nzval, original_nzval) - j_idx = 0 - for col in 1:n - for a_idx in SparseArrays.nzrange(A, col) - row = A.rowval[a_idx] - if row <= m - j_idx += 1 - j_nzval_indices[j_idx] = a_idx - elseif row == m + col - λ_diag_indices[col] = a_idx - end - end - end - @assert j_idx == length(Jv.nzval) "Expected $(length(Jv.nzval)) J entries, found $j_idx" + jtj_p1, jtj_p2, jtj_offsets = _build_jtj_nz_cache(Jv, N) + diag_nz = [_nz_index(N, i, i) for i in 1:n] - b = zeros(m + n) - F = LinearAlgebra.qr(A) + mat = FixedStructureCHOLMOD(N) + F = symbolic_factor(mat) D = marquardt_scaling ? zeros(n) : ones(n) ws = LMWorkspace( - A, j_nzval_indices, λ_diag_indices, F, b, D, marquardt_scaling, + N, jtj_p1, jtj_p2, jtj_offsets, diag_nz, mat, F, + Vector{Float64}(undef, n), D, marquardt_scaling, Vector{Float64}(undef, m), Vector{Float64}(undef, n)) if marquardt_scaling update_column_scale!(ws, Jv) @@ -73,18 +58,9 @@ function LMWorkspace( return ws end -"""Copy current Jacobian values into the augmented matrix.""" -function copy_jacobian!(ws::LMWorkspace, Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}) - nzv = Jv.nzval - for (i, a_idx) in enumerate(ws.j_nzval_indices) - ws.A.nzval[a_idx] = nzv[i] - end - return -end - """Update `ws.D`, the per-column damping scale: each entry is the running maximum (across iterations) of the corresponding Jacobian column's 2-norm. It -is used as the Levenberg-Marquardt diagonal damping `√λ·D` in +is used as the Levenberg-Marquardt diagonal damping `λ·D²` in [`update_lambda!`](@ref). A column whose running max is still zero is floored to `1.0`, keeping `D > 0` so the damped block stays nonsingular.""" function update_column_scale!( @@ -106,22 +82,76 @@ function update_column_scale!( return end -"""Update the √λ·D damping diagonal and re-factorize.""" -function update_lambda!(ws::LMWorkspace, λ::Float64) - sqrtλ = sqrt(λ) - @inbounds for col in eachindex(ws.λ_diag_indices) - ws.A.nzval[ws.λ_diag_indices[col]] = sqrtλ * ws.D[col] +"""Refresh `ws.N = JᵀJ + λ·D²` in place from the current `Jv` (via the cached +row-pair map) and run a numeric CHOLMOD factorization reusing the symbolic +factorization computed once in the `LMWorkspace` constructor.""" +function update_lambda!( + ws::LMWorkspace, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + λ::Float64, +) + Nnz = SparseArrays.nonzeros(ws.N) + fill!(Nnz, 0.0) + _refresh_JtJ!(ws.N, Jv, ws.jtj_p1, ws.jtj_p2, ws.jtj_offsets) + @inbounds for i in eachindex(ws.diag_nz) + Nnz[ws.diag_nz[i]] += λ * ws.D[i]^2 end - ws.F = LinearAlgebra.qr(ws.A) + set_values!(ws.mat, Nnz) + numeric_factor!(ws.F, ws.mat) return end -"""Marquardt column scaling default per formulation: the rectangular CI state -columns `(e, f, Q, P_gen)` differ in natural scale, so identity damping is -ill-conditioned there — default it on. The polar state is well-scaled; keep it -off so the polar solver is bit-identical to before.""" -_default_marquardt_scaling(::AbstractACPowerFlow) = false -_default_marquardt_scaling(::ACRectangularPowerFlow) = true +"""Solve one LM trial step `(JᵀJ + λ·D²)Δx = -Jᵀ·Rv`. Falls back to a fresh +sparse QR of the augmented system `[J; √λ·D]` (uncached; not meant to be hot) +if the normal-equations factorization is not positive definite.""" +function _lm_solve_step!( + ws::LMWorkspace, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + Rv::Vector{Float64}, + λ::Float64, +) + ok = try + update_lambda!(ws, Jv, λ) + LinearAlgebra.issuccess(ws.F) + catch e + e isa SparseArrays.CHOLMOD.CHOLMODException || + e isa SparseArrays.CHOLMOD.PosDefException || rethrow(e) + false + end + if ok + LinearAlgebra.mul!(ws.rhs, Jv', Rv) + ws.rhs .*= -1 + return ws.F \ ws.rhs + end + @warn "LM normal-equations factorization was not positive definite; falling \ + back to a sparse QR solve of the augmented system for this step." maxlog = 5 + return _lm_qr_fallback(Jv, Rv, ws.D, λ) +end + +function _lm_qr_fallback( + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + Rv::Vector{Float64}, + D::Vector{Float64}, + λ::Float64, +) + m, n = size(Jv) + Jv64 = SparseMatrixCSC{Float64, Int64}( + Jv.m, Jv.n, Vector{Int64}(Jv.colptr), Vector{Int64}(Jv.rowval), copy(Jv.nzval)) + Iλ = sparse(Int64.(1:n), Int64.(1:n), sqrt(λ) .* D, n, n) + A = vcat(Jv64, Iλ) + b = zeros(m + n) + b[1:m] .= .-Rv + return LinearAlgebra.qr(A) \ b +end + +"""Marquardt column scaling default per formulation, dispatched on the formulation TYPE (so +it can be resolved at evaluation-model construction time, before an instance exists — see the +`marquardt_scaling` keyword on [`ACPolarPowerFlow`](@ref)/[`ACRectangularPowerFlow`](@ref)/ +[`ACMixedPowerFlow`](@ref)). The rectangular CI state columns `(e, f, Q, P_gen)` differ in +natural scale, so identity damping is ill-conditioned there — default it on. The polar and +mixed states are well-scaled; keep it off so those solvers are bit-identical to before.""" +_default_marquardt_scaling(::Type{<:AbstractACPowerFlow}) = false +_default_marquardt_scaling(::Type{<:ACRectangularPowerFlow}) = true """Driver for the LevenbergMarquardtACPowerFlow method: sets up the data structures (e.g. residual), runs the power flow method via calling `_run_power_flow_method` @@ -150,7 +180,7 @@ function _newton_power_flow( converged = norm(residual.Rv, Inf) < tol i = 0 if !converged - use_scaling = something(marquardt_scaling, _default_marquardt_scaling(pf)) + use_scaling = something(marquardt_scaling, _default_marquardt_scaling(typeof(pf))) ws = LMWorkspace(J.Jv; marquardt_scaling = use_scaling) converged, i = _run_power_flow_method( time_step, @@ -189,8 +219,9 @@ function _run_power_flow_method( linf = norm(residual.Rv, Inf) @debug "initially: sum of squares $(siground(resSize)), L ∞ norm $(siground(linf)), λ = $λ" monitor, diag_state = setup_solver_diagnostics(J, stop_at_fold) - # LM factorizes the augmented [J; √λ·D], not J, so the diagnostic keeps its own - # KLU factor of J (symbolic once here, refreshed each iteration by the hook). + # LM factorizes JᵀJ + λ·D² (or, on the rare QR fallback, the augmented + # [J; √λ·D]), not J itself, so the diagnostic keeps its own KLU factor of J + # (symbolic once here, refreshed each iteration by the hook). diag_cache = isnothing(diag_state) ? nothing : make_linear_solver_cache(PNM.KLUSolver(), J.Jv) @@ -242,15 +273,8 @@ function compute_error( residualSize::Float64, ws::LMWorkspace, ) - copy_jacobian!(ws, J.Jv) ws.marquardt_scaling && update_column_scale!(ws, J.Jv) - update_lambda!(ws, λ) - - m = length(residual.Rv) - @assert m == length(ws.b) - size(J.Jv, 2) "residual/J size mismatch vs preallocated LM buffer (m=$m, buf=$(length(ws.b)), n=$(size(J.Jv, 2)))" - @views ws.b[1:m] .= .-residual.Rv # bottom n entries stay zero from construction - # Δx left allocating: SPQR has no in-place reuse, and the QR rebuild dominates anyway. - Δx = ws.F \ ws.b + Δx = _lm_solve_step!(ws, J.Jv, residual.Rv, λ) # temp_x = Rv + J·Δx LinearAlgebra.mul!(ws.temp_x, J.Jv, Δx) diff --git a/src/linear_solver_backend.jl b/src/linear_solver_backend.jl index f2219d03..680e8da1 100644 --- a/src/linear_solver_backend.jl +++ b/src/linear_solver_backend.jl @@ -84,38 +84,56 @@ condest!(c::PNM.KLULinSolveCache) = PNM.condest!(c) Returns a PNM backend singleton: `PNM.KLUSolver()`, `PNM.AppleAccelerateLUSolver()`, or `PNM.MKLPardisoSolver()`. When `override === nothing`, the platform default from PNM's preference logic is used. Throws if AppleAccelerate is requested off an Apple -platform, or if MKLPardiso is requested on a non-x86_64 architecture or without the -`PowerFlowsPardisoExt` extension loaded (`import Pardiso`).""" +platform, if MKLPardiso is requested on a non-x86_64 architecture or without the +`PowerFlowsPardisoExt` extension loaded (`import Pardiso`), or if `"Dense"` is +requested (PNM resolves it to a real backend tag, but PowerFlows has no DC linear +solver cache for it).""" function resolve_linear_solver_backend(override::Union{Nothing, AbstractString}) name = if isnothing(override) PNM._default_linear_solver() else String(override) end - tag = PNM.resolve_linear_solver(name) - if tag isa PNM.AppleAccelerateLUSolver && !Sys.isapple() + return _validate_linear_solver_backend(PNM.resolve_linear_solver(name)) +end + +_validate_linear_solver_backend(tag::PNM.KLUSolver) = tag + +function _validate_linear_solver_backend(tag::PNM.AppleAccelerateLUSolver) + Sys.isapple() || error("AppleAccelerate backend requested but not on an Apple platform.") - elseif tag isa PNM.MKLPardisoSolver - # Intel MKL is x86_64-only. On other architectures (notably Apple Silicon) - # it can never load, so give a definitive message rather than suggesting - # `import Pardiso`, which would not help. macOS on x86_64 (incl. CI under - # Rosetta) reports `:x86_64` and is allowed through. - if Sys.ARCH !== :x86_64 - error( - "MKLPardiso backend requires an x86_64 platform with Intel MKL; it is " * - "unavailable on $(Sys.ARCH) architectures (e.g. Apple Silicon). " * - "Use the \"KLU\" or \"AppleAccelerateLU\" backend instead.", - ) - elseif !PNM._has_mkl_pardiso_ext() - error( - "MKLPardiso backend requested but Pardiso.jl is not loaded. " * - "Run `import Pardiso` to load the PowerFlowsPardisoExt extension.", - ) - end + return tag +end + +# Intel MKL is x86_64-only. On other architectures (notably Apple Silicon) it can never +# load, so give a definitive message rather than suggesting `import Pardiso`, which would +# not help. macOS on x86_64 (incl. CI under Rosetta) reports `:x86_64` and is allowed through. +function _validate_linear_solver_backend(tag::PNM.MKLPardisoSolver) + if Sys.ARCH !== :x86_64 + error( + "MKLPardiso backend requires an x86_64 platform with Intel MKL; it is " * + "unavailable on $(Sys.ARCH) architectures (e.g. Apple Silicon). " * + "Use the \"KLU\" or \"AppleAccelerateLU\" backend instead.", + ) + elseif !PNM._has_mkl_pardiso_ext() + error( + "MKLPardiso backend requested but Pardiso.jl is not loaded. " * + "Run `import Pardiso` to load the PowerFlowsPardisoExt extension.", + ) end return tag end +function _validate_linear_solver_backend(::PNM.DenseSolver) + throw( + ArgumentError( + "linear_solver=\"Dense\" is not supported: PowerFlows has no DC linear " * + "solver cache for it. Accepted backends are \"KLU\", \"AppleAccelerateLU\" " * + "(macOS only), and \"MKLPardiso\" (x86_64 only, needs `import Pardiso`).", + ), + ) +end + """Construct (without factorizing) the cache for backend `tag` over matrix `A`.""" make_linear_solver_cache(::PNM.KLUSolver, A::SparseMatrixCSC{Float64}) = PNM.KLULinSolveCache(A) diff --git a/src/mixed_cpb_power_flow_jacobian.jl b/src/mixed_cpb_power_flow_jacobian.jl index 56edb2f3..160d1846 100644 --- a/src/mixed_cpb_power_flow_jacobian.jl +++ b/src/mixed_cpb_power_flow_jacobian.jl @@ -10,8 +10,8 @@ MCPB and rewritten each iteration. PQ off-diagonals are constant `±Y` `nonzeros(Jv)` through nzval-index caches built once at construction, so the hot path is `O(N + n_LCC)`. Field roles are in the inline comments below. """ -struct ACMixedCPBJacobian - data::ACPowerFlowData +struct ACMixedCPBJacobian{D <: ACPowerFlowData} + data::D Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} Y_diag::Vector{ComplexF64} # cached Y_bus_eff diagonal; avoids O(log nnz) sparse access per iteration diff --git a/src/mixed_cpb_power_flow_residual.jl b/src/mixed_cpb_power_flow_residual.jl index 55d55d54..3860751f 100644 --- a/src/mixed_cpb_power_flow_residual.jl +++ b/src/mixed_cpb_power_flow_residual.jl @@ -15,8 +15,8 @@ never allocates a `Set`; `validate_offsets` are the precomputed PQ/PV `x`- offsets for the voltage-magnitude diagnostic. Remaining fields are named after their roles. """ -struct ACMixedCPBResidual - data::ACPowerFlowData +struct ACMixedCPBResidual{D <: ACPowerFlowData} + data::D Rv::Vector{Float64} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} P_net_const::Vector{Float64} diff --git a/src/post_processing.jl b/src/post_processing.jl index de164375..f8149be6 100644 --- a/src/post_processing.jl +++ b/src/post_processing.jl @@ -1,9 +1,27 @@ +_counts_as_source(::PSY.StaticInjection) = true +_counts_as_source(::PSY.ElectricLoad) = false +# temporary workaround for FACTSControlDevice +_counts_as_source(::PSY.FACTSControlDevice) = false + function _is_available_source(x, bus::PSY.ACBus) - # temporary workaround for FACTSControlDevice - return PSY.get_available(x) && x.bus == bus && !isa(x, PSY.ElectricLoad) && - !isa(x, PSY.FACTSControlDevice) + return PSY.get_available(x) && PSY.get_bus(x) == bus && _counts_as_source(x) +end + +"""Available non-load, non-FACTS injectors bucketed by bus number, built once so REF/PV +redistribution does not rescan every `StaticInjection` in `sys` per bus.""" +function _build_bus_injector_map(sys::PSY.System) + bus_injectors = Dict{Int, Vector{PSY.StaticInjection}}() + for x in PSY.get_available_components(PSY.StaticInjection, sys) + _counts_as_source(x) || continue + bus_no = PSY.get_number(PSY.get_bus(x)) + push!(get!(() -> PSY.StaticInjection[], bus_injectors, bus_no), x) + end + return bus_injectors end +_bus_sources(bus::PSY.ACBus, bus_injectors::Dict{Int, Vector{PSY.StaticInjection}}) = + get(bus_injectors, PSY.get_number(bus), PSY.StaticInjection[]) + """Returns a dictionary of bus index to power contribution at that bus from FixedAdmittance components, as a tuple of (active power, reactive power).""" function _calculate_fixed_admittance_powers( @@ -39,14 +57,14 @@ function _power_redistribution_ref( Q_gen::Float64, bus::PSY.ACBus, max_iterations::Int, + bus_injectors::Dict{Int, Vector{PSY.StaticInjection}}, generator_slack_participation_factors::Union{ Nothing, Dict{Tuple{DataType, String}, Float64}, } = nothing; skip_reactive::Bool = false, ) - devices_ = - PSY.get_components(x -> _is_available_source(x, bus), PSY.StaticInjection, sys) + devices_ = _bus_sources(bus, bus_injectors) all_devices = devices_ sources = filter(x -> x isa PSY.Source, collect(devices_)) @@ -70,7 +88,14 @@ function _power_redistribution_ref( if length(devices_) == 1 device = first(devices_) PSY.set_active_power!(device, P_gen * PSY.SU) - skip_reactive || _reactive_power_redistribution_pv(sys, Q_gen, bus, max_iterations) + skip_reactive || + _reactive_power_redistribution_pv( + sys, + Q_gen, + bus, + max_iterations, + bus_injectors, + ) return elseif length(devices_) > 1 devices = @@ -91,6 +116,14 @@ function _power_redistribution_ref( else to_redistribute = P_gen - sum(PSY.get_active_power.(all_devices, (PSY.SU,))) sum_bus_gspf = sum(values(devices_gspf)) + if iszero(sum_bus_gspf) + error( + "Generator slack participation factors at REF bus $(PSY.get_name(bus)) " * + "sum to zero across $(length(devices_gspf)) device(s) " * + "($(join(PSY.get_name.(keys(devices_gspf)), ", "))); cannot absorb " * + "$(to_redistribute) MW of slack with zero total participation.", + ) + end for (device, factor) in devices_gspf PSY.set_active_power!( @@ -102,7 +135,9 @@ function _power_redistribution_ref( ) end skip_reactive || - _reactive_power_redistribution_pv(sys, Q_gen, bus, max_iterations) + _reactive_power_redistribution_pv( + sys, Q_gen, bus, max_iterations, bus_injectors, + ) return end end @@ -175,7 +210,8 @@ function _power_redistribution_ref( end end end - skip_reactive || _reactive_power_redistribution_pv(sys, Q_gen, bus, max_iterations) + skip_reactive || + _reactive_power_redistribution_pv(sys, Q_gen, bus, max_iterations, bus_injectors) return end @@ -185,10 +221,10 @@ function _reactive_power_redistribution_pv( Q_gen::Float64, bus::PSY.ACBus, max_iterations::Int, + bus_injectors::Dict{Int, Vector{PSY.StaticInjection}}, ) @debug "Reactive Power Distribution $(PSY.get_name(bus))" - devices_ = - PSY.get_components(x -> _is_available_source(x, bus), PSY.StaticInjection, sys) + devices_ = _bus_sources(bus, bus_injectors) sources = filter(x -> typeof(x) == PSY.Source, collect(devices_)) non_source_devices = filter(x -> typeof(x) !== PSY.Source, collect(devices_)) if length(sources) > 0 && length(non_source_devices) > 0 @@ -592,6 +628,51 @@ _set_group_interior_voltages!( ::PNM.NetworkReductionData, ) = nothing +""" + _append_segment_entries!(entries, member, V_from, V_to, from_bus, nrd) + +Append `member`'s `BranchFlowEntry`(s) to `entries`, given the voltages at `member`'s own +bus pair in `from_bus`-first orientation. Dispatches on `member`'s kind: a plain branch +resolves directly; a nested parallel group recurses through [`_segment_group_flow_entries`](@ref). +""" +function _append_segment_entries!( + entries::Vector{BranchFlowEntry}, + member::PSY.ACTransmission, + V_from::ComplexF64, + V_to::ComplexF64, + ::Int, + nrd::PNM.NetworkReductionData, +) + push!(entries, _segment_flow_entry(member, V_from, V_to, nrd)) + return entries +end + +function _append_segment_entries!( + entries::Vector{BranchFlowEntry}, + member::PNM.AbstractBranchesParallel, + V_from::ComplexF64, + V_to::ComplexF64, + from_bus::Int, + nrd::PNM.NetworkReductionData, +) + append!(entries, _segment_group_flow_entries(member, V_from, V_to, from_bus, nrd)) + return entries +end + +function _append_segment_entries!( + ::Vector{BranchFlowEntry}, + member::PNM.BranchesSeries, + ::ComplexF64, + ::ComplexF64, + ::Int, + ::PNM.NetworkReductionData, +) + error( + "Series chain $(PNM.get_name(member)) is nested inside another series chain or " * + "group. Per-branch flow reporting cannot resolve its interior buses.", + ) +end + """ _segment_group_flow_entries(group, V_from, V_to, from_bus, nrd) @@ -609,25 +690,12 @@ function _segment_group_flow_entries( entries = BranchFlowEntry[] for member in group (member_from, _) = PNM.get_arc_tuple(member, nrd) - (V_f, V_t) = member_from == from_bus ? (V_from, V_to) : (V_to, V_from) - if member isa PNM.BranchesSeries - # A chain has interior buses whose voltages are not on the reduced bus axis, so it - # cannot be evaluated from its endpoints alone. Reductions do not currently nest a - # chain inside a chain segment; erroring names the structure that changed rather - # than silently reporting a two-port flow for a multi-segment path. - error( - "Series chain $(PNM.get_name(member)) appears as a member of the group on " * - "arc $(PNM.get_arc_tuple(group, nrd)), which is nested inside another " * - "series chain. Per-branch flow reporting cannot resolve its interior buses.", - ) - elseif member isa PNM.AbstractReductionAggregate - append!( - entries, - _segment_group_flow_entries(member, V_f, V_t, member_from, nrd), - ) + if member_from == from_bus + (V_f, V_t) = (V_from, V_to) else - push!(entries, _segment_flow_entry(member, V_f, V_t, nrd)) + (V_f, V_t) = (V_to, V_from) end + _append_segment_entries!(entries, member, V_f, V_t, member_from, nrd) end return entries end @@ -712,21 +780,26 @@ function _compute_segment_flows( for (i, segment) in enumerate(arc_entry) (segment_from, segment_to) = PNM.get_arc_tuple(segment) reversed = segment_from != prev_bus_no - current_bus_no = reversed ? segment_from : segment_to - current_V = (i == length(arc_entry)) ? V_endpoints[2] : x[i] - - (V_from, V_to) = reversed ? (current_V, prev_V) : (prev_V, current_V) - if segment isa PNM.AbstractReductionAggregate - # A chain segment can itself be a group of arcs resolved onto the same bus pair, - # whose members need not share the segment's orientation. - append!( - entries, - _segment_group_flow_entries(segment, V_from, V_to, segment_from, nrd), - ) + if reversed + current_bus_no = segment_from + else + current_bus_no = segment_to + end + if i == length(arc_entry) + current_V = V_endpoints[2] else - push!(entries, _segment_flow_entry(segment, V_from, V_to, nrd)) + current_V = x[i] end + if reversed + (V_from, V_to) = (current_V, prev_V) + else + (V_from, V_to) = (prev_V, current_V) + end + # A chain segment can itself be a group of arcs resolved onto the same bus pair, + # whose members need not share the segment's orientation. + _append_segment_entries!(entries, segment, V_from, V_to, segment_from, nrd) + prev_bus_no = current_bus_no if i < length(arc_entry) prev_V = x[i] @@ -850,6 +923,7 @@ function write_power_flow_solution!( else get_computed_gspf(data)[time_step] end + bus_injectors = _build_bus_injector_map(sys) # once redistribution is working again, could remove skip_redistribution. bus_lookup = get_bus_lookup(data) @@ -874,6 +948,7 @@ function write_power_flow_solution!( Q_gen, bus, max_iterations, + bus_injectors, gspf, ) elseif bustype == PSY.ACBusTypes.PV @@ -892,10 +967,13 @@ function write_power_flow_solution!( Q_gen, bus, max_iterations, + bus_injectors, gspf, ) elseif !pf.skip_redistribution - _reactive_power_redistribution_pv(sys, Q_gen, bus, max_iterations) + _reactive_power_redistribution_pv( + sys, Q_gen, bus, max_iterations, bus_injectors, + ) end elseif bustype == PSY.ACBusTypes.PQ Vm = data.bus_magnitude[ix, time_step] @@ -1016,6 +1094,7 @@ function write_power_flow_solution!( else gspf = get_computed_gspf(data)[time_step] end + bus_injectors = _build_bus_injector_map(sys) bus_lookup = get_bus_lookup(data) for (bus_number, reduced_buses) in PNM.get_bus_reduction_map(nrd) if !iszero(length(reduced_buses)) @@ -1047,6 +1126,7 @@ function write_power_flow_solution!( Q_gen, bus, max_iterations, + bus_injectors, gspf; skip_reactive = true, ) @@ -1788,9 +1868,8 @@ function _distribute_arc_flows( ] end -# Per-member DC flow = susceptance-ratio split (`m`) + circulating component `c` (zero on -# non-shifted groups). `c` is per unit at system base, matching `P_from_to`/`P_to_from` at -# this point in the pipeline (MW scaling happens later in `_allocate_results_data`). +# Member shares (`m`, `c`) are computed in the group's own arc frame, so a member keyed the +# other way (`arc_tuple != group_arc`) has its ft/tf shares swapped before being labeled. function _distribute_arc_flows( arc_entry::PNM.AbstractBranchesParallel, nrd::PNM.NetworkReductionData, @@ -1801,42 +1880,21 @@ function _distribute_arc_flows( arc_P_losses::Float64, ) entries = BranchFlowEntry[] + group_arc = PNM.get_arc_tuple(arc_entry, nrd) for br in arc_entry m = PNM.compute_parallel_multiplier(arc_entry, br) c = PNM.compute_parallel_circulating_flow(arc_entry, nrd, br) P_ft = P_from_to * m + c P_tf = P_to_from * m - c - if br isa PNM.AbstractReductionAggregate - # A member can be a whole series chain, which contributes one entry per segment - # once its share of the arc flow is known. - append!( - entries, - _distribute_arc_flows( - br, - nrd, - P_ft, - Q_from_to * m, - P_tf, - Q_to_from * m, - arc_P_losses * m, - ), - ) - continue + Q_ft = Q_from_to * m + Q_tf = Q_to_from * m + if PNM.get_arc_tuple(br, nrd) != group_arc + (P_ft, P_tf) = (P_tf, P_ft) + (Q_ft, Q_tf) = (Q_tf, Q_ft) end - arc_tuple = PNM.get_arc_tuple(br) - push!( + append!( entries, - BranchFlowEntry(( - PNM.get_name(br), - arc_tuple[1], - arc_tuple[2], - P_ft, - P_tf, - arc_P_losses * m, - Q_from_to * m, - Q_to_from * m, - 0.0, - )), + _distribute_arc_flows(br, nrd, P_ft, Q_ft, P_tf, Q_tf, arc_P_losses * m), ) end return entries @@ -2049,6 +2107,18 @@ function write_results( time_step = time_step, ) + # Total withdrawal, not the constant-power bucket alone: switched shunts and + # StandardLoad's constant-current/impedance terms live in the ZIP withdrawal buckets and + # are otherwise left out of P_load/Q_load, though they are in the solved injections. + P_load_total = [ + get_bus_active_power_total_withdrawals(data, ix, time_step) for + ix in eachindex(bus_numbers) + ] + Q_load_total = [ + get_bus_reactive_power_total_withdrawals(data, ix, time_step) for + ix in eachindex(bus_numbers) + ] + results = _allocate_results_data( data, flow_results, @@ -2059,8 +2129,8 @@ function write_results( data.bus_angles[:, time_step], data.bus_active_power_injections[:, time_step], data.bus_reactive_power_injections[:, time_step], - data.bus_active_power_withdrawals[:, time_step], - data.bus_reactive_power_withdrawals[:, time_step], + P_load_total, + Q_load_total, time_step, ) _add_vsc_results!(results, sys, data, PSY.get_base_power(sys), time_step) @@ -2083,6 +2153,7 @@ function update_system!(sys::PSY.System, data::PowerFlowData; time_step = 1) if !isempty(PNM.get_reductions(nrd)) error("update_system! does not support systems with network reductions.") end + bus_injectors = _build_bus_injector_map(sys) for bus in PSY.get_components(PSY.ACBus, sys) bus_index = get_bus_lookup(data)[PSY.get_number(bus)] bus_type = data.bus_type[bus_index, time_step] # use this instead of bus.bustype to account for PV -> PQ @@ -2096,6 +2167,7 @@ function update_system!(sys::PSY.System, data::PowerFlowData; time_step = 1) Q_gen, bus, DEFAULT_MAX_REDISTRIBUTION_ITERATIONS, + bus_injectors, ) elseif bus_type == PSY.ACBusTypes.PV # For PV bus, active and voltage are fixed; update reactive and angle @@ -2105,6 +2177,7 @@ function update_system!(sys::PSY.System, data::PowerFlowData; time_step = 1) Q_gen, bus, DEFAULT_MAX_REDISTRIBUTION_ITERATIONS, + bus_injectors, ) PSY.set_angle!(bus, data.bus_angles[bus_index, time_step]) elseif bus_type == PSY.ACBusTypes.PQ @@ -2112,11 +2185,14 @@ function update_system!(sys::PSY.System, data::PowerFlowData; time_step = 1) Vm = data.bus_magnitude[bus_index, time_step] PSY.set_magnitude!(bus, Vm) PSY.set_angle!(bus, data.bus_angles[bus_index, time_step]) - # if it used to be a PV bus, also set the Q value: - if bus.bustype == PSY.ACBusTypes.PV + # if it used to be a PV bus, also set the Q value -- unless correct_bustypes + # demoted it to PQ for having no available source, in which case there is no + # device left to redistribute reactive power onto. + if bus.bustype == PSY.ACBusTypes.PV && + haskey(bus_injectors, PSY.get_number(bus)) Q_gen = data.bus_reactive_power_injections[bus_index, time_step] _reactive_power_redistribution_pv(sys, Q_gen, bus, - DEFAULT_MAX_REDISTRIBUTION_ITERATIONS) + DEFAULT_MAX_REDISTRIBUTION_ITERATIONS, bus_injectors) # now both the Q and the Vm, Va are correct for this kind of buses end end diff --git a/src/power_flow_method.jl b/src/power_flow_method.jl index 00be7ffc..e52bbfa8 100644 --- a/src/power_flow_method.jl +++ b/src/power_flow_method.jl @@ -54,79 +54,69 @@ The residual, Jacobian sparsity/symbolic factorization, and state-vector buffers structure-invariant for the polar formulation across both loops (the J pattern is built bus-type-agnostically — see `_create_jacobian_matrix_structure` — and the fill rewrites every structural nonzero each call), so they are built once and reused; only value paths re-run. `backend` -is the linear-solver backend tag (its `typeof` keys reuse). See `_newton_workspace!`.""" -struct PolarNRCache <: AbstractNRCache - residual::ACPowerFlowResidual - J::ACPowerFlowJacobian - linSolveCache::PFLinearSolverCache +is the linear-solver backend tag (its `typeof` keys reuse). `linSolveCache` is typed on `C` so the +concrete backend cache narrows without a runtime dispatch (see `_newton_workspace!`). +`bus_type_snapshot` is the bus-type column the last structural rebuild (subnetworks, slack +participation, PQ index list) was keyed on, so `_refresh_polar_residual!` can skip that rebuild +when bus types have not moved since.""" +struct PolarNRCache{C <: PFLinearSolverCache, D <: ACPowerFlowData} <: AbstractNRCache + residual::ACPowerFlowResidual{D} + J::ACPowerFlowJacobian{D} + linSolveCache::C stateVector::StateVectorCache backend::PNM.LinearSolverType + bus_type_snapshot::Vector{PSY.ACBusTypes.Value} end -"""Recompute, in place, every per-time-step and bus-type-derived quantity of `residual` that the -constructor sets but the value path (`_update_residual_values!`) does not refresh — so a reused +"""Recompute, in place, every per-time-step and bus-type-derived quantity of `entry.residual` that +the constructor sets but the value path (`_update_residual_values!`) does not refresh — so a reused residual matches a fresh `ACPowerFlowResidual(data, time_step)` exactly. -Returns `false` (caller must rebuild from scratch) when a *structural* quantity would change versus -the cached residual: the subnetwork partition, the set of slack-participating buses (either changes -the Jacobian sparsity pattern), or the REF-bus set. Returns `true` when only values changed (the -common case: per-step injection changes; PV→PQ Q-limit flips under single-REF slack, where flipped -PV buses carry zero participation and so never alter the pattern).""" -function _refresh_polar_residual!(residual::ACPowerFlowResidual, time_step::Int64) +The subnetwork partition, slack-participation pattern, and PQ index list are pure functions of the +bus-type column (network topology is fixed for `data`'s lifetime), so the subnetwork partition and +the PQ index list are only recomputed when `bus_type` has moved since `entry.bus_type_snapshot` was +captured. The slack-participation weights are NOT bus-type-only: `data.bus_slack_participation_factors` +can carry a different profile per time step even under a fixed bus-type column (a per-step +distributed-slack schedule), so that piece is always recomputed and compared. LCC value state (taps, +thyristor angles) is rebuilt from `x` on every residual/Jacobian evaluation regardless +(`_update_residual_values!`, `_set_entries_for_lcc`), so an LCC system reuses exactly like one without. + +Returns `false` (caller must rebuild from scratch) when a *structural* quantity actually differs +from the cached residual: the subnetwork partition, the set of slack-participating buses (either +changes the Jacobian sparsity pattern), or the REF-bus set. Returns `true` when only values changed +(the common case: per-step injection changes; PV→PQ Q-limit flips under single-REF slack, where +flipped PV buses carry zero participation and so never alter the pattern).""" +function _refresh_polar_residual!(entry::PolarNRCache, time_step::Int64) + residual = entry.residual data = residual.data - n_buses = first(size(data.bus_type)) bus_type = view(data.bus_type, :, time_step) - # LCC self-admittances and tail residuals are rebuilt from x each value call, but the LCC - # branch-admittance/bus-index structure is captured at construction; reuse across a config - # where LCCs are present is out of scope — rebuild. - size(data.lcc.p_set, 1) > 0 && return false - - subnetworks = - _find_subnetworks_for_reference_buses(data.power_network_matrix.data, bus_type) - # Structural guard: the J sparsity pattern is keyed on the subnetwork partition and the - # participating-bus set. Reuse only when both match the cached residual. - keys(subnetworks) == keys(residual.subnetworks) || return false - for (ref, members) in subnetworks - members == residual.subnetworks[ref] || return false + if bus_type == entry.bus_type_snapshot + subnetworks = residual.subnetworks + else + subnetworks = + _find_subnetworks_for_reference_buses(data.power_network_matrix.data, bus_type) + # Structural guard: the J sparsity pattern is keyed on the subnetwork partition and the + # participating-bus set. Reuse only when both match the cached residual. + keys(subnetworks) == keys(residual.subnetworks) || return false + for (ref, members) in subnetworks + members == residual.subnetworks[ref] || return false + end + new_vi = _pq_validate_indices(bus_type) + resize!(residual.validate_indices, length(new_vi)) + copyto!(residual.validate_indices, new_vi) + copyto!(entry.bus_type_snapshot, bus_type) end - new_spf = - _build_bus_slack_participation_factors(data, bus_type, subnetworks, time_step) + + new_spf = _build_bus_slack_participation_factors(data, bus_type, subnetworks, time_step) SparseArrays.nonzeroinds(new_spf) == SparseArrays.nonzeroinds(residual.bus_slack_participation_factors) || return false - - # Refresh slack factors in place (nzind matches per the guard) to preserve the aliasing the - # Jacobian holds into this SparseVector. + # Refresh slack factors in place (nzind matches per the guard) to preserve the aliasing + # the Jacobian holds into this SparseVector. SparseArrays.nonzeros(residual.bus_slack_participation_factors) .= SparseArrays.nonzeros(new_spf) - # Refresh the per-step setpoints exactly as the constructor computes them. P_net is reset to - # P_net_set because the PQ ZIP path accumulates onto P_net (telescoping from this baseline). - @inbounds for ix in 1:n_buses - p = - data.bus_active_power_injections[ix, time_step] - - get_bus_active_power_total_withdrawals(data, ix, time_step) + - data.bus_hvdc_net_power[ix, time_step] - q = - data.bus_reactive_power_injections[ix, time_step] - - get_bus_reactive_power_total_withdrawals(data, ix, time_step) - residual.P_net[ix] = p - residual.P_net_set[ix] = p - residual.Q_net[ix] = q - end - - residual.bus_active_constant_I .= - view(data.bus_active_power_constant_current_withdrawals, :, time_step) - residual.bus_reactive_constant_I .= - view(data.bus_reactive_power_constant_current_withdrawals, :, time_step) - residual.bus_active_constant_Z .= - view(data.bus_active_power_constant_impedance_withdrawals, :, time_step) - residual.bus_reactive_constant_Z .= - view(data.bus_reactive_power_constant_impedance_withdrawals, :, time_step) - - new_vi = _pq_validate_indices(bus_type) - resize!(residual.validate_indices, length(new_vi)) - copyto!(residual.validate_indices, new_vi) + _refresh_residual_setpoints!(residual, data, time_step) return true end @@ -236,22 +226,13 @@ function _set_Δx_nr!(stateVector::StateVectorCache, return end -"""Returns a freshly-allocated stand-in matrix `-(JᵀJ + λI)` for a singular `J`. The result -defines the sparsity pattern that [`_refresh_singular_J_fallback!`](@ref) reuses in place.""" -function _build_singular_J_fallback(Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, +"""Fill `M` in place with `-(fjac2 + λI)`, `M` and `fjac2` sharing one pattern. A manual loop +over `M`'s stored pattern (not the broadcast `-(fjac2 + λ*I)`) so a structurally-present entry +that evaluates to exactly zero stays in the pattern instead of being pruned by sparse broadcast — +the pruning that made a freshly-built `M` fail to match a same-`Jv` recompute of `fjac2`.""" +function _fill_singular_J_fallback!(M::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + fjac2::SparseMatrixCSC{Float64, J_INDEX_TYPE}, x::Vector{Float64}) - fjac2 = Jv' * Jv - lambda = NR_SINGULAR_SCALING * sqrt(length(x) * eps()) * norm(fjac2, 1) - return -(fjac2 + lambda * LinearAlgebra.I) -end - -"""Refresh `M = -(JᵀJ + λI)` in place (λ as in [`_build_singular_J_fallback`](@ref)). Returns -`false` without touching `M` when the `JᵀJ` pattern no longer matches `M`'s, so the caller rebuilds.""" -function _refresh_singular_J_fallback!(M::SparseMatrixCSC{Float64, J_INDEX_TYPE}, - Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, - x::Vector{Float64}) - fjac2 = Jv' * Jv - (fjac2.colptr == M.colptr && fjac2.rowval == M.rowval) || return false lambda = NR_SINGULAR_SCALING * sqrt(length(x) * eps()) * norm(fjac2, 1) Mnz = M.nzval Fnz = fjac2.nzval @@ -264,6 +245,29 @@ function _refresh_singular_J_fallback!(M::SparseMatrixCSC{Float64, J_INDEX_TYPE} end end end + return +end + +"""Returns a freshly-allocated stand-in matrix `-(JᵀJ + λI)` for a singular `J`, on `JᵀJ`'s own +(full) pattern. The result defines the sparsity pattern that +[`_refresh_singular_J_fallback!`](@ref) reuses in place.""" +function _build_singular_J_fallback(Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + x::Vector{Float64}) + fjac2 = Jv' * Jv + M = copy(fjac2) + _fill_singular_J_fallback!(M, fjac2, x) + return M +end + +"""Refresh `M = -(JᵀJ + λI)` in place (λ as in [`_build_singular_J_fallback`](@ref)). Returns +`false` without touching `M` when the `JᵀJ` pattern no longer matches `M`'s (i.e. `Jv`'s own +structural pattern changed), so the caller rebuilds.""" +function _refresh_singular_J_fallback!(M::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + x::Vector{Float64}) + fjac2 = Jv' * Jv + (fjac2.colptr == M.colptr && fjac2.rowval == M.rowval) || return false + _fill_singular_J_fallback!(M, fjac2, x) return true end @@ -976,9 +980,9 @@ function _report_power_flow_convergence( solver_name::String, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, ) - @info("Final residual size: $(norm(residual.Rv, 2)) L2, $(norm(residual.Rv, Inf)) L∞.") + @debug("Final residual size: $(norm(residual.Rv, 2)) L2, $(norm(residual.Rv, Inf)) L∞.") if converged - @info("The $solver_name solver converged after $i iterations.") + @debug("The $solver_name solver converged after $i iterations.") return true end @debug("The $solver_name solver failed to converge after $i iterations.") @@ -1063,6 +1067,9 @@ end # Build + symbolically factor a fresh linear-solver cache. Polar workspace reuse lives in # `PolarNRCache`/`_newton_workspace!` and does not route through here, so this never writes the # shared `data.polar_nr_cache` slot; the continuation path calls it for a one-off cache. +# The caller (`_sensitivity_context`) counts this factorization itself — it is the ONE symbolic +# build of the continuation's probe phase, reused by every batched-pass refresh — so this does not +# also count it (that double-counted the same factorization). function _nr_linear_solver_cache!( data::ACPowerFlowData, J, @@ -1071,7 +1078,6 @@ function _nr_linear_solver_cache!( ) linSolveCache = make_linear_solver_cache(backend, J.Jv) symbolic_factor!(linSolveCache, J.Jv) - _count_symbolic_factor!(data) return linSolveCache end @@ -1143,14 +1149,110 @@ function _nr_build_jacobian( end _nr_build_jacobian(::AbstractACPowerFlow, residual, J, time_step::Int64) = J +"""Shared fresh-build body for `_newton_workspace!`: initialize the residual (deferring the +Jacobian per `_nr_initialize_with_jacobian_deferred`), return early on a 0-iteration warm start, +otherwise build `J`, a symbolically-factored linear-solver cache, and a fresh `StateVectorCache`. +Returns `(residual, J_or_nothing, x0_init, linSolveCache_or_nothing, stateVector_or_nothing, +converged)`. Counts the symbolic factorization it performs (a no-op outside discrete control).""" +function _fresh_newton_workspace( + pf::AbstractACPowerFlow, + data::ACPowerFlowData, + time_step::Int64, + backend, + tol::Float64, + init_kwargs::NamedTuple, +) + residual, J_deferred, x0_init = + _nr_initialize_with_jacobian_deferred(pf, data, time_step; init_kwargs...) + converged = norm(residual.Rv, Inf) < tol + converged && return residual, J_deferred, x0_init, nothing, nothing, true + J = _nr_build_jacobian(pf, residual, J_deferred, time_step) + linSolveCache = make_linear_solver_cache(backend, J.Jv) + symbolic_factor!(linSolveCache, J.Jv) + _count_symbolic_factor!(data) + stateVector = StateVectorCache(x0_init, residual.Rv) + return residual, J, x0_init, linSolveCache, stateVector, false +end + +"""Persistent reuse cache for the rectangular-CI/mixed-CPB Newton workspace, stored in the shared +`data.solver_cache` slot (polar has its own dedicated `data.polar_nr_cache`; rect/mixed share the +generic slot with the DC and fast-decoupled caches — see the slot's docstring in +`PowerFlowData.jl`). Unlike `PolarNRCache`, the residual and Jacobian are NOT reused: their state +dimension is bus-type-derived (PQ/REF 2 vars, PV 3), so a bus-type flip changes the block layout, +not just participation weights, and rebuilding them is what `_nr_initialize_with_jacobian_deferred` +already does cheaply. What this cache avoids re-paying is the linear-solver symbolic factorization: +`colptr`/`rowval`/size are the rebuilt `J.Jv`'s structural fingerprint (`_same_sparsity`); a match skips +`symbolic_factor!` and reuses `linSolveCache` and the `StateVectorCache` buffers as-is.""" +mutable struct RectMixedNRCache{C <: PFLinearSolverCache} <: SolverCache + colptr::Vector{J_INDEX_TYPE} + rowval::Vector{J_INDEX_TYPE} + m::Int + n::Int + backend::PNM.LinearSolverType + linSolveCache::C + stateVector::StateVectorCache +end + +function _build_rect_mixed_cache!( + data::ACPowerFlowData, + backend, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + x0::Vector{Float64}, + r0::Vector{Float64}, +) + linSolveCache = make_linear_solver_cache(backend, Jv) + symbolic_factor!(linSolveCache, Jv) + _count_symbolic_factor!(data) + stateVector = StateVectorCache(x0, r0) + data.solver_cache[] = RectMixedNRCache( + copy(Jv.colptr), copy(Jv.rowval), size(Jv, 1), size(Jv, 2), backend, + linSolveCache, stateVector, + ) + return linSolveCache, stateVector +end + +# No cache yet. +_get_or_build_rect_mixed_cache!(::Nothing, data, backend, Jv, x0, r0) = + _build_rect_mixed_cache!(data, backend, Jv, x0, r0) + +# The slot holds some OTHER SolverCache subtype (e.g. a FastDecoupled driver ran on this `data` +# first). Rect/mixed formulations legitimately share the slot with FD across an ordinary solver +# switch on the same `data` (unlike a DC/AC type mismatch, which is a program bug elsewhere), so +# this treats it the same as no cache rather than following `FDFixedJacobianCache`'s two-arm +# dispatch (::Nothing / concrete type only) into a MethodError. +_get_or_build_rect_mixed_cache!(::SolverCache, data, backend, Jv, x0, r0) = + _build_rect_mixed_cache!(data, backend, Jv, x0, r0) + +function _get_or_build_rect_mixed_cache!( + cache::RectMixedNRCache, + data::ACPowerFlowData, + backend, + Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, + x0::Vector{Float64}, + r0::Vector{Float64}, +) + if typeof(cache.backend) === typeof(backend) && _same_sparsity(cache, Jv) + stateVector = cache.stateVector + copyto!(stateVector.x, x0) + copyto!(stateVector.r, r0) + fill!(stateVector.d, 1.0) + stateVector.fallback_cache[] = nothing + stateVector.fallback_matrix[] = nothing + return cache.linSolveCache, stateVector + end + return _build_rect_mixed_cache!(data, backend, Jv, x0, r0) +end + """Build (or, for the polar formulation, reuse) the Newton workspace for one `_newton_power_flow` call. Returns `(residual, J, x0_init, linSolveCache, stateVector, converged)`; the solver cache and state-vector buffers are only constructed when the initial point has not already converged (matching the historical lazy build), so they are `nothing` in the already-converged case (never consumed, since the caller skips `_run_power_flow_method` then). -Non-polar formulations (rectangular CI, mixed CPB) always build fresh: their state dimension depends -on the bus-type partition, which changes across Q-limit retries and time steps.""" +Non-polar formulations (rectangular CI, mixed CPB) always build a fresh residual and Jacobian: their +state dimension depends on the bus-type partition, which changes across Q-limit retries and time +steps. The linear-solver factorization and state-vector buffers, held in `RectMixedNRCache`, are +reused when the rebuilt Jacobian's sparsity pattern matches (see `_get_or_build_rect_mixed_cache!`).""" function _newton_workspace!( pf::AbstractACPowerFlow, data::ACPowerFlowData, @@ -1158,20 +1260,29 @@ function _newton_workspace!( backend, tol::Float64, init_kwargs::NamedTuple, +) + return _fresh_newton_workspace(pf, data, time_step, backend, tol, init_kwargs) +end + +function _newton_workspace!( + pf::Union{ACRectangularPowerFlow, ACMixedPowerFlow}, + data::ACPowerFlowData, + time_step::Int64, + backend, + tol::Float64, + init_kwargs::NamedTuple, ) residual, J_deferred, x0_init = _nr_initialize_with_jacobian_deferred(pf, data, time_step; init_kwargs...) converged = norm(residual.Rv, Inf) < tol - if converged - return residual, J_deferred, x0_init, nothing, nothing, true - end + converged && return residual, J_deferred, x0_init, nothing, nothing, true J = _nr_build_jacobian(pf, residual, J_deferred, time_step) - linSolveCache = make_linear_solver_cache(backend, J.Jv) - symbolic_factor!(linSolveCache, J.Jv) - stateVector = StateVectorCache(x0_init, residual.Rv) + linSolveCache, stateVector = _get_or_build_rect_mixed_cache!( + data.solver_cache[], data, backend, J.Jv, x0_init, residual.Rv) return residual, J, x0_init, linSolveCache, stateVector, false end +# Dispatch on the slot content keeps the reuse path concretely inferred. function _newton_workspace!( pf::ACPolarPowerFlow, data::ACPowerFlowData, @@ -1180,63 +1291,92 @@ function _newton_workspace!( tol::Float64, init_kwargs::NamedTuple, ) - entry = data.polar_nr_cache[] - # A caller-provided x0 takes a different init path (skips improve_x0, warns) — do not reuse. + return _polar_newton_workspace!( + data.polar_nr_cache[], pf, data, time_step, backend, tol, init_kwargs) +end + +# No cache yet (or the previous entry was invalidated on the last call): build fresh and, unless +# the initial point already converged (matching the historical lazy build), store it for reuse. +function _polar_newton_workspace!( + ::Nothing, + pf::ACPolarPowerFlow, + data::ACPowerFlowData, + time_step::Int64, + backend, + tol::Float64, + init_kwargs::NamedTuple, +) + residual, J, x0_init, linSolveCache, stateVector, converged = + _fresh_newton_workspace(pf, data, time_step, backend, tol, init_kwargs) + data.polar_nr_cache[] = if converged + nothing + else + PolarNRCache( + residual, J, linSolveCache, stateVector, backend, + copy(view(data.bus_type, :, time_step))) + end + return residual, J, x0_init, linSolveCache, stateVector, converged +end + +# A cache entry is present: try to reuse it, falling back to a fresh build (dispatching back to +# the `::Nothing` method) on any invalidation — caller-provided x0, a different backend, or a +# structural change `_refresh_polar_residual!` can't absorb in place. +function _polar_newton_workspace!( + entry::PolarNRCache, + pf::ACPolarPowerFlow, + data::ACPowerFlowData, + time_step::Int64, + backend, + tol::Float64, + init_kwargs::NamedTuple, +) can_reuse = - entry isa PolarNRCache && typeof(entry.backend) === typeof(backend) && !haskey(init_kwargs, :x0) && - _refresh_polar_residual!(entry.residual, time_step) - if can_reuse - residual = entry.residual - J = entry.J - # Re-run the value paths exactly as a fresh init would: improve_x0 (which re-evaluates the - # residual at x0 with identical logging) then the full Jacobian fill. The caller-provided-x0 - # path is excluded by `can_reuse`, so this always takes the improve_x0 branch. - x0_init = improve_x0(pf, data, residual, time_step) - _log_initial_residual(residual) - if get(init_kwargs, :validate_voltage_magnitudes, DEFAULT_VALIDATE_VOLTAGES) - validate_voltage_magnitudes( - x0_init, - residual.validate_indices, - get(init_kwargs, :vm_validation_range, DEFAULT_VALIDATION_RANGE), - 0, - ) - end - converged = norm(residual.Rv, Inf) < tol - # Defer the Jacobian fill past the convergence check: a 0-iteration warm start must not - # pay for it. `nothing` lets the caller rebuild only if it actually needs J. - converged && return residual, nothing, x0_init, nothing, nothing, true - J(time_step) - # Reuse the linear-solver cache (symbolic factorization holds: pattern is bus-type-agnostic) - # and the state-vector buffers; refresh only the per-solve values. - linSolveCache = entry.linSolveCache - stateVector = entry.stateVector - copyto!(stateVector.x, x0_init) - copyto!(stateVector.r, residual.Rv) - # Reset buffers a fresh StateVectorCache would start at, so the reused solve is bit-identical: - # `d` (TR autoscale recomputes it, but NR leaves it untouched) and the singular-Jacobian - # fallback (rebuilt on demand otherwise, but starts empty on a fresh cache). - fill!(stateVector.d, 1.0) - stateVector.fallback_cache[] = nothing - stateVector.fallback_matrix[] = nothing - return residual, J, x0_init, linSolveCache, stateVector, false - end + _refresh_polar_residual!(entry, time_step) + can_reuse || + return _polar_newton_workspace!( + nothing, + pf, + data, + time_step, + backend, + tol, + init_kwargs, + ) - residual, J_deferred, x0_init = - _nr_initialize_with_jacobian_deferred(pf, data, time_step; init_kwargs...) - converged = norm(residual.Rv, Inf) < tol - if converged - # Already converged at the initial point: no solver cache is built (matching the lazy - # path), so clear any stale entry rather than caching an unused workspace. - data.polar_nr_cache[] = nothing - return residual, J_deferred, x0_init, nothing, nothing, true + residual = entry.residual + J = entry.J + # Re-run the value paths exactly as a fresh init would: improve_x0 (which re-evaluates the + # residual at x0 with identical logging) then the full Jacobian fill. The caller-provided-x0 + # path is excluded by `can_reuse`, so this always takes the improve_x0 branch. + x0_init = improve_x0(pf, data, residual, time_step) + _log_initial_residual(residual) + if get(init_kwargs, :validate_voltage_magnitudes, DEFAULT_VALIDATE_VOLTAGES) + validate_voltage_magnitudes( + x0_init, + residual.validate_indices, + get(init_kwargs, :vm_validation_range, DEFAULT_VALIDATION_RANGE), + 0, + ) end - J = _nr_build_jacobian(pf, residual, J_deferred, time_step) - linSolveCache = make_linear_solver_cache(backend, J.Jv) - symbolic_factor!(linSolveCache, J.Jv) - stateVector = StateVectorCache(x0_init, residual.Rv) - data.polar_nr_cache[] = PolarNRCache(residual, J, linSolveCache, stateVector, backend) + converged = norm(residual.Rv, Inf) < tol + # Defer the Jacobian fill past the convergence check: a 0-iteration warm start must not + # pay for it. `nothing` lets the caller rebuild only if it actually needs J. + converged && return residual, nothing, x0_init, nothing, nothing, true + J(time_step) + # Reuse the linear-solver cache (symbolic factorization holds: pattern is bus-type-agnostic) + # and the state-vector buffers; refresh only the per-solve values. + linSolveCache = entry.linSolveCache + stateVector = entry.stateVector + copyto!(stateVector.x, x0_init) + copyto!(stateVector.r, residual.Rv) + # Reset buffers a fresh StateVectorCache would start at, so the reused solve is bit-identical: + # `d` (TR autoscale recomputes it, but NR leaves it untouched) and the singular-Jacobian + # fallback (rebuilt on demand otherwise, but starts empty on a fresh cache). + fill!(stateVector.d, 1.0) + stateVector.fallback_cache[] = nothing + stateVector.fallback_matrix[] = nothing return residual, J, x0_init, linSolveCache, stateVector, false end diff --git a/src/power_flow_types.jl b/src/power_flow_types.jl index 90966296..ffbfb618 100644 --- a/src/power_flow_types.jl +++ b/src/power_flow_types.jl @@ -222,11 +222,14 @@ Works with both the polar ([`ACPolarPowerFlow`](@ref)) and rectangular current-injection ([`ACRectangularPowerFlow`](@ref)) formulations. Marquardt diagonal column scaling (`√λ·D` damping instead of `√λ·I`) can be -toggled via `SolutionParameters(; marquardt_scaling = true|false)`. When -unset it defaults **on** for [`ACRectangularPowerFlow`](@ref) — whose state -columns `(e, f, Q, P_gen)` are differently scaled, so identity damping is -ill-conditioned — and **off** for [`ACPolarPowerFlow`](@ref), leaving the polar -solver numerically unchanged. +toggled via the `marquardt_scaling` keyword on the formulation constructor +(e.g. `ACRectangularPowerFlow(; marquardt_scaling = true|false)`), which is +folded into the stored `SolutionParameters`. Left unset, each formulation +constructor resolves its own default via `_default_marquardt_scaling`: **on** +for [`ACRectangularPowerFlow`](@ref) — whose state columns `(e, f, Q, P_gen)` +are differently scaled, so identity damping is ill-conditioned — and **off** +for [`ACPolarPowerFlow`](@ref)/[`ACMixedPowerFlow`](@ref), leaving those +solvers numerically unchanged. See also: [`ACPowerFlow`](@ref). """ @@ -308,6 +311,21 @@ See also: [`ACPowerFlow`](@ref), [`NewtonRaphsonACPowerFlow`](@ref). """ struct FastDecoupledACPowerFlow{V <: FDVariant, S <: FDScheme} <: ACPowerFlowSolverType end +"""The `SolutionParameters` `maxIterations` default for `ACSolver`, resolved by the +formulation constructors when the caller leaves it at [`UNSET_MAX_ITERATIONS`](@ref).""" +_default_max_iterations(::Type{<:ACPowerFlowSolverType}) = DEFAULT_NR_MAX_ITER +_default_max_iterations(::Type{<:FastDecoupledACPowerFlow}) = DEFAULT_FD_MAX_ITER + +"""`params.maxIterations`, resolving `UNSET_MAX_ITERATIONS` to `ACSolver`'s default. An +explicit value is kept as-is.""" +function _resolved_max_iterations( + params::SolutionParameters, + ::Type{ACSolver}, +) where {ACSolver <: ACPowerFlowSolverType} + params.maxIterations == UNSET_MAX_ITERATIONS || return params.maxIterations + return _default_max_iterations(ACSolver) +end + """Alias for the classic decoupled fast power flow with the XB scheme, [`FastDecoupledACPowerFlow`](@ref)`{`[`FDDecoupled`](@ref)`, `[`FDSchemeXB`](@ref)`}`. Use as a solver type parameter, e.g. `ACPowerFlow{FastDecoupledXB}()`.""" @@ -372,8 +390,6 @@ with the specified solver type. `check_reactive_power_limits`, `enhanced_flat_start`, `control_discrete_devices`, `area_interchange_control`, `interchange_tolerance` and `tie_definition`, which remain accepted as keywords here and are folded into the stored parameters. -- `solver_settings::AbstractDict`: **Deprecated.** The untyped predecessor of - `solution_parameters`; entries naming a parameter are still applied. """ struct ACPolarPowerFlow{ACSolver <: ACPowerFlowSolverType} <: AbstractACPowerFlow{ACSolver} exporter::Union{Nothing, PowerFlowEvaluationModel} @@ -451,15 +467,14 @@ function ACPolarPowerFlow{ACSolver}(; area_interchange_control::Union{Nothing, Bool} = nothing, interchange_tolerance::Union{Nothing, Float64} = nothing, tie_definition::Union{Nothing, Symbol} = nothing, + marquardt_scaling::Union{Nothing, Bool} = nothing, solution_parameters::SolutionParameters = SolutionParameters(), - solver_settings::Union{Nothing, AbstractDict} = nothing, ) where {ACSolver <: ACPowerFlowSolverType} if calculate_loss_factors && ACSolver == LevenbergMarquardtACPowerFlow error("Loss factor calculation is not supported by the Levenberg-Marquardt solver.") end - params = _fold_legacy_parameters( - solution_parameters, - solver_settings; + params = _apply_legacy_kwargs( + solution_parameters; check_reactive_power_limits, enhanced_flat_start, control_discrete_devices, @@ -483,6 +498,10 @@ function ACPolarPowerFlow{ACSolver}(; params.interchange_tolerance, params.tie_definition, ), + marquardt_scaling = something( + marquardt_scaling, _default_marquardt_scaling(ACPolarPowerFlow), + ), + maxIterations = _resolved_max_iterations(params, ACSolver), ) return ACPolarPowerFlow{ACSolver}( exporter, @@ -598,7 +617,6 @@ polar state layout and have no current-injection equivalent. passing `true` throws `ArgumentError`. Default `false`. - `solution_parameters::SolutionParameters`: The solve parameters; see [`SolutionParameters`](@ref). -- `solver_settings::AbstractDict`: **Deprecated**, superseded by `solution_parameters`. """ struct ACRectangularPowerFlow{ACSolver <: ACPowerFlowSolverType} <: AbstractACPowerFlow{ACSolver} @@ -638,8 +656,8 @@ function ACRectangularPowerFlow{ACSolver}(; area_interchange_control::Union{Nothing, Bool} = nothing, interchange_tolerance::Union{Nothing, Float64} = nothing, tie_definition::Union{Nothing, Symbol} = nothing, + marquardt_scaling::Union{Nothing, Bool} = nothing, solution_parameters::SolutionParameters = SolutionParameters(), - solver_settings::Union{Nothing, AbstractDict} = nothing, ) where {ACSolver <: ACPowerFlowSolverType} if ACSolver <: Union{ RobustHomotopyPowerFlow, @@ -656,9 +674,8 @@ function ACRectangularPowerFlow{ACSolver}(; ) end _reject_fd_decoupled_on_nonpolar(ACSolver, "ACRectangularPowerFlow") - params = _fold_legacy_parameters( - solution_parameters, - solver_settings; + params = _apply_legacy_kwargs( + solution_parameters; check_reactive_power_limits, enhanced_flat_start, control_discrete_devices, @@ -676,6 +693,13 @@ function ACRectangularPowerFlow{ACSolver}(; time_steps, ) _validate_discrete_control_settings(params.control_discrete_devices, ACSolver) + params = _override( + params; + marquardt_scaling = something( + marquardt_scaling, _default_marquardt_scaling(ACRectangularPowerFlow), + ), + maxIterations = _resolved_max_iterations(params, ACSolver), + ) return ACRectangularPowerFlow{ACSolver}( exporter, generator_slack_participation_factors, @@ -733,7 +757,6 @@ polar state layout and have no mixed current-power equivalent. passing `true` throws `ArgumentError`. Default `false`. - `solution_parameters::SolutionParameters`: The solve parameters; see [`SolutionParameters`](@ref). -- `solver_settings::AbstractDict`: **Deprecated**, superseded by `solution_parameters`. """ struct ACMixedPowerFlow{ACSolver <: ACPowerFlowSolverType} <: AbstractACPowerFlow{ACSolver} @@ -773,8 +796,8 @@ function ACMixedPowerFlow{ACSolver}(; area_interchange_control::Union{Nothing, Bool} = nothing, interchange_tolerance::Union{Nothing, Float64} = nothing, tie_definition::Union{Nothing, Symbol} = nothing, + marquardt_scaling::Union{Nothing, Bool} = nothing, solution_parameters::SolutionParameters = SolutionParameters(), - solver_settings::Union{Nothing, AbstractDict} = nothing, ) where {ACSolver <: ACPowerFlowSolverType} if ACSolver <: Union{ RobustHomotopyPowerFlow, @@ -792,9 +815,8 @@ function ACMixedPowerFlow{ACSolver}(; ) end _reject_fd_decoupled_on_nonpolar(ACSolver, "ACMixedPowerFlow") - params = _fold_legacy_parameters( - solution_parameters, - solver_settings; + params = _apply_legacy_kwargs( + solution_parameters; check_reactive_power_limits, enhanced_flat_start, control_discrete_devices, @@ -812,6 +834,13 @@ function ACMixedPowerFlow{ACSolver}(; time_steps, ) _validate_discrete_control_settings(params.control_discrete_devices, ACSolver) + params = _override( + params; + marquardt_scaling = something( + marquardt_scaling, _default_marquardt_scaling(ACMixedPowerFlow), + ), + maxIterations = _resolved_max_iterations(params, ACSolver), + ) return ACMixedPowerFlow{ACSolver}( exporter, generator_slack_participation_factors, diff --git a/src/powersystems_utils.jl b/src/powersystems_utils.jl index 73439ef2..cb00b31c 100644 --- a/src/powersystems_utils.jl +++ b/src/powersystems_utils.jl @@ -102,12 +102,7 @@ set_power_flow!(br::PSY.TwoWindingTransformer, flow::Complex) = _set_circuit_power_flow!(PSY.get_circuit(br), flow) set_power_flow!(winding::PNM.ThreeWindingTransformerCircuit, flow::Complex) = - _set_circuit_power_flow!(_winding_circuit(winding), flow) - -# The wrapper's winding number indexes the parent's circuit tuple; flows, availability and the -# series parameters all live on that circuit. -_winding_circuit(winding::PNM.ThreeWindingTransformerCircuit) = - PSY.get_circuits(PNM.get_transformer(winding))[PNM.get_winding_number(winding)] + _set_circuit_power_flow!(PSY.get_circuit(winding), flow) function set_voltage!(bus::PSY.ACBus, V::Complex) PSY.set_magnitude!(bus, abs(V)) @@ -156,7 +151,7 @@ function error_if_reversed(hvdc::PSY.TwoTerminalLCCLine, P_dc::Float64) ) end -_eval_loss_function(curve::PSY.LinearCurve, x::Float64) = curve(x) +_eval_loss_function(curve::PSY.InputOutputCurve, x::Float64) = curve(x) _eval_loss_function(pwl::PSY.PiecewiseIncrementalCurve, x::Float64) = IS.InputOutputCurve(pwl)(x) diff --git a/src/psse_export.jl b/src/psse_export.jl index 7b285523..6cb4c27d 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -187,7 +187,7 @@ has no such section, so a v33 export warns and drops them. - `write_comments::Bool = false`: whether to add the customary-but-not-in-spec-annotations after a slash on the first line and at group boundaries - `name::AbstractString = "export"`: the base name of the export - - `step::Any = nothing`: optional step data to append to the base export name. User is + - `step = nothing`: optional step data to append to the base export name. User is responsible for updating the step data. If the step data is `nothing`, it is not used; if it is a tuple or vector, it is joined with \\_ and concatted; else it is concatted after \\_. @@ -205,6 +205,9 @@ mutable struct PSSEExporter <: SystemPowerFlowContainer name::String write_comments::Bool overwrite::Bool + # A label folded into the export directory name by `_step_to_string`: `nothing`, an + # iterable such as `(year, period)`, or a bare scalar. Untyped on purpose: POM dispatches + # on the invariant `PowerFlowEvaluationData{PSSEExporter}`, so the type takes no parameter. step::Any raw_buffer::IOBuffer # Persist an IOBuffer to reduce allocations on repeated exports md_dict::OrderedDict{String, Any} # Persist metadata to avoid unnecessary recomputation @@ -223,7 +226,7 @@ mutable struct PSSEExporter <: SystemPowerFlowContainer name::AbstractString = PSSE_DEFAULT_EXPORT_NAME, write_comments::Bool = false, overwrite::Bool = false, - step::Any = nothing, + step = nothing, ) (psse_version in PSSE_EXPORT_SUPPORTED_VERSIONS) || throw( @@ -345,8 +348,7 @@ function _attach_source_model!( ) end params = get_solution_parameters(pf) - isempty(solver_kwargs) || - (params = _override(params, Dict{Symbol, Any}(pairs(solver_kwargs)))) + isempty(solver_kwargs) || (params = _override(params; solver_kwargs...)) exporter.source_model = pf exporter.source_parameters = params return @@ -381,45 +383,10 @@ function update_exporter!(exporter::PSSEExporter, data::PSY.System) return end -get_data_array(buf::Base.GenericIOBuffer{<:Array{UInt8}}) = # < Julia 1.11 - buf.data - -(@isdefined GenericMemory) && ( # >= Julia 1.11 - get_data_array(buf::Base.GenericIOBuffer{<:GenericMemory{:not_atomic, UInt8}}) = - Base.wrap(Array, buf.data) -) - -const _FloatToBufSupportedTypes = if (@isdefined GenericMemory) - Union{ - Base.GenericIOBuffer{<:Array{UInt8}}, - Base.GenericIOBuffer{<:GenericMemory{:not_atomic, UInt8}}, - } -else - Base.GenericIOBuffer{<:Array{UInt8}} -end - -(IOBuffer <: _FloatToBufSupportedTypes) || - @warn "Fast Float64 to IOBuffer implementation is out of date, will not be used" - -"Temporary, very specialized proof of concept patch for https://github.com/JuliaLang/julia/issues/55835" -function better_float_to_buf(buf::_FloatToBufSupportedTypes, n::Float64) - Base.ensureroom(buf, Base.Ryu.neededdigits(Float64)) - # get_data_array incurs an allocation on Julia >= 1.11. I think writeshortest could work - # with the underlying Memory with minimal modification, which would be nice because - # other than this, better_float_to_buf is completely allocation free. - data_array = get_data_array(buf) - # RAW numerics are read into Fortran single precision, so write the shortest string - # that round-trips as Float32 (typed=false avoids the "f0" suffix Ryu appends for Float32). - new_pos = - Base.Ryu.writeshortest(data_array, buf.ptr, Float32(n), false, false, true, -1, - UInt8('e'), false, UInt8('.'), false, false) - buf.ptr = new_pos - buf.size = new_pos - 1 - return -end - fastprint(io::IO, val) = print(io, val) -fastprint(io::_FloatToBufSupportedTypes, val::Float64) = better_float_to_buf(io, val) +# RAW numerics are read into Fortran single precision, so print the shortest string that +# round-trips as Float32 rather than the full Float64 precision. +fastprint(io::IO, val::Float64) = print(io, Float32(val)) function fastprintdelim(io, val, delim = ", ") fastprint(io, val) @@ -505,7 +472,7 @@ Base.return_types(Base.Fix1(convert_empty, Vector{String})) # -> [Vector{String ``` """ convert_empty(::Type{T}, val) where {T} = isempty(val) ? T() : val::T -convert_empty_stringvec = Base.Fix1(convert_empty, Vector{String}) +const convert_empty_stringvec = Base.Fix1(convert_empty, Vector{String}) # PERF could be improved by appending to the buffer rather than doing string interpolation, seems unnecessary _psse_quote_string(s::String) = "'$s'" @@ -1042,15 +1009,17 @@ function _write_3w_transformer_record2!( io::IO, transformer::PSY.ThreeWindingTransformer, ) - R1_2 = PSY.get_r_12(transformer, PSY.SU) - X1_2 = PSY.get_x_12(transformer, PSY.SU) - SBASE1_2 = PSY.get_base_power_12(transformer) - R2_3 = PSY.get_r_23(transformer, PSY.SU) - X2_3 = PSY.get_x_23(transformer, PSY.SU) - SBAS2_3 = PSY.get_base_power_23(transformer) - R3_1 = PSY.get_r_31(transformer, PSY.SU) - X3_1 = PSY.get_x_31(transformer, PSY.SU) - SBAS3_1 = PSY.get_base_power_31(transformer) + # The pairwise fields are `nothing` for a transformer built from star-leg circuits; a + # blank field lets PSS/E apply its own default instead of printing `nothing`. + R1_2 = _value_or_default(PSY.get_r_12(transformer, PSY.SU), PSSE_DEFAULT) + X1_2 = _value_or_default(PSY.get_x_12(transformer, PSY.SU), PSSE_DEFAULT) + SBASE1_2 = _value_or_default(PSY.get_base_power_12(transformer), PSSE_DEFAULT) + R2_3 = _value_or_default(PSY.get_r_23(transformer, PSY.SU), PSSE_DEFAULT) + X2_3 = _value_or_default(PSY.get_x_23(transformer, PSY.SU), PSSE_DEFAULT) + SBAS2_3 = _value_or_default(PSY.get_base_power_23(transformer), PSSE_DEFAULT) + R3_1 = _value_or_default(PSY.get_r_31(transformer, PSY.SU), PSSE_DEFAULT) + X3_1 = _value_or_default(PSY.get_x_31(transformer, PSY.SU), PSSE_DEFAULT) + SBAS3_1 = _value_or_default(PSY.get_base_power_31(transformer), PSSE_DEFAULT) star_bus = PSY.get_star_bus(transformer) VMSTAR = PSY.get_magnitude(star_bus) ANSTAR = rad2deg(PSY.get_angle(star_bus)) @@ -1308,7 +1277,10 @@ function _compute_generator_powers( base_power::Float64, ) pg, qg = get_active_and_reactive_power_from_generator(generator, PSY.NU) - if hvdc_end == "TO" + # PF's own HVDC injection convention is negative at the FROM end (it withdraws from + # the AC network) and positive at TO; the synthetic generator's stored power is the + # same raw flow at both ends, so only FROM needs the flip. + if hvdc_end == "FR" pg = -pg end return pg, qg @@ -1332,7 +1304,8 @@ function _compute_active_power_limits( base_power::Float64, ) limits = get_active_power_limits_for_power_flow(generator, PSY.NU) - if hvdc_end == "TO" + # Mirrors the FROM-end sign flip in `_compute_generator_powers`. + if hvdc_end == "FR" return (min = -limits.max, max = -limits.min) end return limits @@ -1833,9 +1806,13 @@ function _write_discrete_branch_record!( RATEA = _value_or_default(PSY.get_rating(branch, PSY.NU), PSSE_DEFAULT) RATEB = 0.0 RATEC = 0.0 - RATEA = - RATEA >= INFINITE_BOUND ? 0.0 : - RATEA / PSY.get_base_power(exporter.system, PSY.NU) + # PFFP's switch/breaker importer stores RATE unscaled, so export divides by SBASE to + # round-trip. + if RATEA >= INFINITE_BOUND + RATEA = 0.0 + else + RATEA = RATEA / PSY.get_base_power(exporter.system, PSY.NU) + end @fastprintdelim_unroll(io, false, I, J, CKT, R, X, B, RATEA, RATEB, RATEC, GI, BI, @@ -1969,12 +1946,12 @@ function write_to_buffers!( X = PSY.get_x(branch, PSY.SU) RATE1 = _value_or_default(PSY.get_rating(branch, PSY.NU), PSSE_DEFAULT) - RATE1 = - if RATE1 >= INFINITE_BOUND - 0.0 - else - RATE1 / PSY.get_base_power(exporter.system, PSY.NU) - end + # See `_write_discrete_branch_record!`. + if RATE1 >= INFINITE_BOUND + RATE1 = 0.0 + else + RATE1 = RATE1 / PSY.get_base_power(exporter.system, PSY.NU) + end rates = [RATE1] # Using 0.0 as default for rating exporter, since PSSEv35 does not allow blank values @@ -2284,9 +2261,9 @@ function _compute_dcline_common_fields( NAME = _is_valid_psse_name(dcline_name) ? dcline_name : last(dcline_name, 12) NAME = _psse_quote_string(NAME) MDC = Int(PSY.get_power_mode(dcline)) - # PSS/E stores SETVL in MW, while the PSY value is in system-base per unit. - SETVL = - PSY.get_transfer_setpoint(dcline) * PSY.get_base_power(exporter.system, PSY.NU) + # PSY stores transfer_setpoint in the same units as SETVL (MW or A per power_mode; + # never per-unit), so it is written through unchanged. + SETVL = PSY.get_transfer_setpoint(dcline) VSCHD = PSY.get_scheduled_dc_voltage(dcline) # RDC is a DC-circuit resistance: PSY per-unitizes it against the DC base (VSCHD^2 / # baseMVA), not the rectifier AC commutating base, so the inverse conversion must use @@ -2473,11 +2450,12 @@ function _has_dc_voltage_reference(dc_control) end # DCSET for one converter. A droop terminal is exported as MW control, so its setpoint is the -# scheduled active-power demand (NOT the droop reference voltage, which the record cannot hold); -# the from terminal injects +P_flow into the DC line, the to terminal receives -P_flow. Setpoints -# are stored in system-base p.u.; scale back to PSS/E units — a DC-voltage setpoint by -# `rated_dc_voltage` (kV), a DC-power setpoint by `base_power` (MW). `rated_dc_voltage == 0` -# (unspecified) writes the DC-voltage setpoint through unchanged. +# scheduled active-power demand (NOT the droop reference voltage, which the record cannot hold). +# `dc_setpoint`'s convention is positive == supplies the AC network at that bus: from +# withdraws (negative), to supplies (positive). Setpoints are stored in system-base p.u.; +# scale back to PSS/E units — a DC-voltage setpoint by `rated_dc_voltage` (kV), a DC-power +# setpoint by `base_power` (MW). `rated_dc_voltage == 0` (unspecified) writes the DC-voltage +# setpoint through unchanged. function _vsc_export_dcset( vscline::PSY.TwoTerminalVSCLine, side::Symbol, @@ -2486,11 +2464,11 @@ function _vsc_export_dcset( if side == :from dc_control = PSY.get_dc_control_from(vscline) dc_setpoint = PSY.get_dc_setpoint_from(vscline) - flow_sign = 1.0 + flow_sign = -1.0 else dc_control = PSY.get_dc_control_to(vscline) dc_setpoint = PSY.get_dc_setpoint_to(vscline) - flow_sign = -1.0 + flow_sign = 1.0 end if dc_control == PSY.VSCDCControlModes.DC_VOLTAGE_DROOP return flow_sign * PSY.get_active_power_flow(vscline, PSY.SU) * base_power @@ -2889,13 +2867,8 @@ end # Total switched-shunt susceptance for BINIT: `solved_admittance` when the case was read in # as solved, else the currently engaged blocks. -_switched_shunt_binit(solved::Float64, ::Vector{Int}, ::Vector{Complex{Float64}}) = solved -_switched_shunt_binit( - ::Nothing, - engaged::Vector{Int}, - y_increase::Vector{Complex{Float64}}, -) = - imag(sum(engaged .* y_increase; init = 0.0 + 0.0im)) +_switched_shunt_binit(solved, engaged::Vector{Int}, y_increase::Vector{Complex{Float64}}) = + imag(_switched_admittance(solved, engaged, y_increase)) """Build v35 switched shunt step data (S, N, B triplets padded to 8).""" function _build_switched_shunt_steps_v35( diff --git a/src/psse_solution_records.jl b/src/psse_solution_records.jl index 93f96d34..7f3165f5 100644 --- a/src/psse_solution_records.jl +++ b/src/psse_solution_records.jl @@ -142,7 +142,6 @@ function solution_record_values( base_power::Float64, ) solver = _solver_type(pf) - fd = _is_fast_decoupled(solver) step_control = _solution_record_step_control(solver, params) # A discrete-control solve moves both tap changers and switched shunts; PowerFlows has @@ -169,11 +168,9 @@ function solution_record_values( varlim = -1 end - if fd - iterations = something(params.maxIterations, DEFAULT_FD_MAX_ITER) - else - iterations = something(params.maxIterations, DEFAULT_NR_MAX_ITER) - end + # `params.maxIterations` is already resolved to the solver's default by the model + # constructor (see `_default_max_iterations`), so no branch is needed here. + iterations = params.maxIterations if params.enhanced_flat_start flatst = 1 @@ -319,9 +316,12 @@ function _ends_solution_block(line::AbstractString) return !isnothing(tryparse(Int, token)) end -# Records, in order, with the `@!` column-header comments and blank lines dropped. A v35 -# export leads with a `@!IC,SBASE,REV,...` header, so the case identification record is not -# necessarily the first line of the file. +# Records, with the `@!` column-header comments and blank lines dropped. A v35 export leads +# with a `@!IC,SBASE,REV,...` header, so the case identification record is not necessarily +# the first line of the file. Used only for the solution-record block itself: the two title +# records ahead of it are skipped by fixed position (see `_case_header_and_block`), not by +# this filter, because PSS/E writes them even when blank, and a non-blank second title line +# must not be mistaken for the block's first record. function _significant_records(lines) return [ line for line in map(strip, lines) @@ -329,6 +329,19 @@ function _significant_records(lines) ] end +# The case identification record and the significant records of the block that follows the +# two title records, or `nothing` when the file has no case identification record at all. +function _case_header_and_block(lines::Vector{<:AbstractString}) + header_ix = findfirst( + line -> !isempty(strip(line)) && !startswith(strip(line), "@!"), + lines, + ) + isnothing(header_ix) && return nothing + block_start = header_ix + 3 + block_start <= length(lines) + 1 || return nothing + return (strip(lines[header_ix]), _significant_records(lines[block_start:end])) +end + """ read_solution_records(path) -> Union{Nothing, SolutionRecordValues} @@ -340,24 +353,23 @@ Unrecognized records and unrecognized field names are ignored, so a case written newer PSS/E than this mapping knows about still reads. """ function read_solution_records(path::AbstractString) - return _read_solution_records(_significant_records(readlines(path))) + header_and_block = _case_header_and_block(readlines(path)) + isnothing(header_and_block) && return nothing + return _read_solution_records(header_and_block...) end -# Core of `read_solution_records`, taking the already-split significant records so a -# caller that also needs another field of the file (e.g. the base power) can read it once. -function _read_solution_records(records::Vector{<:AbstractString}) - length(records) >= 3 || return nothing - - # Case identification record 1, field 3, is the format revision. - header = _split_record(records[1]) - length(header) >= 3 || return nothing - revision = tryparse(Int, strip(first(split(strip(header[3]), '/')))) +# Core of `read_solution_records`, taking the header record and the already-split +# significant block records so a caller that also needs another field of the file (e.g. the +# base power) can read it once. +function _read_solution_records(header::AbstractString, block::Vector{<:AbstractString}) + fields = _split_record(header) + length(fields) >= 3 || return nothing + revision = tryparse(Int, strip(first(split(strip(fields[3]), '/')))) (isnothing(revision) || revision < 35) && return nothing values = SolutionRecordValues() found = false - # Record 2 is the case name; the block starts after it. - for stripped in records[3:end] + for stripped in block _ends_solution_block(stripped) && break fields = _split_record(stripped) @@ -416,8 +428,7 @@ function _read_solution_records(records::Vector{<:AbstractString}) end # Copy-with-overrides, so each record's parse only has to name the fields it sets. -SolutionRecordValues(base::SolutionRecordValues; kwargs...) = - _override(base, Dict{Symbol, Any}(kwargs)) +SolutionRecordValues(base::SolutionRecordValues; kwargs...) = _override(base; kwargs...) """ solution_parameters(values::SolutionRecordValues, base_power) -> SolutionParameters @@ -426,7 +437,11 @@ Map solution records back onto PowerFlows parameters. `base_power` is the system MVA and converts the record's MW/MVAr mismatch back to a per-unit tolerance. Record fields with no PowerFlows counterpart are dropped; PowerFlows parameters the format -cannot express keep their defaults. +cannot express keep their defaults. This includes `enhanced_flat_start`: PSS/E's FLATST +means "always flat start," while PowerFlows' flag is a fallback used only when the starting +residual is large (default `true`), so the two are not the same setting — `values.flatst` +is parsed but never applied, and `enhanced_flat_start` keeps `SolutionParameters`' own +default. """ function solution_parameters(values::SolutionRecordValues, base_power::Float64) fd = uppercase(values.solver) == SOLUTION_RECORD_SOLVER_DECOUPLED @@ -442,7 +457,7 @@ function solution_parameters(values::SolutionRecordValues, base_power::Float64) if values.itmxn > 0 maxIterations = values.itmxn else - maxIterations = nothing + maxIterations = UNSET_MAX_ITERATIONS end # Tie-line-and-load interchange is not implemented and the model constructor rejects @@ -472,12 +487,26 @@ function solution_parameters(values::SolutionRecordValues, base_power::Float64) ) end + control_discrete_devices = values.actaps != 0 || values.swshnt != 0 + # Discrete control is a Newton/trust-region continuation; the model constructor + # rejects it under fast-decoupled solving, so a case that requests both is read with + # the control flag dropped rather than producing a `SolutionParameters` that fails to + # build a model. + if fd && control_discrete_devices + @warn( + "The solution records request discrete-device control (ACTAPS/SWSHNT) under " * + "fast-decoupled solving (FDNS); PowerFlows does not support discrete control " * + "as a fast-decoupled continuation, so it is being dropped.", + maxlog = 1, + ) + control_discrete_devices = false + end + return SolutionParameters(; tol = tol, maxIterations = maxIterations, check_reactive_power_limits = values.varlim >= 0, - enhanced_flat_start = values.flatst == 1, - control_discrete_devices = values.actaps != 0 || values.swshnt != 0, + control_discrete_devices = control_discrete_devices, area_interchange_control = values.areain != 0, tie_definition = tie_definition, fd_step_control..., @@ -509,21 +538,29 @@ function read_solution_parameters( path::AbstractString; base_power::Union{Nothing, Real} = nothing, ) - records = _significant_records(readlines(path)) - values = _read_solution_records(records) + header_and_block = _case_header_and_block(readlines(path)) + isnothing(header_and_block) && return nothing + header, block = header_and_block + values = _read_solution_records(header, block) isnothing(values) && return nothing if isnothing(base_power) - sbase = _case_base_power(records) + sbase = _case_base_power(header) else sbase = Float64(base_power) end return solution_parameters(values, sbase) end -# Field 2 of the case identification record is the system base in MVA. -function _case_base_power(records::Vector{<:AbstractString}) - isempty(records) && return 100.0 - fields = _split_record(records[1]) - length(fields) >= 2 || return 100.0 - return something(tryparse(Float64, strip(fields[2])), 100.0) +# Field 2 of the case identification record is the system base in MVA. Reached only once a +# solution-record block has been found, so the header is a case identification record by +# construction; a missing or unparseable SBASE field means the file is malformed, not that +# 100 MVA is a safe guess. +function _case_base_power(header::AbstractString) + fields = _split_record(header) + length(fields) >= 2 || + error("Case identification record has no SBASE field: \"$header\"") + sbase = tryparse(Float64, strip(fields[2])) + isnothing(sbase) && + error("Case identification record SBASE field is not a number: \"$header\"") + return sbase end diff --git a/src/rectangular_ci_power_flow_jacobian.jl b/src/rectangular_ci_power_flow_jacobian.jl index a23bcc59..392170a9 100644 --- a/src/rectangular_ci_power_flow_jacobian.jl +++ b/src/rectangular_ci_power_flow_jacobian.jl @@ -20,8 +20,8 @@ than `O((N + n_LCC) · log(nnz_per_col))` of `Jv[r, c] = v` setindex. `slack_bus_k` / `slack_c_k` for the corresponding per-iteration data - LCC tail nzval cache `lcc_nz` (24×n_lccs; the last 2 identity diagonals stay 1.0) """ -struct ACRectangularCIJacobian - data::ACPowerFlowData +struct ACRectangularCIJacobian{D <: ACPowerFlowData} + data::D Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} Y_diag::Vector{ComplexF64} # cached Y_bus_eff diagonal; avoids O(log nnz) sparse access per iteration diff --git a/src/rectangular_ci_power_flow_residual.jl b/src/rectangular_ci_power_flow_residual.jl index b4d47a0a..8d7bfb51 100644 --- a/src/rectangular_ci_power_flow_residual.jl +++ b/src/rectangular_ci_power_flow_residual.jl @@ -26,8 +26,8 @@ PV blocks are 3 entries `(e, f, Q)`. - `validate_offsets::Vector{Int}` — precomputed `x`-offsets of PQ/PV buses for the per-iteration voltage-magnitude diagnostic """ -struct ACRectangularCIResidual - data::ACPowerFlowData +struct ACRectangularCIResidual{D <: ACPowerFlowData} + data::D Rv::Vector{Float64} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} P_net_const::Vector{Float64} diff --git a/src/rectangular_ci_setup.jl b/src/rectangular_ci_setup.jl index bf47804d..482eb729 100644 --- a/src/rectangular_ci_setup.jl +++ b/src/rectangular_ci_setup.jl @@ -262,7 +262,8 @@ function rect_finalize_bus_injections!( # REF slots are net of constant power only (cp): the residual # subtracts `const_I * V_set` from them itself. data.bus_active_power_injections[bus_k, time_step] = - P_net_cp + data.bus_active_power_withdrawals[bus_k, time_step] + P_net_cp + data.bus_active_power_withdrawals[bus_k, time_step] - + data.bus_hvdc_net_power[bus_k, time_step] data.bus_reactive_power_injections[bus_k, time_step] = Q_net_cp + data.bus_reactive_power_withdrawals[bus_k, time_step] elseif bt == PSY.ACBusTypes.PV @@ -271,13 +272,12 @@ function rect_finalize_bus_injections!( P_eff = P_net_set[bus_k] + c_k * P_slack_total Q_eff = x[off + 2] data.bus_active_power_injections[bus_k, time_step] = - P_eff + get_bus_active_power_non_impedance_withdrawals( - data, bus_k, time_step, - ) + P_eff + + get_bus_active_power_non_impedance_withdrawals(data, bus_k, time_step) - + data.bus_hvdc_net_power[bus_k, time_step] data.bus_reactive_power_injections[bus_k, time_step] = - Q_eff + get_bus_reactive_power_non_impedance_withdrawals( - data, bus_k, time_step, - ) + Q_eff + + get_bus_reactive_power_non_impedance_withdrawals(data, bus_k, time_step) end end end diff --git a/src/residual_condition_diagnostics.jl b/src/residual_condition_diagnostics.jl index dd412988..d4f51349 100644 --- a/src/residual_condition_diagnostics.jl +++ b/src/residual_condition_diagnostics.jl @@ -303,13 +303,13 @@ function _decide_det_sign_switch!( continue end n_finite += 1 - vote = _bordering_flipped(mon, g, k) - isnothing(vote) && continue - n_voting += 1 - n_flipped += vote + if _bordering_has_vote(mon, g, k) + n_voting += 1 + n_flipped += _bordering_flipped(mon, g, k) + end end - if n_finite == 0 + if iszero(n_finite) @warn "$label: every fold-monitor bordering is degenerate; read as a " * "fold$(bail ? ", aborting." : ".")" return bail @@ -324,29 +324,32 @@ function _decide_det_sign_switch!( for k in eachindex(gs) g = gs[k] isfinite(g) || continue - if split && _bordering_flipped(mon, g, k) === true + if split && _bordering_has_vote(mon, g, k) && _bordering_flipped(mon, g, k) _handle_border_pole!(mon, label, k) elseif !iszero(sign(g)) mon.signs[k] = Int8(sign(g)) end end - (split || n_flipped == 0) && return false + (split || iszero(n_flipped)) && return false @warn "$label: sign(det J) flipped on all $(n_voting) borderings. Fold / " * "voltage-collapse signature$(bail ? ", aborting." : ".")" return bail end -"""Did bordering `k` flip sign this iteration? `nothing` when it has no vote to cast: -no previous sign yet (fresh or just re-picked), or an exactly zero `g`, which holds -the previous sign rather than replacing it. Reads `mon.signs` without writing, so it -answers the same before and after the verdict — which is why the signs are committed -in a second pass.""" -function _bordering_flipped(mon::BorderedFoldMonitor, g::Float64, k::Int) - current = Int8(sign(g)) - (iszero(current) || iszero(mon.signs[k])) && return nothing - return current != mon.signs[k] +"""Does bordering `k` have a previous sign to vote with this iteration? `false` when it has none +yet (fresh or just re-picked), or `g` is exactly zero, which holds the previous sign rather than +casting a vote. Reads `mon.signs` without writing, so it answers the same before and after the +verdict — which is why the signs are committed in a second pass.""" +function _bordering_has_vote(mon::BorderedFoldMonitor, g::Float64, k::Int)::Bool + return !iszero(sign(g)) && !iszero(mon.signs[k]) +end + +"""Did bordering `k` flip sign this iteration versus its last recorded sign? Only meaningful +when [`_bordering_has_vote`](@ref) is `true` for the same `(g, k)`.""" +function _bordering_flipped(mon::BorderedFoldMonitor, g::Float64, k::Int)::Bool + return Int8(sign(g)) != mon.signs[k] end """Handle a degenerate bordering: `det M` — not `J` — went singular. Re-pick slot `k`; @@ -479,7 +482,16 @@ function run_solver_diagnostics!( end """`+`/`−` for the monitor's sign, or `n/a` when `g` is unavailable.""" -_fmt_det_sign(g::Float64) = !isfinite(g) ? "n/a" : (g > 0 ? "+" : (g < 0 ? "−" : "0")) +function _fmt_det_sign(g::Float64) + isfinite(g) || return "n/a" + if g > 0 + return "+" + elseif g < 0 + return "−" + else + return "0" + end +end """ _report_area_interchange_failure(data, time_step) diff --git a/src/solution_parameters.jl b/src/solution_parameters.jl index 020552cb..fbc81143 100644 --- a/src/solution_parameters.jl +++ b/src/solution_parameters.jl @@ -17,15 +17,22 @@ pf = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; ) ``` -Every field name matches the keyword the corresponding solver already accepts, so a -parameter may also be overridden per call — `solve_power_flow!(data; tol = 1e-8)` wins -over the stored value for that solve only. +Every solver-facing field name matches the keyword the corresponding solver already +accepts, so a parameter may also be overridden per call — `solve_power_flow!(data; tol = +1e-8)` wins over the stored value for that solve only. The network-control fields +(`check_reactive_power_limits`, `enhanced_flat_start`, `control_discrete_devices`, +`area_interchange_control`, `interchange_tolerance`, `tie_definition`, +`model_dc_network`) are read from the stored parameters, not from a per-call keyword, +because most of them shape `PowerFlowData` at construction time — a keyword passed to +`solve_power_flow!` after that has nothing left to change. `check_reactive_power_limits` +is the one exception: it is re-read on every Q-limit retry, so a per-call override does +take effect. # Convergence - `tol::Float64`: convergence threshold on the ∞-norm of the per-unit mismatch. -- `maxIterations::Union{Nothing, Int}`: iteration cap. `nothing` (the default) leaves each - solver on its own default — `DEFAULT_NR_MAX_ITER` for Newton-type solvers, - `DEFAULT_FD_MAX_ITER` for fast decoupled. +- `maxIterations::Int`: iteration cap. Left unset, a formulation constructor (e.g. + [`ACPolarPowerFlow`](@ref)) resolves it to the solver's own default — + `DEFAULT_NR_MAX_ITER` for Newton-type solvers, `DEFAULT_FD_MAX_ITER` for fast decoupled. # Network controls - `check_reactive_power_limits::Bool`: enforce generator reactive limits by switching PV @@ -47,10 +54,13 @@ over the stored value for that solve only. # Newton / trust region / Levenberg-Marquardt - `refinement_threshold`, `refinement_eps`, `iwamoto`, `stop_at_fold`. - `factor`, `eta`, `autoscale`, `iwamoto_fallback`. -- `λ_0`, `marquardt_scaling` (`nothing` selects the per-formulation default). +- `λ_0`, `marquardt_scaling::Bool`: Marquardt diagonal column scaling. The formulation + constructor (e.g. [`ACRectangularPowerFlow`](@ref)) resolves its own default + (`true` for rectangular + LM, `false` elsewhere) unless a `marquardt_scaling` + keyword is given explicitly there. # Fast decoupled -- `handoff_solver` (`nothing` for pure FD), `handoff_tol`, `refreeze_on_stall`, +- `handoff_solver` (`NoHandoff` for pure FD), `handoff_tol`, `refreeze_on_stall`, `fd_non_divergent`, `fd_blowup`, `fd_dvlim`, `fd_vm_abort`, `fd_ndvfct`, `fd_max_step_halvings`. @@ -61,16 +71,25 @@ over the stored value for that solve only. - `learning_rate`, `beta1`, `beta2`, `epsilon`. # Backend -- `linear_solver`: name of the sparse linear-solver backend, or `nothing` to take the - `PowerNetworkMatrices` preference default. +- `linear_solver::String`: name of the sparse linear-solver backend. Defaults to the + `PowerNetworkMatrices` preference default, resolved once at construction. Per-call data (`x0`) is not a parameter and is not carried here — pass it at the call site. """ + +"""Sentinel [`ACPowerFlowSolverType`](@ref)-shaped marker for "no fast-decoupled handoff +solver configured" — the [`SolutionParameters`](@ref) `handoff_solver` default. A concrete +singleton type (not `nothing`) keeps the field concretely typed; FD dispatches on the value +(`_fd_maybe_handoff!(::Type{NoHandoff}, …)` vs. the solver-type method) instead of an +`isnothing` check.""" +struct NoHandoff end + Base.@kwdef struct SolutionParameters - # `maxIterations === nothing` keeps each solver's own default: 50 for Newton-type - # solvers, 150 for fast decoupled. tol::Float64 = DEFAULT_NR_TOL - maxIterations::Union{Nothing, Int} = nothing + # `UNSET_MAX_ITERATIONS` keeps each solver's own default: 50 for Newton-type solvers, + # 150 for fast decoupled. A formulation constructor resolves it via + # `_default_max_iterations`; a concrete `Int` (not `nothing`) keeps the field stable. + maxIterations::Int = UNSET_MAX_ITERATIONS # Read through the `get_*` accessors — never splatted into a solver call. check_reactive_power_limits::Bool = false @@ -94,15 +113,14 @@ Base.@kwdef struct SolutionParameters autoscale::Bool = DEFAULT_AUTOSCALE iwamoto_fallback::Bool = DEFAULT_IWAMOTO_FALLBACK - # `marquardt_scaling === nothing` selects the per-formulation default (off for polar, - # on for rectangular). λ_0::Float64 = DEFAULT_λ_0 - marquardt_scaling::Union{Bool, Nothing} = nothing + marquardt_scaling::Bool = false # `handoff_solver` is typed as `DataType`, not `ACPowerFlowSolverType`, because that # type is defined after this file in the include order; `_validate_fd_handoff_solver` - # checks the value anyway. - handoff_solver::Union{Nothing, DataType} = nothing + # checks the value anyway. Defaults to the `NoHandoff` sentinel (not `nothing`) so the + # field stays concrete. + handoff_solver::DataType = NoHandoff handoff_tol::Float64 = DEFAULT_FD_HANDOFF_TOL refreeze_on_stall::Bool = DEFAULT_FD_REFREEZE_ON_STALL fd_non_divergent::Bool = DEFAULT_FD_NON_DIVERGENT @@ -120,7 +138,7 @@ Base.@kwdef struct SolutionParameters beta2::Float64 = 0.999 epsilon::Float64 = 1e-8 - linear_solver::Union{Nothing, AbstractString} = nothing + linear_solver::String = PNM._default_linear_solver() end # Excluded from `get_solver_kwargs` so the kwargs surface a solver sees matches what it saw @@ -135,43 +153,52 @@ const _SOLUTION_PARAMETER_CONTROL_FIELDS = ( :model_dc_network, ) -const _SOLUTION_PARAMETER_SOLVER_FIELDS = Tuple( - name for name in fieldnames(SolutionParameters) - if !(name in _SOLUTION_PARAMETER_CONTROL_FIELDS) -) - -# `maxIterations` is the sentinel field: emitting `nothing` would override the solver's own -# default, so it's dropped when unset. Both field lists are precomputed to avoid rebuilding -# a tuple on every solve. -const _SOLUTION_PARAMETER_SOLVER_FIELDS_NO_ITER = Tuple( - name for name in _SOLUTION_PARAMETER_SOLVER_FIELDS if name !== :maxIterations -) - """ solver_kwargs(params::SolutionParameters) -> NamedTuple The solver-facing parameters as a `NamedTuple`, ready to splat into a solver call. -Network-control fields are excluded — those are read through their accessors — and -`maxIterations` is omitted when unset so each solver keeps its own default. +Network-control fields are excluded — those are read through their accessors. + +Field access is written out literally (not `map(getfield, names)`) so the return type +infers as a concrete `NamedTuple` rather than `Any`. """ function solver_kwargs(params::SolutionParameters) - names = if isnothing(params.maxIterations) - _SOLUTION_PARAMETER_SOLVER_FIELDS_NO_ITER - else - _SOLUTION_PARAMETER_SOLVER_FIELDS - end - return NamedTuple{names}(map(n -> getfield(params, n), names)) + return (; + tol = params.tol, + maxIterations = params.maxIterations, + validate_voltage_magnitudes = params.validate_voltage_magnitudes, + vm_validation_range = params.vm_validation_range, + refinement_threshold = params.refinement_threshold, + refinement_eps = params.refinement_eps, + iwamoto = params.iwamoto, + stop_at_fold = params.stop_at_fold, + factor = params.factor, + eta = params.eta, + autoscale = params.autoscale, + iwamoto_fallback = params.iwamoto_fallback, + λ_0 = params.λ_0, + marquardt_scaling = params.marquardt_scaling, + handoff_solver = params.handoff_solver, + handoff_tol = params.handoff_tol, + refreeze_on_stall = params.refreeze_on_stall, + fd_non_divergent = params.fd_non_divergent, + fd_blowup = params.fd_blowup, + fd_dvlim = params.fd_dvlim, + fd_vm_abort = params.fd_vm_abort, + fd_ndvfct = params.fd_ndvfct, + fd_max_step_halvings = params.fd_max_step_halvings, + Δt_k = params.Δt_k, + learning_rate = params.learning_rate, + beta1 = params.beta1, + beta2 = params.beta2, + epsilon = params.epsilon, + linear_solver = params.linear_solver, + ) end -""" - SolutionParameters(settings::AbstractDict) -> SolutionParameters - -Build a `SolutionParameters` from a legacy `solver_settings` dictionary. Keys that name a -field are applied to the defaults; any other key is dropped with a warning, since it would -previously have been splatted into a solver and silently ignored there. -""" -SolutionParameters(settings::AbstractDict) = - _override(SolutionParameters(), _settings_overrides(settings)) +# Guards against a new SolutionParameters field silently missing from the literal list above. +@assert Set(keys(solver_kwargs(SolutionParameters()))) == + Set(setdiff(fieldnames(SolutionParameters), _SOLUTION_PARAMETER_CONTROL_FIELDS)) """ _override(x, overrides::AbstractDict) -> typeof(x) @@ -196,38 +223,15 @@ end _override(x; kwargs...) = _override(x, Dict{Symbol, Any}(kwargs)) -function _settings_overrides(settings) - overrides = Dict{Symbol, Any}() - isnothing(settings) && return overrides - for (key, value) in settings - sym = Symbol(key) - if sym in fieldnames(SolutionParameters) - overrides[sym] = value - else - @warn( - "solver_settings key :$sym does not name a SolutionParameters field and " * - "was dropped. Pass it as a keyword to the solve call instead.", - maxlog = 1, - ) - end - end - return overrides -end - """ - _fold_legacy_parameters(params, solver_settings; legacy_kwargs...) -> SolutionParameters - -Merge the deprecated ways of specifying solve parameters into `params`, in increasing -order of precedence: `params` itself, then the `solver_settings` dictionary, then any -explicitly-passed legacy keyword (a `nothing` value means "not passed"). + _apply_legacy_kwargs(params; legacy_kwargs...) -> SolutionParameters -The named keywords (`check_reactive_power_limits`, `control_discrete_devices`, ...) remain -supported spellings and are not deprecated; only the untyped `solver_settings` dictionary -is, so only it raises a `depwarn`. +Fold the per-constructor control keywords (`check_reactive_power_limits`, +`control_discrete_devices`, ...) into `params`. These remain supported spellings — +a `nothing` value means "not passed", so the stored parameter is kept. """ -function _fold_legacy_parameters( - params::SolutionParameters, - solver_settings; +function _apply_legacy_kwargs( + params::SolutionParameters; check_reactive_power_limits::Union{Nothing, Bool} = nothing, enhanced_flat_start::Union{Nothing, Bool} = nothing, control_discrete_devices::Union{Nothing, Bool} = nothing, @@ -235,14 +239,6 @@ function _fold_legacy_parameters( interchange_tolerance::Union{Nothing, Float64} = nothing, tie_definition::Union{Nothing, Symbol} = nothing, ) - if !isnothing(solver_settings) - Base.depwarn( - "`solver_settings` is deprecated; pass " * - "`solution_parameters = SolutionParameters(...)` instead.", - :solver_settings, - ) - end - overrides = _settings_overrides(solver_settings) legacy = (; check_reactive_power_limits, enhanced_flat_start, @@ -251,6 +247,7 @@ function _fold_legacy_parameters( interchange_tolerance, tie_definition, ) + overrides = Dict{Symbol, Any}() for (name, value) in pairs(legacy) isnothing(value) || (overrides[name] = value) end diff --git a/src/solve_ac_power_flow.jl b/src/solve_ac_power_flow.jl index 05a8145f..1aec9385 100644 --- a/src/solve_ac_power_flow.jl +++ b/src/solve_ac_power_flow.jl @@ -74,17 +74,27 @@ function solve_and_store_power_flow!( return converged end -# Re-resolve a tap's circuit in `system` by name, rather than holding a reference, so the -# write lands in the caller's system even when it is not the one enrollment read. The tap may -# sit on either arity, and `PSY.get_circuits` covers both (a 2W returns a 1-tuple). -function _resolve_tap_circuit(system::PSY.System, d::ControlledTap) - tx = PSY.get_component(PSY.ACTransmission, system, d.device_name) - isnothing(tx) && return nothing - circuits = PSY.get_circuits(tx) - if d.circuit_index > length(circuits) - return nothing - end - return circuits[d.circuit_index] +# Re-resolve a tap's owning transformer in `system` by name, rather than holding a reference, +# so the write lands in the caller's system even when it is not the one enrollment read. +# Looked up under the concrete arity types, never abstract `PSY.ACTransmission`: a `Line` +# sharing the transformer's name would otherwise make the lookup ambiguous. +function _lookup_tap_transformer(system::PSY.System, name::String) + tx = PSY.get_component(PSY.TwoWindingTransformer, system, name) + isnothing(tx) || return tx + return PSY.get_component(PSY.ThreeWindingTransformer, system, name) +end + +# The tap may sit on either arity, and `PSY.get_circuits` covers both (a 2W returns a +# 1-tuple). Bool predicate + accessor, not a `nothing`-returning resolver. +function _has_tap_circuit(system::PSY.System, d::ControlledTap) + tx = _lookup_tap_transformer(system, d.device_name) + isnothing(tx) && return false + return d.circuit_index <= length(PSY.get_circuits(tx)) +end + +function _tap_circuit(system::PSY.System, d::ControlledTap) + tx = _lookup_tap_transformer(system, d.device_name) + return PSY.get_circuits(tx)[d.circuit_index] end """ @@ -110,14 +120,13 @@ function write_device_settings!(system::PSY.System, data) return end for d in set.taps - circuit = _resolve_tap_circuit(system, d) - if isnothing(circuit) + if !_has_tap_circuit(system, d) @warn "write_device_settings!: transformer \"$(d.device_name)\" not found in \ the system; the solved tap ratio $(d.current) for \"$(d.name)\" was NOT \ written back." continue end - PSY.set_tap!(circuit, d.current) + PSY.set_tap!(_tap_circuit(system, d), d.current) end for d in set.shunts sa = PSY.get_component(PSY.SwitchedAdmittance, system, d.name) @@ -259,6 +268,11 @@ function solve_power_flow!( tb_ix = [bus_lookup[bus_no] for bus_no in last.(arcs)] # to bus indices @assert length(fb_ix) == length(arcs) + # Per-step branch-flow buffers, allocated once and reused across time steps. + step_V = Vector{ComplexF64}(undef, length(data.bus_angles[:, 1])) + Sft = Vector{ComplexF64}(undef, length(arcs)) + Stf = Vector{ComplexF64}(undef, length(arcs)) + cd = get_controlled_devices(data) validate_device_store_width(cd, get_time_steps(data)) for (ts_pos, time_step) in enumerate(sorted_time_steps) @@ -303,14 +317,17 @@ function solve_power_flow!( end # Per-step branch flows (not batched after the loop) so a future per-step Yft/Ytf - # (e.g. varying tap positions) is used correctly. + # (e.g. varying tap positions) is used correctly. Buffers are preallocated above and + # reused in place across time steps. # NOTE PNM's structs use ComplexF32, while the system objects store Float64's. # so if you set the system bus angles/voltages to match these fields, then repeat # this math using the system voltages, you'll see differences in the flows, ~1e-4. - step_V = + @views step_V .= data.bus_magnitude[:, time_step] .* exp.(1im .* data.bus_angles[:, time_step]) - Sft = step_V[fb_ix] .* conj.(Yft.data * step_V) - Stf = step_V[tb_ix] .* conj.(Ytf.data * step_V) + mul!(Sft, Yft.data, step_V) + mul!(Stf, Ytf.data, step_V) + Sft .= view(step_V, fb_ix) .* conj.(Sft) + Stf .= view(step_V, tb_ix) .* conj.(Stf) data.arc_active_power_flow_from_to[:, time_step] .= real.(Sft) data.arc_reactive_power_flow_from_to[:, time_step] .= imag.(Sft) data.arc_active_power_flow_to_from[:, time_step] .= real.(Stf) @@ -335,7 +352,8 @@ function _solve_with_q_limits!( time_step::Int64; kwargs..., ) - check_reactive_power_limits = get_check_reactive_power_limits(pf) + check_reactive_power_limits = get( + kwargs, :check_reactive_power_limits, get_check_reactive_power_limits(pf)) converged = false for _ in 1:MAX_REACTIVE_POWER_ITERATIONS @@ -359,16 +377,39 @@ function _solve_with_q_limits!( return _newton_power_flow(pf, data, time_step; kwargs...) end +"""Dispatches on `data.controlled_devices`'s concrete type rather than branching on +`isnothing`/`isempty` in one method body, so the discrete-control continuation +(`_control_continuation!` and everything it pulls in) is only ever type-inferred and compiled +for a call that actually carries a `ControlledDeviceSet` — never for the plain (no discrete +control) solve, which is the common case and was paying 36-55% of first-solve compile time +for a code path it never takes.""" function _ac_power_flow( data::ACPowerFlowData, pf::AbstractACPowerFlow{<:ACPowerFlowSolverType}, time_step::Int64; kwargs..., ) - cd = data.controlled_devices - if isnothing(cd) || isempty(cd) - return _solve_with_q_limits!(pf, data, time_step; kwargs...) - end + return _ac_power_flow(data.controlled_devices, data, pf, time_step; kwargs...) +end + +function _ac_power_flow( + ::Nothing, + data::ACPowerFlowData, + pf::AbstractACPowerFlow{<:ACPowerFlowSolverType}, + time_step::Int64; + kwargs..., +) + return _solve_with_q_limits!(pf, data, time_step; kwargs...) +end + +function _ac_power_flow( + cd::ControlledDeviceSet, + data::ACPowerFlowData, + pf::AbstractACPowerFlow{<:ACPowerFlowSolverType}, + time_step::Int64; + kwargs..., +) + isempty(cd) && return _solve_with_q_limits!(pf, data, time_step; kwargs...) return _control_continuation!(pf, data, time_step; kwargs...) end diff --git a/src/solve_dc_power_flow.jl b/src/solve_dc_power_flow.jl index d4c0d4d0..0cf60c6c 100644 --- a/src/solve_dc_power_flow.jl +++ b/src/solve_dc_power_flow.jl @@ -34,39 +34,10 @@ struct DCSolverCache{M, B, C, S} <: SolverCache scratch::S end -# Reuse on a matching key, else `nothing` to signal a rebuild. Dispatch on the cached entry's type -# rather than an `isa`/sentinel check: an empty slot returns `nothing`; a stray non-DC `SolverCache` -# (cross-use with the AC path, impossible today since the data types are disjoint) is a loud -# `MethodError` instead of a silent mis-read. -_reuse_dc_cache(::Nothing, M, backend) = nothing -_reuse_dc_cache(e::DCSolverCache, M, backend) = - if (e.matrix === M && typeof(e.backend) === typeof(backend)) - (e.cache, e.scratch) - else - nothing - end - -# Reuse a cached factorization of `M` while the matrix object and backend are unchanged; -# rebuild otherwise. Assumes the network matrix is not mutated in place. -function _get_or_build_solver_cache!( - data::PowerFlowData, - backend, - M::SparseMatrixCSC{Float64}, -) - reused = _reuse_dc_cache(data.solver_cache[], M, backend) - isnothing(reused) || return reused - cache = make_linear_solver_cache(backend, M) - full_factor!(cache, M) - scratch = _make_dc_scratch(data) - data.solver_cache[] = DCSolverCache(M, backend, cache, scratch) - return cache, scratch -end - -# Per-solve scratch + network-fixed precomputes, built once with the cache. Parametrized -# on the arc-bus-incidence type `A` so `arc_bus_incidence` is concrete after the -# function-barrier dispatch, keeping the `mul!` SpMV statically dispatched (a plain -# NamedTuple left the field `Union{SparseMatrixCSC,Nothing}` → per-solve dynamic dispatch). -struct DCSolveScratch{A} +# Per-solve scratch + network-fixed precomputes, reused across time steps and repeated +# solves. Parametrized on `A` (arc-bus incidence) and `B` (`ba`, below) so both stay +# concrete after the function-barrier dispatch. +struct DCSolveScratch{A, B} power_injections::Matrix{Float64} p_inj::Matrix{Float64} rs::Vector{Float64} @@ -77,10 +48,34 @@ struct DCSolveScratch{A} # Pre-computed from/to bus indices for arc angle differences (avoids per-call allocation) fb_ix::Vector{Int} tb_ix::Vector{Int} + # PNM's `BA_Matrix.data` (bus × arc, susceptance-weighted incidence), PTDF only. Built + # from the same `Ybus` as the PTDF matrix so the axes match without a permutation. + # `Nothing` for ABA (uses its own `BA_Matrix` as `aux_network_matrix`) and vPTDF. + ba::B + # PTDF-only loss-factor buffers (arc × T and bus × T); zero-sized otherwise. + lf_rf::Matrix{Float64} + lf_rhs::Matrix{Float64} +end + +""" + DCScratchStage{S <: DCSolveScratch} <: SolverCache + +Scratch staged in `data.solver_cache[]` at construction, before a backend is chosen; the +first solve promotes it to a [`DCSolverCache`](@ref). Type-disjoint from `DCSolverCache` +and `FastDecoupledCache`, so the slot's concrete type drives dispatch with no sentinel tag. +""" +struct DCScratchStage{S <: DCSolveScratch} <: SolverCache + scratch::S end +# Loss-factor buffers for `_dc_ptdf_loss_factors!`: sized only when `ba` (PTDF) is present. +_loss_factor_buffers(::Nothing, n_arcs, n_buses, n_ts) = + (Matrix{Float64}(undef, 0, 0), Matrix{Float64}(undef, 0, 0)) +_loss_factor_buffers(::SparseMatrixCSC, n_arcs, n_buses, n_ts) = + (Matrix{Float64}(undef, n_arcs, n_ts), Matrix{Float64}(undef, n_buses, n_ts)) + """ - _make_dc_scratch(data::PowerFlowData) -> DCSolveScratch + _make_dc_scratch(data::PowerFlowData; ba = nothing) -> DCSolveScratch Build the `DCSolveScratch` a DC solve reuses across time steps: the injection work buffers, plus the topology-fixed precomputes (non-reference bus rows, per-arc from/to bus @@ -88,7 +83,10 @@ indices, per-arc equivalent resistances, and the arc-bus incidence). Built once matrix because everything but the work buffers depends only on the topology, not on the injections that change between steps. """ -function _make_dc_scratch(data::PowerFlowData) +function _make_dc_scratch( + data::PowerFlowData; + ba::Union{Nothing, SparseMatrixCSC{Float64, Int}} = nothing, +) n_buses = size(data.bus_active_power_injections, 1) valid_ix = collect(1:n_buses)[get_valid_ix(data)] # resolve Not(ref) → Vector{Int} n_ts = size(data.bus_active_power_injections, 2) @@ -97,6 +95,7 @@ function _make_dc_scratch(data::PowerFlowData) bus_lookup = get_bus_lookup(data) fb_ix = [bus_lookup[bus_no] for bus_no in first.(arcs)] tb_ix = [bus_lookup[bus_no] for bus_no in last.(arcs)] + lf_rf, lf_rhs = _loss_factor_buffers(ba, length(arcs), n_buses, n_ts) return DCSolveScratch( similar(data.bus_active_power_injections), Matrix{Float64}(undef, length(valid_ix), n_ts), @@ -105,9 +104,56 @@ function _make_dc_scratch(data::PowerFlowData) valid_ix, fb_ix, tb_ix, + ba, + lf_rf, + lf_rhs, ) end +# `aba_matrix.K` is always a KLU factorization of this exact matrix (PNM's own choice, +# regardless of the solve backend), so only the KLU backend can reuse it. +_dc_initial_cache(::PNM.KLUSolver, aba_matrix::PNM.ABA_Matrix) = aba_matrix.K +function _dc_initial_cache(backend, aba_matrix::PNM.ABA_Matrix) + M = aba_matrix.data + cache = make_linear_solver_cache(backend, M) + full_factor!(cache, M) + return cache +end + +# Dispatch on the solver-cache slot's concrete type and run the solve inside each arm, so +# no value read from the abstract `RefValue{Union{Nothing,SolverCache}}` slot crosses a +# return boundary as `Any`. `run!` is one of `_run_ptdf_solve!`/`_run_vptdf_solve!`/ +# `_run_aba_solve!`, passed as a plain (non-stored) function argument. +_dc_solve!(data, ::Nothing, backend, aba_matrix, run!::F) where {F} = + _dc_build_cache_and_solve!(data, backend, aba_matrix, _make_dc_scratch(data), run!) + +_dc_solve!(data, staged::DCScratchStage, backend, aba_matrix, run!::F) where {F} = + _dc_build_cache_and_solve!(data, backend, aba_matrix, staged.scratch, run!) + +function _dc_solve!(data, entry::DCSolverCache, backend, aba_matrix, run!::F) where {F} + M = aba_matrix.data + if entry.matrix === M && typeof(entry.backend) === typeof(backend) + run!(data, entry.cache, entry.scratch) + return nothing + end + # Matrix identity never actually changes for a given `data` (only the backend can), so + # the topology-fixed scratch is reused as-is; only the factorization is rebuilt. + return _dc_build_cache_and_solve!(data, backend, aba_matrix, entry.scratch, run!) +end + +function _dc_build_cache_and_solve!( + data, + backend, + aba_matrix, + scratch::DCSolveScratch, + run!::F, +) where {F} + cache = _dc_initial_cache(backend, aba_matrix) + data.solver_cache[] = DCSolverCache(aba_matrix.data, backend, cache, scratch) + run!(data, cache, scratch) + return nothing +end + _convert_to_range(ix::Integer) = ix:ix _convert_to_range(::Colon) = Colon() @@ -149,13 +195,6 @@ function _run_ptdf_solve!( data.bus_active_power_injections - data.bus_active_power_withdrawals power_injections .+= data.bus_hvdc_net_power power_injections .+= data.bus_phase_shift_injections - mul!( - data.arc_active_power_flow_from_to, - transpose(data.power_network_matrix.data), - power_injections, - ) - data.arc_active_power_flow_from_to .-= data.arc_phase_shift_flow_offsets - @. data.arc_active_power_flow_to_from = -data.arc_active_power_flow_from_to # HVDC flows stored separately and already calculated: see initialize_power_flow_data! valid_ix = scratch.valid_ix p_inj = scratch.p_inj @@ -163,16 +202,52 @@ function _run_ptdf_solve!( solve!(solver_cache, p_inj) @views data.bus_angles[valid_ix, :] .= p_inj _shift_angles_to_stored_reference!(data) + # f = BAᵀθ − offsets, from the already-solved angles. + mul!(data.arc_active_power_flow_from_to, transpose(scratch.ba), data.bus_angles) + data.arc_active_power_flow_from_to .-= data.arc_phase_shift_flow_offsets + @. data.arc_active_power_flow_to_from = -data.arc_active_power_flow_from_to mul!(data.arc_angle_differences, scratch.arc_bus_incidence, data.bus_angles) @. data.arc_active_power_losses = scratch.rs * data.arc_active_power_flow_from_to^2 data.converged .= true _adjust_dc_slack_injections!(data, power_injections) if get_calculate_loss_factors(data) - data.loss_factors .= dc_loss_factors(data, scratch.rs) + _dc_ptdf_loss_factors!( + data.loss_factors, + data.arc_active_power_flow_from_to, + solver_cache, + scratch, + ) end return end +""" + _dc_ptdf_loss_factors!(loss_factors, flow, solver_cache, scratch) + +In-place PTDF loss factors: `2 · ABA⁻¹_ext · (BA · (R∘f))` (ref-bus rows zero), solved +against `solver_cache` — the same factorization the angle solve already used. +`scratch.p_inj` is reused as the solve buffer; its θ-solve contents are no longer needed +by this point in `_run_ptdf_solve!`. +""" +function _dc_ptdf_loss_factors!( + loss_factors::Matrix{Float64}, + flow::Matrix{Float64}, + solver_cache::PFLinearSolverCache, + scratch::DCSolveScratch, +) + valid_ix = scratch.valid_ix + rf = scratch.lf_rf + @. rf = scratch.rs * flow + rhs = scratch.lf_rhs + mul!(rhs, scratch.ba, rf) + x = scratch.p_inj + @views x .= rhs[valid_ix, :] + solve!(solver_cache, x) + fill!(loss_factors, 0.0) + @views loss_factors[valid_ix, :] .= 2 .* x + return +end + function _run_vptdf_solve!( data::vPTDFPowerFlowData, solver_cache::PFLinearSolverCache, @@ -377,9 +452,13 @@ function solve_power_flow!( ) _distribute_dc_slack!(data) backend = resolve_linear_solver_backend(linear_solver) - solver_cache, scratch = - _get_or_build_solver_cache!(data, backend, data.aux_network_matrix.data) - _run_ptdf_solve!(data, solver_cache, scratch) + _dc_solve!( + data, + data.solver_cache[], + backend, + data.aux_network_matrix, + _run_ptdf_solve!, + ) return end @@ -403,9 +482,13 @@ function solve_power_flow!( ) _distribute_dc_slack!(data) backend = resolve_linear_solver_backend(linear_solver) - solver_cache, scratch = - _get_or_build_solver_cache!(data, backend, data.aux_network_matrix.data) - _run_vptdf_solve!(data, solver_cache, scratch) + _dc_solve!( + data, + data.solver_cache[], + backend, + data.aux_network_matrix, + _run_vptdf_solve!, + ) return end @@ -443,9 +526,13 @@ function solve_power_flow!( ) _distribute_dc_slack!(data) backend = resolve_linear_solver_backend(linear_solver) - solver_cache, scratch = - _get_or_build_solver_cache!(data, backend, data.power_network_matrix.data) - _run_aba_solve!(data, solver_cache, scratch) + _dc_solve!( + data, + data.solver_cache[], + backend, + data.power_network_matrix, + _run_aba_solve!, + ) return end @@ -609,14 +696,13 @@ function dc_loss_factors(data::vPTDFPowerFlowData, Rs::Vector{Float64}) n_buses = length(get_bus_axis(data)) n_ts = size(data.arc_active_power_flow_from_to, 2) result = zeros(n_buses, n_ts) - flows_k = Vector{Float64}(undef, n_ts) - # Single pass: fetch each PTDF row once, read the already-solved flow, then accumulate. + cache = PNM.get_ptdf_data(ptdf) + arc_lookup = PNM.get_arc_lookup(ptdf) for (k, arc) in enumerate(arc_ax) - row_k = ptdf[arc, :] + row_k = _ptdf_cached_row(ptdf, cache, arc_lookup, arc) r_k = Rs[k] - flows_k .= data.arc_active_power_flow_from_to[k, :] for t in 1:n_ts - @inbounds w = 2.0 * r_k * flows_k[t] + @inbounds w = 2.0 * r_k * data.arc_active_power_flow_from_to[k, t] @inbounds @simd for j in 1:n_buses result[j, t] += row_k[j] * w end diff --git a/test/PowerFlowsTests.jl b/test/PowerFlowsTests.jl deleted file mode 100644 index b62f64a8..00000000 --- a/test/PowerFlowsTests.jl +++ /dev/null @@ -1,144 +0,0 @@ -module PowerFlowsTests - -using ReTest -import Test # for Test.TestLogger (ReTest re-exports macros but not the module) -using PowerFlows -using Logging -using Dates -using PowerSystems -using PowerSystemCaseBuilder -# FIXME: what's the canonical way to load a system from PSS(e) raw under PSY6? -# or should we just switch to using a file format other than raw for those tests? -import PowerSystemCaseBuilder: make_system -const PFP = PowerSystemCaseBuilder.PowerFlowFileParser -using PowerNetworkMatrices -using InfrastructureSystems -using LinearAlgebra -using CSV -using DataFrames -using JSON3 -using InteractiveUtils -using DataStructures -import SparseArrays -import SparseArrays: SparseMatrixCSC, sparse, sprandn, sprand -import Random - -import Aqua -Aqua.test_unbound_args(PowerFlows) -Aqua.test_undefined_exports(PowerFlows) -Aqua.test_ambiguities(PowerFlows) -Aqua.test_stale_deps(PowerFlows) -Aqua.test_deps_compat(PowerFlows) - -import InfrastructureSystems as IS -import PowerSystemCaseBuilder as PSB -import PowerSystems as PSY -import PowerNetworkMatrices as PNM -import PowerFlows as PF - -# used to be public, no longer: import here so we can use in tests -import PowerFlows: PowerFlowData -import PowerFlows: ACPowerFlowData, PTDFPowerFlowData, vPTDFPowerFlowData, ABAPowerFlowData -import PowerFlows: solve_power_flow!, write_results - -const BASE_DIR = dirname(dirname(Base.find_package("PowerFlows"))) -const TEST_DATA_DIR = joinpath( - dirname(dirname(Base.find_package("PowerFlows"))), - "test", - "test_data", -) -const DIFF_INF_TOLERANCE = 1e-4 -const DIFF_L2_TOLERANCE = 1e-3 -const TIGHT_TOLERANCE = 1e-7 - -const LOG_FILE = "power-flows.log" - -# [include test utils here] -include("test_utils/common.jl") -include("test_utils/psse_results_compare.jl") -include("test_utils/penalty_factors_brute_force.jl") -include("test_utils/validate_reduced_power_flow.jl") -include("test_utils/jacobian_verification.jl") - -const AC_SOLVERS_TO_TEST = ( - NewtonRaphsonACPowerFlow, - TrustRegionACPowerFlow, - LevenbergMarquardtACPowerFlow, - RobustHomotopyPowerFlow, - FastDecoupledACPowerFlow, -) - -for filename in readdir(joinpath(BASE_DIR, "test")) - if startswith(filename, "test_") && endswith(filename, ".jl") - include(filename) - end -end - -function get_logging_level_from_env(env_name::String, default) - level = get(ENV, env_name, default) - return IS.get_logging_level(level) -end - -# Expected-@error allowlist for the stray-error gate: the area-interchange greedy-relax path -# logs an infeasible-schedule Error BY DESIGN (_ac_power_flow_with_area_relax!), and those -# already-@test_logs-asserted events still reach this global tracker under the full-suite -# ReTest schedule, so the gate must exclude exactly them. -const _AREA_RELAX_ERROR_MARKER = "Area interchange:" - -_is_area_relax_error(event) = occursin(_AREA_RELAX_ERROR_MARKER, event.message) - -"""Error-level log events the stray-error gate should fail on: everything except the -area-interchange greedy-relax sequence.""" -function unexpected_error_events(tracker) - events = IS.get_log_events(tracker, Logging.Error) - return [event for event in events if !_is_area_relax_error(event)] -end - -# See also `load_tests.jl` for running tests interactively with ReTest.jl -function run_tests(args...; kwargs...) - logger = global_logger() - try - logging_config_filename = get(ENV, "SIIP_LOGGING_CONFIG", nothing) - if logging_config_filename !== nothing - config = IS.LoggingConfiguration(logging_config_filename) - else - config = IS.LoggingConfiguration(; - filename = LOG_FILE, - file_level = get_logging_level_from_env("SIENNA_FILE_LOG_LEVEL", "Info"), - console_level = get_logging_level_from_env( - "SIENNA_CONSOLE_LOG_LEVEL", - "Error", - ), - ) - end - console_logger = Logging.ConsoleLogger(config.console_stream, config.console_level) - - IS.open_file_logger(config.filename, config.file_level) do file_logger - levels = (Logging.Info, Logging.Warn, Logging.Error) - multi_logger = - IS.MultiLogger([console_logger, file_logger], IS.LogEventTracker(levels)) - Logging.global_logger(multi_logger) - - if !isempty(config.group_levels) - IS.set_group_levels!(multi_logger, config.group_levels) - end - - @time retest(args...; kwargs...) - unexpected = unexpected_error_events(multi_logger.tracker) - # Name the offenders: a bare count gives no way to find which site tripped the gate. - for event in unexpected - @warn "Unexpected error-level log event" event.file event.line event.count event.message - end - @test isempty(unexpected) - @info IS.report_log_summary(multi_logger) - end - finally - # Guarantee that the global logger is reset. - global_logger(logger) - nothing - end -end - -export run_tests - -end diff --git a/test/Project.toml b/test/Project.toml index 59154777..5bf1f638 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -19,6 +19,7 @@ JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Pardiso = "46dd5b70-b6fb-5a00-ae2d-e8fea33afaf2" PowerFlowFileParser = "bed98974-b02e-5e2f-9ee0-a103f5c450dd" PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" @@ -27,7 +28,6 @@ PowerSystemCaseBuilder = "f00506e0-b84f-492a-93c2-c0a9afc4364e" PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" PowerTableDataParser = "2b750c0e-0bff-11f1-9200-1befd75df6be" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -ReTest = "e0db7c4e-2690-44b9-bad6-7687da720f89" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/includes.jl b/test/includes.jl new file mode 100644 index 00000000..7719e2b8 --- /dev/null +++ b/test/includes.jl @@ -0,0 +1,93 @@ +# Shared preamble evaluated into every test worker's sandbox module before the test file +# body runs. + +using Test +using Logging +using Dates +using Random +using LinearAlgebra +using PowerFlows +using PowerSystems +using PowerSystemCaseBuilder +import PowerSystemCaseBuilder: make_system +const PFP = PowerSystemCaseBuilder.PowerFlowFileParser +using PowerNetworkMatrices +using InfrastructureSystems +using CSV +using DataFrames +using JSON3 +using InteractiveUtils +using DataStructures +import SparseArrays +import SparseArrays: SparseMatrixCSC, sparse, sprandn, sprand +import Aqua + +import InfrastructureSystems as IS +import PowerSystemCaseBuilder as PSB +import PowerSystems as PSY +import PowerNetworkMatrices as PNM +import PowerFlows as PF + +# used to be public, no longer: import here so tests can use them +import PowerFlows: PowerFlowData +import PowerFlows: ACPowerFlowData, PTDFPowerFlowData, vPTDFPowerFlowData, ABAPowerFlowData +import PowerFlows: solve_power_flow!, write_results + +const BASE_DIR = dirname(dirname(Base.find_package("PowerFlows"))) +const TEST_DATA_DIR = joinpath(BASE_DIR, "test", "test_data") +const DIFF_INF_TOLERANCE = 1e-4 +const DIFF_L2_TOLERANCE = 1e-3 +const TIGHT_TOLERANCE = 1e-7 + +# Keep each worker's captured console output to real problems; the runner echoes it back. +Logging.global_logger(Logging.ConsoleLogger(stderr, Logging.Error)) + +include("test_utils/common.jl") +include("test_utils/psse_results_compare.jl") +include("test_utils/penalty_factors_brute_force.jl") +include("test_utils/validate_reduced_power_flow.jl") +include("test_utils/jacobian_verification.jl") +include("test_utils/cross_file_fixtures.jl") + +const AC_SOLVERS_TO_TEST = ( + NewtonRaphsonACPowerFlow, + TrustRegionACPowerFlow, + LevenbergMarquardtACPowerFlow, + RobustHomotopyPowerFlow, + FastDecoupledACPowerFlow, +) + +# Expected-@error allowlist for the stray-error gate: the area-interchange greedy-relax path +# logs an infeasible-schedule Error BY DESIGN (_ac_power_flow_with_area_relax!). +const _AREA_RELAX_ERROR_MARKER = "Area interchange:" + +_is_area_relax_error(event) = occursin(_AREA_RELAX_ERROR_MARKER, event.message) + +"Error-level log events the stray-error gate should fail on: everything except the +area-interchange greedy-relax sequence." +function unexpected_error_events(tracker) + events = IS.get_log_events(tracker, Logging.Error) + return [event for event in events if !_is_area_relax_error(event)] +end + +"Run `f` (one test file's `include`) under a log-event tracker and fail the enclosing +testset if it logs an unexpected Error-level event. Each worker runs exactly one test +file, so this is that file's stray-error gate; restores the previous global logger after." +function with_stray_error_gate(f::Function) + previous_logger = global_logger() + tracker = IS.LogEventTracker((Logging.Info, Logging.Warn, Logging.Error)) + console_logger = Logging.ConsoleLogger(stderr, Logging.Error) + multi_logger = IS.MultiLogger([console_logger], tracker) + Logging.global_logger(multi_logger) + try + f() + unexpected = unexpected_error_events(tracker) + for event in unexpected + @warn "Unexpected error-level log event" event.file event.line event.count event.message + end + @test isempty(unexpected) + finally + Logging.global_logger(previous_logger) + end + return +end diff --git a/test/load_tests.jl b/test/load_tests.jl deleted file mode 100644 index 3d949c9f..00000000 --- a/test/load_tests.jl +++ /dev/null @@ -1,32 +0,0 @@ -# copied from InfrastructureSystems.jl test/load_tests.jl -using Revise - -""" - recursive_includet(filename) - -Load tests for interactive use with `ReTest.jl`. Usage: - -```julia -using TestEnv -TestEnv.activate() -include("test/load_tests.jl") -using .PowerFlowsTests -run_tests() -``` - -See the InfrastructureSystems.jl documentation page -["Running Tests"](https://sienna-platform.github.io/InfrastructureSystems.jl/stable/dev_guide/tests/) -for more details. - -Copied from https://juliatesting.github.io/ReTest.jl/stable/#Working-with-Revise -""" -function recursive_includet(filename) - already_included = copy(Revise.included_files) - includet(filename) - newly_included = setdiff(Revise.included_files, already_included) - for (mod, file) in newly_included - Revise.track(mod, file) - end -end - -recursive_includet("PowerFlowsTests.jl") diff --git a/test/performance/fd_benchmark.jl b/test/performance/fd_benchmark.jl index a41fbeb9..30e6b451 100644 --- a/test/performance/fd_benchmark.jl +++ b/test/performance/fd_benchmark.jl @@ -46,7 +46,8 @@ end # Time a single solve, after one warm-up solve to remove compilation latency. function bench_single(solver, settings) - pf = ACPowerFlow{solver}(; correct_bustypes = true, solver_settings = settings) + pf = ACPowerFlow{solver}(; + correct_bustypes = true, solution_parameters = SolutionParameters(; settings...)) PF.solve_power_flow!(PF.PowerFlowData(pf, _build())) # warm-up (compile) data = PF.PowerFlowData(pf, _build()) return @elapsed PF.solve_power_flow!(data) @@ -56,7 +57,8 @@ end # time-step loop within `solve_power_flow!`). After one warm-up. function bench_multiperiod(solver, settings, steps) pf = ACPowerFlow{solver}(; - correct_bustypes = true, time_steps = steps, solver_settings = settings) + correct_bustypes = true, time_steps = steps, + solution_parameters = SolutionParameters(; settings...)) PF.solve_power_flow!(_replicate_first_step!(PF.PowerFlowData(pf, _build()), steps)) # warm-up data = _replicate_first_step!(PF.PowerFlowData(pf, _build()), steps) return @elapsed PF.solve_power_flow!(data) diff --git a/test/performance/performance_test.jl b/test/performance/performance_test.jl index e334ecee..398fa317 100644 --- a/test/performance/performance_test.jl +++ b/test/performance/performance_test.jl @@ -128,20 +128,23 @@ for (group, name) in systems sys = build_system(group, name) for (label, solver, settings) in polar_ac_solvers bench_ac!(name, label, - () -> - ACPowerFlow{solver}(; correct_bustypes = true, solver_settings = settings), + () -> ACPowerFlow{solver}(; + correct_bustypes = true, + solution_parameters = SolutionParameters(; settings...)), sys) end for (label, solver, settings) in _RECT_CI_VARIANTS bench_ac!(name, label, () -> PF.ACRectangularPowerFlow{solver}(; - correct_bustypes = true, solver_settings = settings), + correct_bustypes = true, + solution_parameters = SolutionParameters(; settings...)), sys) end for (label, solver, settings) in _MIXED_CPB_VARIANTS bench_ac!(name, label, () -> PF.ACMixedPowerFlow{solver}(; - correct_bustypes = true, solver_settings = settings), + correct_bustypes = true, + solution_parameters = SolutionParameters(; settings...)), sys) end for (dc_pf, label) in dc_solvers @@ -183,7 +186,8 @@ if get(ENV, "PF_PERF_SKIP_LARGE_SYSTEMS", "false") != "true" for (label, solver, settings) in large_ac_solvers bench_ac!(name, label, () -> ACPowerFlow{solver}(; - correct_bustypes = true, solver_settings = settings), + correct_bustypes = true, + solution_parameters = SolutionParameters(; settings...)), sys) end end diff --git a/test/runtests.jl b/test/runtests.jl index efe8a2cd..5e1e8e28 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,5 +1,52 @@ -# See also `load_tests.jl` for running tests interactively with ReTest.jl +# Each test_*.jl runs in its own worker (ParallelTestRunner); files share nothing but +# `includes.jl`'s preamble. +# +# julia --project=test test/runtests.jl # full suite, all jobs +# julia --project=test test/runtests.jl test_dc_power_flow # filter by FILE name (startswith) +# julia --project=test test/runtests.jl --jobs=4 # cap parallelism +# julia --project=test test/runtests.jl --list # list discoverable tests + using PowerFlows +using ParallelTestRunner +import PowerSystemCaseBuilder as PSB + +const TEST_DIR = @__DIR__ + +const DISABLED_TESTS = Set(String[]) + +# Each file's expression installs its own stray-error gate (see `with_stray_error_gate` +# in includes.jl) around the `include`, since ParallelTestRunner gives each test file its +# own worker rather than a suite-wide logger to share. +testsuite = Dict{String, Expr}( + splitext(f)[1] => :(with_stray_error_gate(() -> include($(joinpath(TEST_DIR, f))))) + for f in readdir(TEST_DIR) if + startswith(f, "test_") && endswith(f, ".jl") && splitext(f)[1] ∉ DISABLED_TESTS +) + +const INIT_CODE = :(include($(joinpath(TEST_DIR, "includes.jl")))) + +# Worker-process env: PowerSystemCaseBuilder reads a shared serialized-system HDF5 store +# concurrently across workers — disable HDF5 file locking to avoid cross-process contention. +const WORKER_ENV = [ + "HDF5_USE_FILE_LOCKING" => "FALSE", + "RUNNING_SIENNA_TESTS" => "true", + "VECLIB_MAXIMUM_THREADS" => "1", +] + +# A cold PSB cache means every worker misses `is_serialized` and they race to write the same +# bundle directory, which `PSY.to_file` does not do atomically. Running serially populates it +# safely; once warm this costs nothing. An explicit `--jobs` wins, because a second `--jobs` +# in ARGS would survive `extract_flag!` and then be read as a test-name filter. +function _psb_cache_is_cold() + if !isdir(PSB.SERIALIZED_DIR) + return true + end + return isempty(readdir(PSB.SERIALIZED_DIR)) +end + +if _psb_cache_is_cold() && !any(startswith("--jobs"), ARGS) + @info "PowerSystemCaseBuilder cache is empty; building it serially before testing." + push!(ARGS, "--jobs=1") +end -include("PowerFlowsTests.jl") -PowerFlowsTests.run_tests() +runtests(PowerFlows, ARGS; testsuite, init_code = INIT_CODE, env = WORKER_ENV) diff --git a/test/test_ac_nr_allocations.jl b/test/test_ac_nr_allocations.jl index 79cf8b09..246971b8 100644 --- a/test/test_ac_nr_allocations.jl +++ b/test/test_ac_nr_allocations.jl @@ -33,7 +33,7 @@ end sys = PSB.build_system(PSB.MatpowerTestSystems, "matpower_ACTIVSg2000_sys") pf = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}(:validate_voltage_magnitudes => false), + solution_parameters = SolutionParameters(; validate_voltage_magnitudes = false), ) pf_data = PF.PowerFlowData(pf, sys) residual = PF.ACRectangularCIResidual(pf_data, 1) @@ -71,7 +71,10 @@ end pf = ACPowerFlow{PF.NewtonRaphsonACPowerFlow}(; correct_bustypes = true) data = PF.PowerFlowData(pf, sys) PF.solve_power_flow!(data) # warm: builds + caches the structure - # Perturb injections so the measured solve must ITERATE (not a 0-iteration warm start). + # The reuse path is a separate dispatch arm, so it compiles on its first hit; take that + # hit before measuring. Perturb injections so each measured solve must iterate. + data.bus_active_power_injections[:, 1] .*= 1.02 + PF.solve_power_flow!(data) data.bus_active_power_injections[:, 1] .*= 1.02 a_iterating = @allocated PF.solve_power_flow!(data) @test a_iterating < 4_000_000 @@ -90,6 +93,48 @@ end @test isapprox(data.bus_angles[:, 1], θ1; atol = 1e-12) end +@testset "Polar NR workspace reuse: type stability and allocation" begin + # The reuse path infers concretely and allocates under 4 KB per call. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") + pf = ACPowerFlow{PF.NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + data = PF.PowerFlowData(pf, sys) + backend = PF.resolve_linear_solver_backend(nothing) + init_kwargs = + (; + validate_voltage_magnitudes = false, + vm_validation_range = PF.DEFAULT_VALIDATION_RANGE, + ) + PF._newton_workspace!(pf, data, 1, backend, PF.DEFAULT_NR_TOL, init_kwargs) # warm: builds + caches + + rt = only( + Base.return_types(PF._newton_workspace!, + ( + typeof(pf), + typeof(data), + Int64, + typeof(backend), + Float64, + typeof(init_kwargs), + )), + ) + @test rt.parameters[1] === PF.ACPowerFlowResidual + @test rt.parameters[3] === Vector{Float64} + @test rt.parameters[5] <: Union{Nothing, PF.StateVectorCache} + @test rt.parameters[6] === Bool + + data.bus_active_power_injections[:, 1] .*= 1.001 # must iterate, not a 0-iteration warm start + PF._newton_workspace!(pf, data, 1, backend, PF.DEFAULT_NR_TOL, init_kwargs) # warm the reuse branch + data.bus_active_power_injections[:, 1] .*= 1.001 + # Measure with logging off so the bound reflects allocation, not console formatting. + a = Logging.with_logger(Logging.NullLogger()) do + @allocated PF._newton_workspace!(pf, data, 1, backend, PF.DEFAULT_NR_TOL, init_kwargs) + end + # Measured 5.2 KB/call on c_sys14: 1.4 KB dispatch and return boxing at the abstract cache + # slot, 3.9 KB in the reuse arm (slack-factor rebuild, improve_x0). The un-narrowed path + # this guards against was 10.5 KB. + @test a < 8_000 +end + @testset "DC PCM-reuse allocation regression" begin # A repeated DC solve on the same `data` (the PCM loop: fixed network, changing # injections) must reuse the cached factorization AND the typed `DCSolveScratch` diff --git a/test/test_aqua.jl b/test/test_aqua.jl new file mode 100644 index 00000000..a5aec445 --- /dev/null +++ b/test/test_aqua.jl @@ -0,0 +1,19 @@ +@testset "Aqua: unbound type parameters" begin + Aqua.test_unbound_args(PowerFlows) +end + +@testset "Aqua: undefined exports" begin + Aqua.test_undefined_exports(PowerFlows) +end + +@testset "Aqua: method ambiguities" begin + Aqua.test_ambiguities(PowerFlows) +end + +@testset "Aqua: stale dependencies" begin + Aqua.test_stale_deps(PowerFlows) +end + +@testset "Aqua: deps compat" begin + Aqua.test_deps_compat(PowerFlows) +end diff --git a/test/test_area_interchange_enrollment.jl b/test/test_area_interchange_enrollment.jl index 62acda7e..0108511a 100644 --- a/test/test_area_interchange_enrollment.jl +++ b/test/test_area_interchange_enrollment.jl @@ -20,17 +20,6 @@ function _tie_test_context(sys::PSY.System, area_tail::Dict{String, Int}) ) end -function _find_tie(ties::Vector{PF.AreaTie}, fix::Int, tix::Int) - return only( - filter( - tie -> - (tie.from_bus_ix == fix && tie.to_bus_ix == tix) || - (tie.from_bus_ix == tix && tie.to_bus_ix == fix), - ties, - ), - ) -end - @testset "area interchange tie enumeration" begin sys = _make_two_area_system() ctx = _tie_test_context(sys, Dict("Area1" => 1, "Area2" => 2)) @@ -330,59 +319,6 @@ end @test A[t, t] == A.nzval[o[4]] end -_set_slack!(sys, bus_name) = - PSY.set_bustype!(PSY.get_component(PSY.ACBus, sys, bus_name), PSY.ACBusTypes.SLACK) - -function _add_area_interchange!( - sys, - from_name::String, - to_name::String, - flow::Float64; - name::String = "$(from_name)_$(to_name)", -) - PSY.add_component!( - sys, - PSY.AreaInterchange(; - name = name, - available = true, - active_power_flow = flow, - from_area = PSY.get_component(PSY.Area, sys, from_name), - to_area = PSY.get_component(PSY.Area, sys, to_name), - flow_limits = (from_to = 0.0, to_from = 0.0), - ), - ) - return -end - -# Shared by the rule-9 (unenforceable-schedule) and happy-path tests. Area1 owns REF, -# never SLACK; Area2/Area3 can each -# optionally hold SLACK (Area3's Bus 9 has a small gen so it's PV-eligible). AreaInterchange: -# Area2->Area1 0.3, Area3->Area1 0.2 => pdes(Area1)=-0.5, pdes(Area2)=0.3, pdes(Area3)=0.2. -function _three_area_transfer_fixture(; slack_area3::Bool = true) - sys = _make_three_area_system() - bus9 = PSY.get_component(PSY.ACBus, sys, "Bus 9") - gen9 = PSY.ThermalStandard(; - name = "Bus9Gen", - available = true, - status = PSY.OperationalStates.ONLINE, - bus = bus9, - active_power = 0.1, - reactive_power = 0.0, - rating = 1.0, - active_power_limits = (min = 0.0, max = 1.0), - reactive_power_limits = (min = -1.0, max = 1.0), - ramp_limits = nothing, - operation_cost = PSY.ThermalGenerationCost(nothing), - base_power = 100.0, - ) - PSY.add_component!(sys, gen9) - _set_slack!(sys, "Bus 6") - slack_area3 && _set_slack!(sys, "Bus 9") - _add_area_interchange!(sys, "Area2", "Area1", 0.3; name = "A2_A1") - _add_area_interchange!(sys, "Area3", "Area1", 0.2; name = "A3_A1") - return sys -end - @testset "area interchange enrollment rule 1 multiple SLACK buses" begin sys = _make_two_area_system() _set_slack!(sys, "Bus 2") diff --git a/test/test_area_interchange_solve.jl b/test/test_area_interchange_solve.jl index 5a40c229..57472ddb 100644 --- a/test/test_area_interchange_solve.jl +++ b/test/test_area_interchange_solve.jl @@ -327,46 +327,6 @@ end @test cache2.area_data !== cache1.area_data end -# A boundary-crossing 3W transformer winding whose star bus's Y-bus diagonal is polluted by -# BOTH a sibling winding of the same transformer and an unrelated extra line -- neither is a -# member of the boundary-crossing winding's own corridor. Tertiary winding disabled: not -# needed here. -function _make_3w_boundary_fixture() - sys = System(100.0) - area_a = PSY.Area(; name = "AreaA") - area_b = PSY.Area(; name = "AreaB") - PSY.add_component!(sys, area_a) - PSY.add_component!(sys, area_b) - - bus1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230) - bus2 = _add_simple_bus!(sys, 2, ACBusTypes.PV, 230) - bus3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230) - bus4 = _add_simple_bus!(sys, 4, ACBusTypes.PQ, 230) - bus5 = _add_simple_bus!(sys, 5, ACBusTypes.PQ, 230) - PSY.set_area!(bus1, area_a) - PSY.set_area!(bus2, area_b) - PSY.set_area!(bus3, area_a) - PSY.set_area!(bus4, area_b) - PSY.set_area!(bus5, area_a) - - _add_simple_source!(sys, bus1, 0.0, 0.0) - _add_simple_thermal_standard!(sys, bus2, 0.1, 0.0) - _add_simple_load!(sys, bus3, 5.0, 2.0) - _add_simple_load!(sys, bus4, 5.0, 2.0) - _add_simple_load!(sys, bus5, 2.0, 1.0) - - _add_simple_line!(sys, bus1, bus3) - _add_simple_line!(sys, bus2, bus4) - - xfmr = _add_simple_transformer_3w!(sys, bus3, bus4, bus3, 99) - star_bus = PSY.get_star_bus(xfmr) - PSY.set_area!(star_bus, area_a) - _add_simple_line!(sys, star_bus, bus5) - - PSY.set_bustype!(bus2, ACBusTypes.SLACK) - return sys -end - @testset "area interchange 3W winding NI matches independent oracle (polluted star-bus diagonal)" begin sys = _make_3w_boundary_fixture() pf = PF.ACPowerFlow{NewtonRaphsonACPowerFlow}(; @@ -671,8 +631,9 @@ end delta_p_first = copy(data.area_interchange.delta_p) @test_logs( - (:info, r"converged after [01] iterations"), + (:debug, r"converged after [01] iterations"), match_mode = :any, + min_level = Logging.Debug, solve_power_flow!(data) ) @@ -1065,10 +1026,10 @@ end # (n_lcc == 0) is exactly the shape the OLD formula (`n_state - 4*n_lcc`) mispartitioned, # folding the whole VSC tail into the "bus" block. sys = _build_vsc_system(; g = 50.0) - settings = merge(VSC_SETTINGS, Dict{Symbol, Any}(:linear_solver => "KLU")) + params = PF._override(VSC_SOLUTION_PARAMETERS; linear_solver = "KLU") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; log_solver_diagnostics = true, - solver_settings = settings, + solution_parameters = params, ) data = PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(data, 1) diff --git a/test/test_dc_power_flow.jl b/test/test_dc_power_flow.jl index 72b89988..0bc3e88c 100644 --- a/test/test_dc_power_flow.jl +++ b/test/test_dc_power_flow.jl @@ -331,3 +331,53 @@ end @test isapprox(data.arc_active_power_flow_from_to, flows_before; atol = 1e-10) end end + +# `(aba_matrix, run!)` for a DC-family `data`, dispatched on its concrete type rather than +# an `isa`/ternary chain on `pf`. +_dc_test_pieces(data::PF.ABAPowerFlowData) = (data.power_network_matrix, PF._run_aba_solve!) +_dc_test_pieces(data::PF.PTDFPowerFlowData) = (data.aux_network_matrix, PF._run_ptdf_solve!) +_dc_test_pieces(data::PF.vPTDFPowerFlowData) = + (data.aux_network_matrix, PF._run_vptdf_solve!) + +@testset "DC solver_cache slot is concrete through _dc_solve!" begin + # `_dc_solve!` dispatches on the solver-cache slot's concrete type instead of returning + # `(cache, scratch)` from the abstract `RefValue{Union{Nothing,SolverCache}}` slot. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) + for pf in (DCPowerFlow(), PTDFDCPowerFlow(), vPTDFDCPowerFlow()) + data = PowerFlowData(pf, sys) + backend = PF.resolve_linear_solver_backend(nothing) + aba_matrix, run! = _dc_test_pieces(data) + rt_empty = Base.return_types( + PF._dc_solve!, + (typeof(data), typeof(data.solver_cache[]), typeof(backend), + typeof(aba_matrix), typeof(run!)), + ) + @test rt_empty == [Nothing] + solve_power_flow!(data) + rt_built = Base.return_types( + PF._dc_solve!, + (typeof(data), typeof(data.solver_cache[]), typeof(backend), + typeof(aba_matrix), typeof(run!)), + ) + @test rt_built == [Nothing] + end +end + +@testset "DC construction factors ABA once on the KLU backend" begin + # `aba_matrix.K` is already a KLU factorization from construction; a KLU-backend solve + # must reuse it rather than factoring again. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) + for pf in (DCPowerFlow(), PTDFDCPowerFlow(), vPTDFDCPowerFlow()) + data = PowerFlowData(pf, sys) + aba_matrix, _ = _dc_test_pieces(data) + solve_power_flow!(data; linear_solver = "KLU") + @test data.solver_cache[].cache === aba_matrix.K + end +end + +@testset "\"Dense\" linear_solver is rejected up front, not a MethodError mid-solve" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) + @test_throws ArgumentError PF.resolve_linear_solver_backend("Dense") + data = PowerFlowData(DCPowerFlow(), sys) + @test_throws ArgumentError solve_power_flow!(data; linear_solver = "Dense") +end diff --git a/test/test_discrete_control.jl b/test/test_discrete_control.jl index 7a14b312..a8b3c62c 100644 --- a/test/test_discrete_control.jl +++ b/test/test_discrete_control.jl @@ -219,6 +219,21 @@ end @test t.vset_hi ≈ 1.03 end +@testset "discrete control: negative regulated_bus_number resolves via abs (PSS/E CONT<0)" begin + # PSS/E CONT1<0 marks which side of the transformer regulates; the bus number itself is + # |CONT1|. A raw negative value must not be used as a bus number directly (it resolves no + # bus, so the tap would de-enroll with "controlled bus -3 is not in the network"). + sys = _make_tap_shunt_system() + tx = first(PSY.get_components(PSY.TwoWindingTransformer, sys)) + PSY.set_regulated_bus_number!(PSY.get_circuit(tx), -3) + data = PowerFlowData(ACPolarPowerFlow(), sys) + bl = PF.get_bus_lookup(data) + set = PowerFlows.build_controlled_device_set( + sys, bl, data.power_network_matrix) + @test length(set.taps) == 1 + @test set.taps[1].controlled_ix == bl[3] +end + @testset "discrete control: implausible vset locks the device" begin # An API-built shunt whose admittance_limits hold actual susceptance bounds # (per the PSY docstring) would yield a garbage voltage setpoint; the builder @@ -983,6 +998,28 @@ end @test PSY.get_tap(PSY.get_circuit(tx0)) in levels # the written tap is a valid discrete level end +@testset "discrete control: write-back survives a Line sharing the transformer's name" begin + # write_device_settings! re-resolves a tap's owning transformer by name; looking it up + # under the abstract PSY.ACTransmission (rather than the concrete arity types) throws + # "More than one ... ACTransmission with name ..." the moment a Line shares that name. + sys = _make_solvable_tap_shunt_system() + tx = first(PSY.get_components(PSY.TwoWindingTransformer, sys)) + tap_name = PSY.get_name(tx) + b2 = PSY.get_component(ACBus, sys, "bus_2") + b3 = PSY.get_component(ACBus, sys, "bus_3") + # A new arc (bus_2 <-> bus_3, not previously connected), so this only adds a name + # collision, not a parallel line group on an existing arc. + add_component!( + sys, + Line(; name = tap_name, available = true, active_power_flow = 0.0, + reactive_power_flow = 0.0, arc = Arc(; from = b2, to = b3), + r = 0.1, x = 0.1, b = (from = 0.0, to = 0.0), rating = 1.0, + angle_limits = (min = -pi / 2, max = pi / 2)), + ) + pf = ACPolarPowerFlow(; control_discrete_devices = true) + @test solve_and_store_power_flow!(pf, sys) +end + @testset "write-back round-trips the API shunt convention" begin sys = _make_tap_shunt_system() sa = first(PSY.get_components(PSY.SwitchedAdmittance, sys)) @@ -1065,11 +1102,10 @@ end data = PowerFlowData(pf, _make_solvable_tap_shunt_system()) PowerFlows._solve_with_q_limits!(pf, data, 1) ctx = PowerFlows._sensitivity_context(pf, data, 1) - @test !isnothing(ctx) @test PowerFlows._supports_batched_refresh(ctx) end - # No context at all ⇒ no batching, and the predicate must not throw. - @test !PowerFlows._supports_batched_refresh(nothing) + # Singular-base fallback ⇒ no batching, and the predicate must not throw. + @test !PowerFlows._supports_batched_refresh(PowerFlows.FiniteDifferenceProbes()) end @testset "discrete control: analytic sensitivity agrees across AC formulations" begin @@ -1089,7 +1125,7 @@ end PowerFlows._solve_with_q_limits!(pf, data, 1) set = data.controlled_devices ctx = PowerFlows._sensitivity_context(pf, data, 1) - @test !isnothing(ctx) + @test PowerFlows._supports_batched_refresh(ctx) snap = PowerFlows._snapshot_state(data, 1) gains = Dict{String, Float64}() for devices in (set.taps, set.shunts, set.facts) @@ -1118,7 +1154,7 @@ end end end -@testset "discrete control: linearized plant sensitivity matches FD probe (P2)" begin +@testset "discrete control: linearized plant sensitivity matches FD probe" begin # The linearized sensitivity dy/dp = (−J⁻¹ ∂F/∂p)[Vm(controlled)] must agree with the # finite-difference probe in SIGN and magnitude (the FD probe carries O(δ) truncation, so # the linear form is if anything more accurate). A sign error here would silently invert a @@ -1129,7 +1165,7 @@ end PowerFlows._solve_with_q_limits!(pf, data, 1) # converge base case only set = data.controlled_devices ctx = PowerFlows._sensitivity_context(pf, data, 1) - @test !isnothing(ctx) + @test PowerFlows._supports_batched_refresh(ctx) scratch_snap = PowerFlows._snapshot_state(data, 1) for d in (set.taps[1], set.shunts[1]) lin, ok_lin = PowerFlows._linear_plant_sign(d, data, 1, ctx) @@ -1157,7 +1193,30 @@ end @test PowerFlows.get_control_inner_solve_count(data) > 1 end -@testset "discrete control: batched passes keep inner solves ~flat in device count (P3)" begin +@testset "discrete control: batched refresh rebuilds after a bus-type flip" begin + # `_refresh_sensitivity_context!` correctly refuses to reuse a ctx whose bus-type snapshot + # has gone stale (a Q-limit PV<->PQ flip invalidates its baked-in subnetwork/slack layout), + # but on its own it never replaces `ctx` — so a single flip anywhere in the network used to + # disable batching for the rest of the continuation. `_refresh_or_rebuild_context` must + # rebuild fresh instead. + sys = _make_solvable_tap_shunt_system() + pf = ACPolarPowerFlow(; control_discrete_devices = true) + data = PowerFlowData(pf, sys) + ts = 1 + @test PowerFlows.solve_power_flow!(data) + ctx = PowerFlows._sensitivity_context(pf, data, ts) + @test PowerFlows._supports_batched_refresh(ctx) + # Simulate a Q-limit-driven flip elsewhere in the network (bus 2, a PQ bus in this fixture). + bus2_ix = PowerFlows.get_bus_lookup(data)[2] + data.bus_type[bus2_ix, ts] = PSY.ACBusTypes.PV + @test !PowerFlows._refresh_sensitivity_context!(ctx, data, ts) # refuses reuse, as designed + rebuilt = PowerFlows._refresh_or_rebuild_context(ctx, pf, data, ts) + @test PowerFlows._supports_batched_refresh(rebuilt) + @test rebuilt !== ctx # a genuinely fresh context, not the stale one + @test collect(view(data.bus_type, :, ts)) == rebuilt.bus_type +end + +@testset "discrete control: batched passes keep inner solves ~flat in device count" begin # P3 does one inner solve per PASS (not per device). On a set of decoupled controlled # feeders the inner-solve count must stay ~flat as the device count grows — the sequential # path would scale it ~linearly. Build K feeders (REF ─tap─ PQ-load, REF ─line─ PQ-shunt). @@ -1212,6 +1271,49 @@ end @test n8 < 2 * n1 + 20 end +@testset "discrete control: batched pass stays active across an organic Q-limit flip" begin + # `build_ieee14_facts_system(stress=1.1, shmx_mva=10.0)`: bus 6 is still PV after the + # UNMOVED-device base solve (Q-limits alone don't flip it yet), but flips PQ once the FACTS + # device's continuation moves the network enough — an organic, mid-continuation flip, not + # one manufactured by hand-editing `data.bus_type`. A one-time `ctx.bus_type` snapshot would + # let this flip silently disable batching for the rest of the continuation. + sys0 = build_ieee14_facts_system(; stress = 1.1, shmx_mva = 10.0) + pf = ACPolarPowerFlow(; + control_discrete_devices = true, + check_reactive_power_limits = true, + ) + data0 = PowerFlowData(pf, sys0) + ts = 1 + @test PowerFlows._solve_with_q_limits!(pf, data0, ts) # base solve only, no continuation + bl = PowerFlows.get_bus_lookup(data0) + bus6_ix = bl[6] + @test data0.bus_type[bus6_ix, ts] == PSY.ACBusTypes.PV # not yet flipped + + sys = build_ieee14_facts_system(; stress = 1.1, shmx_mva = 10.0) + data = PowerFlowData(pf, sys) + @test solve_power_flow!(data) + @test all(data.converged) + @test data.bus_type[bus6_ix, ts] == PSY.ACBusTypes.PQ # flipped DURING the continuation + + # Base NR cache (1) + initial `_sensitivity_context` build (2) + exactly one rebuild after + # the flip (3). >2 is the signature that `_refresh_or_rebuild_context` fired and produced a + # live context, rather than leaving batching permanently disabled after the flip. + @test PowerFlows.get_control_symbolic_factor_count(data) > 2 + + # The REBUILT (post-flip) context's analytic gain must still agree with the FD-probe + # oracle (the sequential path's own sign/magnitude source) — the same cross-check other + # tests apply to the original context, here applied after the flip and rebuild. + ctx = PowerFlows._sensitivity_context(pf, data, ts) + @test PowerFlows._supports_batched_refresh(ctx) + facts = only(data.controlled_devices.facts) + scratch_snap = PowerFlows._snapshot_state(data, ts) + lin, ok_lin = PowerFlows._linear_plant_sign(facts, data, ts, ctx) + fd, ok_fd = PowerFlows._plant_sign(facts, data, ts, pf, scratch_snap) + @test ok_lin && ok_fd + @test sign(lin) == sign(fd) + @test isapprox(lin, fd; rtol = 1e-2) +end + @testset "linear plant sign matches FD probe at PV controlled bus" begin sys = _make_tap_shunt_system() pf = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; control_discrete_devices = true) @@ -1220,7 +1322,7 @@ end # Converge the base state, then build the sensitivity context the probes use. @test PowerFlows._solve_with_q_limits!(pf, data, ts) ctx = PowerFlows._sensitivity_context(pf, data, ts) - @test !isnothing(ctx) + @test PowerFlows._supports_batched_refresh(ctx) set = PowerFlows.get_controlled_devices(data) for d in set.shunts cbus = PowerFlows.controlled_bus_ix(d) @@ -1309,7 +1411,7 @@ end _add_control_tap!(sys, pq[2], pq[3]) # incident to the AC-voltage-controlled VSC bus pf = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; control_discrete_devices = true, - solver_settings = VSC_SETTINGS, + solution_parameters = VSC_SOLUTION_PARAMETERS, ) data = PowerFlowData(pf, sys) ts = 1 diff --git a/test/test_fast_decoupled.jl b/test/test_fast_decoupled.jl index 07b431ef..641da2d6 100644 --- a/test/test_fast_decoupled.jl +++ b/test/test_fast_decoupled.jl @@ -43,7 +43,7 @@ end @testset "FastDecoupled WP0: settings validation" begin # The variant/scheme are now FastDecoupledACPowerFlow type parameters, so invalid values are # unrepresentable. Only the handoff solver still needs runtime validation. - @test PF._validate_fd_handoff_solver(nothing) === nothing + @test PF._validate_fd_handoff_solver(PF.NoHandoff) === nothing @test PF._validate_fd_handoff_solver(NewtonRaphsonACPowerFlow) === nothing @test PF._validate_fd_handoff_solver(TrustRegionACPowerFlow) === nothing @test PF._validate_fd_handoff_solver(LevenbergMarquardtACPowerFlow) === nothing @@ -331,6 +331,126 @@ end @test norm(Matrix(PF._restamp_ybus(p)) - Yb) / norm(Yb) <= 1e-4 end +# An r=x=0 transformer. ZeroImpedanceBranchReduction excludes transformer arcs, so this +# arc reaches Ybus assembly with a literal 0+0j impedance; PNM substitutes its own configured +# `minimum_retained_impedance` (never a hard-coded default) before FD ever reads it. Regression +# for the `1/complex(0,0)` NaN this used to produce. +function _zero_impedance_transformer_system() + sys = PSY.System(100.0) + b1 = _add_simple_bus!(sys, 1, PSY.ACBusTypes.REF, 230.0, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, PSY.ACBusTypes.PV, 230.0, 1.0, 0.0) + b3 = _add_simple_bus!(sys, 3, PSY.ACBusTypes.PQ, 230.0, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_thermal_standard!(sys, b2, 0.3, 0.0) + _add_simple_load!(sys, b3, 15.0, 6.0) + _add_simple_line!(sys, b1, b2, 0.01, 0.10, 0.05) + tx = PSY.TwoWindingTransformer(; + name = "zero_z_2_3", + circuit = PSY.TransformerCircuit(; + available = true, + arc = PSY.Arc(; from = b2, to = b3), + r = 0.0, + x = 0.0, + tap = 1.0, + α = 0.0, + rating = 2.0, + base_power = 100.0, + ), + ) + add_component!(sys, tx) + return sys +end + +@testset "FastDecoupled WP1: zero-impedance transformer, no NaN" begin + # `network_reductions = nothing` (default `minimum_retained_impedance`) and an explicit, + # NON-default `ZeroImpedanceBranchReduction(; minimum_retained_impedance = 1e-4)`: the + # original defect read a hard-coded default min_x_eps for FD while Ybus assembly used the + # reduction's CONFIGURED value, so the two silently disagreed whenever the reduction was + # customized (`Yb[i,i] − self_acc[i]` residual then injects a fake shunt of the mismatch's + # size, making B″ indefinite). `_arc_params` must read the same value Ybus assembly used. + for reductions in ( + PNM.NetworkReduction[], + PNM.NetworkReduction[ + PNM.ZeroImpedanceBranchReduction(; minimum_retained_impedance = 1e-4), + ], + ) + sys = _zero_impedance_transformer_system() + pf_kwargs = (; + network_reductions = reductions, skip_redistribution = true, + correct_bustypes = true, + ) + nr = ACPowerFlow(; pf_kwargs...) + data_nr = PowerFlowData(nr, sys) + @test solve_power_flow!(data_nr; pf = nr) + + fd = ACPowerFlow{PF.FastDecoupledACPowerFlow}(; pf_kwargs...) + data_fd = PowerFlowData(fd, sys) + @test solve_power_flow!(data_fd; pf = fd) + @test !any(isnan, data_fd.bus_magnitude[:, 1]) + @test data_fd.bus_magnitude[:, 1] ≈ data_nr.bus_magnitude[:, 1] atol = 1e-6 + + # The π param FD reads must match the min_x_eps Ybus assembly actually substituted. + nrd = PNM.get_network_reduction_data(PF.get_power_network_matrix(data_fd)) + eb = only(PNM.arc_equivalent_branches(nrd, (2, 3))) + expected_x = isempty(reductions) ? PNM.ZERO_IMPEDANCE_X_EPSILON : 1e-4 + @test PNM.get_equivalent_x(eb) ≈ expected_x + end +end + +# A degree-two-reduced series chain whose parallel-group segment mixes phase-shift and +# impedance angles has NO single-π equivalent (`PNM.arc_equivalent_branches` throws for it — the +# asymmetric two-port `|Yft| != |Ytf|` cannot be one π branch). Bus 2 is degree-two: it connects +# only the (1,2) parallel [Line ∥ lossy PST] group and the (2,3) line, so `DegreeTwoReduction` +# merges it into a chain over that non-representable group. +function _pst_line_parallel_degree_two_system() + sys = PSY.System(100.0) + b1 = _add_simple_bus!(sys, 1, PSY.ACBusTypes.REF, 230.0, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, PSY.ACBusTypes.PQ, 230.0, 1.0, 0.0) + b3 = _add_simple_bus!(sys, 3, PSY.ACBusTypes.PQ, 230.0, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b3, 10.0, 3.0) + _add_simple_line!(sys, b1, b2, 0.01, 0.10, 0.0) + pst12 = PSY.TwoWindingTransformer(; + name = "PST12", + circuit = PSY.TransformerCircuit(; + available = true, + arc = PSY.Arc(; from = b1, to = b2), + r = 0.05, + x = 0.20, + tap = 1.0, + α = 0.15, + rating = 2.0, + base_power = 100.0, + control_limits = (min = -0.7, max = 0.7), + ), + ) + add_component!(sys, pst12) + _add_simple_line!(sys, b2, b3, 0.01, 0.10, 0.0) + return sys +end + +@testset "FastDecoupled WP1: degree-two chain over a non-single-π parallel group" begin + sys = _pst_line_parallel_degree_two_system() + reductions = + PNM.NetworkReduction[PNM.DegreeTwoReduction(; + reduce_reactive_power_injectors = false, + )] + pf_kwargs = (; + network_reductions = reductions, skip_redistribution = true, + correct_bustypes = true, + ) + nr = ACPowerFlow(; pf_kwargs...) + data_nr = PowerFlowData(nr, sys) + @test solve_power_flow!(data_nr; pf = nr) + + fd = ACPowerFlow{PF.FastDecoupledACPowerFlow}(; pf_kwargs...) + data_fd = PowerFlowData(fd, sys) + # Regression: used to throw "Series chain ... has no π representation at all." + @test solve_power_flow!(data_fd; pf = fd) + @test !any(isnan, data_fd.bus_magnitude[:, 1]) + @test data_fd.bus_magnitude[:, 1] ≈ data_nr.bus_magnitude[:, 1] atol = 1e-6 +end + # ===================================================================================== # WP2 — Frozen-Jacobian (:fixed_jacobian) loop + shared safeguard helpers. # ===================================================================================== @@ -338,13 +458,13 @@ end # Per-formulation frozen-Jacobian FD power flow constructor by name. _fd_fixed_jacobian_pf(::Type{<:PF.ACPolarPowerFlow}; kwargs...) = ACPowerFlow{_fd_solver(:fixed_jacobian)}(; - solver_settings = Dict{Symbol, Any}(kwargs...)) + solution_parameters = SolutionParameters(; kwargs...)) _fd_fixed_jacobian_pf(::Type{<:PF.ACRectangularPowerFlow}; kwargs...) = ACRectangularPowerFlow{_fd_solver(:fixed_jacobian)}(; - solver_settings = Dict{Symbol, Any}(kwargs...)) + solution_parameters = SolutionParameters(; kwargs...)) _fd_fixed_jacobian_pf(::Type{<:PF.ACMixedPowerFlow}; kwargs...) = ACMixedPowerFlow{_fd_solver(:fixed_jacobian)}(; - solver_settings = Dict{Symbol, Any}(kwargs...)) + solution_parameters = SolutionParameters(; kwargs...)) # Plain (non-FD) formulation constructor parametrized by a solver type, for NR-parity refs. _plain_pf(::Type{<:PF.ACPolarPowerFlow}, ::Type{S}) where {S} = ACPowerFlow{S}() @@ -532,9 +652,9 @@ end sys_fd = PSB.build_system(PSB.PSITestSystems, "c_sys5"; add_forecasts = false) pf = ACPowerFlow{_fd_solver(:fixed_jacobian)}(; - solver_settings = Dict{Symbol, Any}( - :fd_dvlim => 0.005, # tiny DVLIM forces the clamp to engage repeatedly - :maxIterations => 150, + solution_parameters = SolutionParameters(; + fd_dvlim = 0.005, # tiny DVLIM forces the clamp to engage repeatedly + maxIterations = 150, )) data_fd = PowerFlowData(pf, sys_fd) converged = solve_power_flow!(data_fd) @@ -637,14 +757,14 @@ end # Polar :decoupled FD constructor with arbitrary settings. _fd_decoupled_pf(; scheme::PF.FDScheme = PF.FDSchemeXB(), kwargs...) = ACPowerFlow{PF.FastDecoupledACPowerFlow{PF.FDDecoupled, typeof(scheme)}}(; - solver_settings = Dict{Symbol, Any}(kwargs...)) + solution_parameters = SolutionParameters(; kwargs...)) # T2 — Polar FDNR solution parity. For each scheme and system, the pure FD :decoupled # solve must (1) converge to tol=1e-9 within DEFAULT_FD_MAX_ITER, (2) match an INDEPENDENT # NewtonRaphsonACPowerFlow solve to TIGHT_TOLERANCE on bus_magnitude / bus_angles / _calc_x, # and (3) take MORE iterations than NR and > 5 — proving it is genuinely fast-decoupled # (linear rate), not an accidental exact-Newton. -@testset "FastDecoupled WP3: :decoupled NR-parity (T2)" begin +@testset "FastDecoupled WP3: :decoupled NR-parity" begin systems = ( ("c_sys5", () -> PSB.build_system(PSB.PSITestSystems, "c_sys5"; add_forecasts = false), @@ -723,8 +843,8 @@ end # Build an ACPowerFlow{FastDecoupled} (polar) with handoff settings for a given variant. _fd_handoff_pf(variant, handoff; extra...) = ACPowerFlow{_fd_solver(variant)}(; - solver_settings = Dict{Symbol, Any}( - :handoff_solver => handoff, + solution_parameters = SolutionParameters(; + handoff_solver = handoff, extra..., )) @@ -824,8 +944,8 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) # RobustHomotopy is not an accepted handoff target (only nothing / NR / TR / LM are). pf = ACPowerFlow{PF.FastDecoupledACPowerFlow}(; - solver_settings = Dict{Symbol, Any}( - :handoff_solver => RobustHomotopyPowerFlow)) + solution_parameters = SolutionParameters(; + handoff_solver = RobustHomotopyPowerFlow)) data = PowerFlowData(pf, sys) @test_throws ArgumentError solve_power_flow!(data) end @@ -917,7 +1037,7 @@ end @testset "rectangular" begin pf_nr = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:validate_voltage_magnitudes => false)) + solution_parameters = SolutionParameters(; validate_voltage_magnitudes = false)) data_nr = PF.PowerFlowData( pf_nr, make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), @@ -925,8 +1045,8 @@ end @test solve_power_flow!(data_nr) pf_fd = ACRectangularPowerFlow{_fd_solver(:fixed_jacobian)}(; - solver_settings = Dict{Symbol, Any}( - :validate_voltage_magnitudes => false)) + solution_parameters = SolutionParameters(; + validate_voltage_magnitudes = false)) data_fd = PF.PowerFlowData( pf_fd, make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), @@ -941,7 +1061,7 @@ end @testset "mixed" begin pf_nr = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:validate_voltage_magnitudes => false)) + solution_parameters = SolutionParameters(; validate_voltage_magnitudes = false)) data_nr = PF.PowerFlowData( pf_nr, make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), @@ -949,8 +1069,8 @@ end @test solve_power_flow!(data_nr) pf_fd = ACMixedPowerFlow{_fd_solver(:fixed_jacobian)}(; - solver_settings = Dict{Symbol, Any}( - :validate_voltage_magnitudes => false)) + solution_parameters = SolutionParameters(; + validate_voltage_magnitudes = false)) data_fd = PF.PowerFlowData( pf_fd, make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), @@ -1089,8 +1209,8 @@ end # documented stiff-system limitation; emits the low-reactance warning). Use the robust path # (FD stage → NR handoff) for parity, which converges quickly to the same solution. pf_fd = ACPowerFlow{_fd_solver(:decoupled)}(; - solver_settings = Dict{Symbol, Any}( - :handoff_solver => NewtonRaphsonACPowerFlow, + solution_parameters = SolutionParameters(; + handoff_solver = NewtonRaphsonACPowerFlow, )) data_fd = PF.PowerFlowData( pf_fd, @@ -1216,9 +1336,9 @@ end @testset "FastDecoupled WP5b: multi-period :fixed_jacobian (T7 fixed)" begin # Multi-period correctness for the frozen-Jacobian variant (mirrors the :decoupled T7 above). - # The fixed-Jacobian path factors the frozen J per driver invocation (it does not populate the - # B′/B″ FastDecoupledCache), so this asserts the solve — every step converges and matches NR — - # rather than the decoupled cache counters. + # `FDFixedJacobianCache` reuses the frozen Jacobian's symbolic factorization while its pattern + # is unchanged (`numeric_refactor!` only) — all 24 steps share one signature (no per-step + # Q-limit switching), so exactly one symbolic factorization across the whole horizon. sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) time_steps = 24 @@ -1235,6 +1355,48 @@ end @test solve_power_flow!(data_nr) @test isapprox(data_fd.bus_magnitude, data_nr.bus_magnitude; atol = TIGHT_TOLERANCE) @test isapprox(data_fd.bus_angles, data_nr.bus_angles; atol = TIGHT_TOLERANCE) + + fdj_cache = data_fd.solver_cache[] + @test fdj_cache isa PF.FDFixedJacobianCache + distinct_sigs = + length(unique(hash(view(data_fd.bus_type, :, t)) for t in 1:time_steps)) + @test distinct_sigs == 1 + @test fdj_cache.factor_count == 1 +end + +@testset "FastDecoupled WP5b: :fixed_jacobian factor-once across repeated solves" begin + # Regression: every FD{Fix} solve used to build a fresh linear-solver cache and run a + # full symbolic+numeric factorization from scratch. Perturb the withdrawal directly on the + # SAME `PowerFlowData` between solves (the documented benchmark trap — an unperturbed re-solve + # warm-starts to 0 iterations) and assert the unchanged bus-type signature reuses the symbolic + # factorization (`factor_count` stays at 1), with a much smaller `@allocated` footprint on the + # warm repeat solves than the cold first solve (which pays the one-time `full_factor!`). + for (mod, name) in + ((PSB.PSITestSystems, "c_sys14"), (PSB.PSISystems, "RTS_GMLC_DA_sys")) + sys = if name == "c_sys14" + PSB.build_system(mod, name; add_forecasts = false) + else + PSB.build_system(mod, name) + end + pf_fd = ACPowerFlow{_fd_solver(:fixed_jacobian)}(; correct_bustypes = true) + data = PowerFlowData(pf_fd, sys) + + # Cold: the FIRST solve builds the cache and pays the one-time `full_factor!`. + alloc_cold = @allocated (@test solve_power_flow!(data)) + cache = data.solver_cache[] + @test cache isa PF.FDFixedJacobianCache + @test cache.factor_count == 1 + + alloc_warm = 0 + for _ in 1:3 + data.bus_active_power_withdrawals .*= 1.01 # perturb: avoid the 0-iteration trap + alloc_warm = @allocated (@test solve_power_flow!(data)) + @test all(data.converged) + @test cache.factor_count == 1 # symbolic factorization reused, not rebuilt + end + @test cache === data.solver_cache[] # same cache object across every repeat + @test alloc_warm < alloc_cold + end end @testset "FastDecoupled WP5b: multi-period BX scheme" begin @@ -1555,9 +1717,9 @@ end sys_fd = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) pf = ACPowerFlow{_fd_solver(variant)}(; - solver_settings = Dict{Symbol, Any}( - :handoff_solver => TrustRegionACPowerFlow, - :maxIterations => 1)) # forces an unconverged FD stage → handoff + solution_parameters = SolutionParameters(; + handoff_solver = TrustRegionACPowerFlow, + maxIterations = 1)) # forces an unconverged FD stage → handoff data_fd = PowerFlowData(pf, sys_fd) @test solve_power_flow!(data_fd) @test isapprox(data_fd.bus_magnitude[:, 1], data_nr.bus_magnitude[:, 1]; diff --git a/test/test_gradient_descent_ac_power_flow.jl b/test/test_gradient_descent_ac_power_flow.jl index 39cdd2be..a4f5039e 100644 --- a/test/test_gradient_descent_ac_power_flow.jl +++ b/test/test_gradient_descent_ac_power_flow.jl @@ -37,7 +37,7 @@ # Solve with Gradient Descent (Adam) pf_gd = ACPowerFlow{GradientDescentACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:learning_rate => 0.01), + solution_parameters = SolutionParameters(; learning_rate = 0.01), ) result_gd = solve_power_flow(pf_gd, sys2; maxIterations = 15000) @test !ismissing(result_gd) diff --git a/test/test_hvdc.jl b/test/test_hvdc.jl index 785955e9..e5abaf64 100644 --- a/test/test_hvdc.jl +++ b/test/test_hvdc.jl @@ -51,6 +51,34 @@ end end +@testset "Test DC power flow with a quadratic VSC converter loss curve" begin + for DC_type in (PF.DCPowerFlow, PF.PTDFDCPowerFlow, PF.vPTDFDCPowerFlow) + @testset "DC Solver: $(DC_type)" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230, 1.0, 0.0) + b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230, 1.0, 0.0) + + _add_simple_source!(sys, b1, 1.0, 0.0) + _add_simple_load!(sys, b2, 0.3, 0.1) + _add_simple_load!(sys, b3, 0.4, 0.1) + + _add_simple_line!(sys, b1, b2, 0.01, 0.05, 0.02) + _add_simple_line!(sys, b1, b3, 0.01, 0.05, 0.02) + + vsc = _add_simple_vsc!(sys, b2, b3; active_power_flow = 0.2) + quad_loss = LossCurve(QuadraticCurve(0.01, 0.02, 0.005), NaturalUnit()) + set_converter_loss_from!(vsc, quad_loss) + set_converter_loss_to!(vsc, quad_loss) + + pf = DC_type() + data = PF.PowerFlowData(pf, sys) + solve_power_flow!(data) + @test all(data.converged) + end + end +end + @testset "Test HVDC injections helper function" begin sys = build_system(MatpowerTestSystems, "matpower_case5_dc_sys") hvdc = only(get_components(TwoTerminalHVDC, sys)) @@ -294,3 +322,27 @@ end @test all(bus_results.Vm .> 0.9) @test all(bus_results.Vm .< 1.1) end + +@testset "Test DC LCC arc flow sign (rectifier/inverter, with loss)" begin + # 30 MW with a 10% loss curve (3 MW lost): P_to_from is the received power, negated (-27), not the loss (+3). + for DC_type in (PF.DCPowerFlow, PF.PTDFDCPowerFlow, PF.vPTDFDCPowerFlow) + @testset "DC Solver: $(DC_type)" begin + sys, lcc = simple_lcc_system() + set_active_power_flow!(lcc, 30.0 * u"MW") + set_loss!(lcc, LossCurve(LinearCurve(0.1), NaturalUnit())) + pf = DC_type(; correct_bustypes = true) + data = PowerFlowData(pf, sys) + @test isapprox(data.lcc.arc_active_power_flow_from_to[1, 1], 0.3; atol = 1e-6) + @test isapprox( + data.lcc.arc_active_power_flow_to_from[1, 1], + -0.27; + atol = 1e-6, + ) + + results = solve_power_flow(pf, sys, PF.FlowReporting.ARC_FLOWS) + lcc_df = results["1"]["lcc_results"] + @test isapprox(lcc_df[1, :P_from_to], 30.0; atol = 1e-6) + @test isapprox(lcc_df[1, :P_to_from], -27.0; atol = 1e-6) + end + end +end diff --git a/test/test_iterative_methods.jl b/test/test_iterative_methods.jl index 1682cead..75684dbb 100644 --- a/test/test_iterative_methods.jl +++ b/test/test_iterative_methods.jl @@ -38,28 +38,28 @@ end # test NR kwargs. sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") nr_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}( - :maxIterations => 50, - :tol => 1e-10, - :refinement_threshold => 0.01, - :refinement_eps => 1e-7, + solution_parameters = SolutionParameters(; + maxIterations = 50, + tol = 1e-10, + refinement_threshold = 0.01, + refinement_eps = 1e-7, )) - @test_logs (:info, r".*NewtonRaphsonACPowerFlow solver converged" - ) match_mode = :any PF.solve_power_flow(nr_pf, sys) + @test_logs (:debug, r".*NewtonRaphsonACPowerFlow solver converged" + ) match_mode = :any min_level = Logging.Debug PF.solve_power_flow(nr_pf, sys) end @testset "TrustRegionACPowerFlow kwargs" begin # test trust region kwargs. sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") tr_pf = ACPowerFlow{TrustRegionACPowerFlow}(; - solver_settings = Dict{Symbol, Any}( - :eta => 1e-5, - :tol => 1e-10, - :factor => 1.1, - :maxIterations => 50, + solution_parameters = SolutionParameters(; + eta = 1e-5, + tol = 1e-10, + factor = 1.1, + maxIterations = 50, )) - @test_logs (:info, r".*TrustRegionACPowerFlow solver converged" - ) match_mode = :any PF.solve_power_flow(tr_pf, sys) + @test_logs (:debug, r".*TrustRegionACPowerFlow solver converged" + ) match_mode = :any min_level = Logging.Debug PF.solve_power_flow(tr_pf, sys) end function bad_x0!(sys::PSY.System) @@ -73,19 +73,19 @@ end # Small trust region size => Cauchy or dogleg step tr_pf_small = ACPowerFlow{TrustRegionACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:factor => 0.01, :maxIterations => 1)) + solution_parameters = SolutionParameters(; factor = 0.01, maxIterations = 1)) @test_logs (:debug, r"(Dogleg step selected|Cauchy step selected)") match_mode = :any min_level = Logging.Debug PF.solve_power_flow(tr_pf_small, sys) # Large trust region size => Newton-Raphson step tr_pf_large = ACPowerFlow{TrustRegionACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:factor => 10.0, :maxIterations => 1)) + solution_parameters = SolutionParameters(; factor = 10.0, maxIterations = 1)) @test_logs (:debug, r"Newton-Raphson step selected.*") match_mode = :any min_level = Logging.Debug PF.solve_power_flow(tr_pf_large, sys) # Large eta => step rejected, Iwamoto fallback attempted (default on) tr_pf_large_eta = ACPowerFlow{TrustRegionACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:eta => 2.0, :maxIterations => 1)) + solution_parameters = SolutionParameters(; eta = 2.0, maxIterations = 1)) @test_logs (:debug, r"Iwamoto fallback.*") match_mode = :any min_level = Logging.Debug PF.solve_power_flow( tr_pf_large_eta, sys, @@ -93,8 +93,8 @@ end # Large eta with iwamoto_fallback disabled => plain rejection tr_pf_no_iwamoto = ACPowerFlow{TrustRegionACPowerFlow}(; - solver_settings = Dict{Symbol, Any}( - :eta => 2.0, :maxIterations => 1, :iwamoto_fallback => false)) + solution_parameters = SolutionParameters(; + eta = 2.0, maxIterations = 1, iwamoto_fallback = false)) @test_logs (:debug, r"Step rejected.*") match_mode = :any min_level = Logging.Debug PF.solve_power_flow( tr_pf_no_iwamoto, sys, @@ -102,7 +102,7 @@ end # Small eta => step accepted tr_pf_small_eta = ACPowerFlow{TrustRegionACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:eta => 1e-6, :maxIterations => 1)) + solution_parameters = SolutionParameters(; eta = 1e-6, maxIterations = 1)) @test_logs (:debug, r"Step accepted.*") match_mode = :any min_level = Logging.Debug PF.solve_power_flow( tr_pf_small_eta, sys, @@ -112,28 +112,28 @@ end @testset "Iwamoto step control convergence" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") iwamoto_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:iwamoto => true)) - @test_logs (:info, r".*NewtonRaphsonACPowerFlow solver converged" - ) match_mode = :any PF.solve_power_flow(iwamoto_pf, sys) + solution_parameters = SolutionParameters(; iwamoto = true)) + @test_logs (:debug, r".*NewtonRaphsonACPowerFlow solver converged" + ) match_mode = :any min_level = Logging.Debug PF.solve_power_flow(iwamoto_pf, sys) end @testset "Iwamoto step control kwargs" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") iwamoto_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}( - :iwamoto => true, - :maxIterations => 50, - :tol => 1e-10, + solution_parameters = SolutionParameters(; + iwamoto = true, + maxIterations = 50, + tol = 1e-10, )) - @test_logs (:info, r".*NewtonRaphsonACPowerFlow solver converged" - ) match_mode = :any PF.solve_power_flow(iwamoto_pf, sys) + @test_logs (:debug, r".*NewtonRaphsonACPowerFlow solver converged" + ) match_mode = :any min_level = Logging.Debug PF.solve_power_flow(iwamoto_pf, sys) end @testset "Iwamoto result equivalence with plain NR" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") nr_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}() iwamoto_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:iwamoto => true)) + solution_parameters = SolutionParameters(; iwamoto = true)) nr_result = PF.solve_power_flow(nr_pf, sys) sys2 = PSB.build_system(PSB.PSITestSystems, "c_sys5") iwamoto_result = PF.solve_power_flow(iwamoto_pf, sys2) @@ -154,9 +154,9 @@ end bad_x0!(sys) iwamoto_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; enhanced_flat_start = false, - solver_settings = Dict{Symbol, Any}(:iwamoto => true)) - @test_logs (:info, r".*NewtonRaphsonACPowerFlow solver converged" - ) match_mode = :any PF.solve_power_flow(iwamoto_pf, sys) + solution_parameters = SolutionParameters(; iwamoto = true)) + @test_logs (:debug, r".*NewtonRaphsonACPowerFlow solver converged" + ) match_mode = :any min_level = Logging.Debug PF.solve_power_flow(iwamoto_pf, sys) end @testset "Iwamoto on larger system (RTS_GMLC)" begin @@ -164,7 +164,7 @@ end nr_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) iwamoto_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}(:iwamoto => true)) + solution_parameters = SolutionParameters(; iwamoto = true)) nr_result = PF.solve_power_flow(nr_pf, sys) sys2 = PSB.build_system(PSB.PSISystems, "RTS_GMLC_DA_sys") iwamoto_result = PF.solve_power_flow(iwamoto_pf, sys2) @@ -188,7 +188,7 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; enhanced_flat_start = false, - solver_settings = Dict{Symbol, Any}(:maxIterations => 3), + solution_parameters = SolutionParameters(; maxIterations = 3), ) data = PowerFlowData(pf, sys) data.bus_magnitude .= 0.0 @@ -205,9 +205,9 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") iwamoto_pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; enhanced_flat_start = false, - solver_settings = Dict{Symbol, Any}( - :iwamoto => true, - :maxIterations => 20, + solution_parameters = SolutionParameters(; + iwamoto = true, + maxIterations = 20, )) data = PowerFlowData(iwamoto_pf, sys) # Set all voltage magnitudes to zero so the Jacobian is singular @@ -225,7 +225,7 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; enhanced_flat_start = false, time_steps = 2, - solver_settings = Dict{Symbol, Any}(:maxIterations => 2)) + solution_parameters = SolutionParameters(; maxIterations = 2)) data = PowerFlowData(pf, sys) data.bus_magnitude .= 0.0 @test_logs( @@ -347,7 +347,7 @@ end sys4 = deepcopy(sys) bad_x0!(sys4) improvement_regex = r".*DC power flow fallback yields smaller residual.*" - @test_logs (:info, improvement_regex) match_mode = :any PF.solve_power_flow( + @test_logs (:info, improvement_regex) match_mode = :any min_level = Logging.Debug PF.solve_power_flow( dc_pf, sys4, ) diff --git a/test/test_jacobian.jl b/test/test_jacobian.jl index bdd423e2..d647cf8c 100644 --- a/test/test_jacobian.jl +++ b/test/test_jacobian.jl @@ -1,32 +1,3 @@ -function verify_jacobian( - sys::PSY.System; - pf::PF.ACPowerFlow = PF.ACPowerFlow{NewtonRaphsonACPowerFlow}(; - correct_bustypes = true, - ), - label::String = "", - perturbation::Float64 = 0.02, - seed::Int = 42, -) - data = PF.PowerFlowData(pf, sys) - time_step = 1 - residual = PF.ACPowerFlowResidual(data, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) - x0 = PF.calculate_x0(data, time_step) - # Verify away from the flat-start state. At flat start θ=0 for every bus, - # which silently zeroes all `sin(Δθ)` cross-terms — a sign flip in the - # symbolic Jacobian for those entries would not be detected. A small - # deterministic perturbation breaks the symmetry. - if perturbation > 0 - Random.seed!(seed) - x0 .+= perturbation .* randn(length(x0)) - end - residual(x0, time_step) - J(time_step) - verify_jacobian_asymptotic( - residual, deepcopy(J.Jv), x0, time_step; label = label, - ) -end - @testset "Jacobian verification" begin sys = PSB.build_system(PSITestSystems, "c_sys14") verify_jacobian(sys; label = "polar c_sys14") @@ -214,21 +185,6 @@ end verify_jacobian(sys; pf = pf, label = "polar c_sys14 distributed-slack") end -# Two-swing island: buses 1 and 2 are both REF in one island, bus 3 is a PQ load. The -# second swing has a nonzero fixed angle so the check exercises real off-diagonal ∂P/∂θ terms. -function _two_swing_system() - sys = System(100.0) - b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.06, 0.0) - b2 = _add_simple_bus!(sys, 2, ACBusTypes.REF, 230, 1.05, 0.05) - b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230, 1.0, 0.0) - _add_simple_source!(sys, b1, 0.0, 0.0) - _add_simple_source!(sys, b2, 0.0, 0.0) - _add_simple_load!(sys, b3, 40, 15) - _add_simple_line!(sys, b1, b3, 5e-3, 5e-3, 1e-3) - _add_simple_line!(sys, b2, b3, 5e-3, 5e-3, 1e-3) - return sys -end - @testset "Multi-swing: two swings in one island each self-balance (solve)" begin sys = _two_swing_system() pf = PF.ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = false) diff --git a/test/test_lcc_discrete_control.jl b/test/test_lcc_discrete_control.jl index 0389946a..118ce459 100644 --- a/test/test_lcc_discrete_control.jl +++ b/test/test_lcc_discrete_control.jl @@ -115,50 +115,14 @@ end # test_multiperiod_discrete_control.jl, the same comparison without LCC converter state. const LCC_PARITY_ATOL = 1e-6 -"""Parse the bundled two-LCC fixture and add enrollable controlled devices: a stepping -switched shunt and a shunt FACTS device at bus 101 (PQ, 230 kV, largest load). The fixture -has no transformers, so no tap device is enrolled. `p_set_mw` overrides both LCC transfer -setpoints (0.0 exercises the i_dc = 0 tap-pinning branch). - -Every branch carries x = 1e-4 pu against a much larger r, so bus 101 is electrically bolted to -the REF bus and the network is resistance-dominated — a reactive move there shifts angle far -more than magnitude. Device ratings and setpoints are therefore sized past anything realistic, -so the devices clear `CONTROL_GAIN_FLOOR` and enroll instead of being frozen as insensitive, -and their setpoints sit above the reachable voltage so the continuation keeps driving them.""" -function build_lcc_control_system(; p_set_mw::Union{Nothing, Float64} = nothing) - raw = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw); runchecks = false) - bus101 = get_bus(sys, 101) - add_component!( - sys, - SwitchedAdmittance(; name = "ctrl_shunt_101", available = true, - bus = bus101, number_engaged = [0], number_of_steps = [8], - Y_increase = [0.0 + 0.5im], admittance_limits = (min = 1.05, max = 1.08), - control_mode = PSY.SwitchedAdmittanceControlMode.DISCRETE_VOLTAGE, - ), - ) - add_component!( - sys, - FACTSControlDevice(; - name = "ctrl_facts_101", - available = true, - bus = bus101, - control_mode = PSY.FACTSOperationModes.NML, - voltage_setpoint = 1.06, - max_shunt_current = 1000.0, - max_reactive_power = 9999.0, - shunt_control_type = PSY.FACTSShuntControlType.STATCOM, - regulated_bus_number = 0, - ), - ) - if p_set_mw !== nothing - # `initialize_LCCParameters!` seeds `p_set` from this setter's value in MW. - for l in get_components(TwoTerminalLCCLine, sys) - set_transfer_setpoint!(l, p_set_mw) - end - end - return sys -end +"""Element-wise parity: every entry within `atol`. + +`isapprox` on two vectors compares `norm(x - y)`, a 2-norm over the whole vector, so the same +per-entry error fails on a bigger system purely because there are more entries. Every tolerance +here is a per-quantity statement — `_assert_steps_separated` below already reads +`LCC_PARITY_ATOL` that way — so compare element-wise. +""" +_parity(x, y; atol = LCC_PARITY_ATOL) = all(isapprox.(x, y; atol = atol)) """Rebuild the fixture with both controlled devices hard-locked at the settings `results` reports for time step `ts`, so it can be solved with control off. The shunt locks through its @@ -249,15 +213,6 @@ function _set_lcc_bus101_load_at_step!(data, t::Int) return end -"""Element-wise parity: every entry within `atol`. - -`isapprox` on two vectors compares `norm(x - y)`, a 2-norm over the whole vector, so the same -per-entry error fails on a bigger system purely because there are more entries. Every tolerance -here is a per-quantity statement — `_assert_steps_separated` below already reads -`LCC_PARITY_ATOL` that way — so compare element-wise. -""" -_parity(x, y; atol = LCC_PARITY_ATOL) = all(isapprox.(x, y; atol = atol)) - """Assert each pair of time-step columns in `pairs` is separated by more than the parity tolerance, so comparing against the wrong column would be caught. LCC converter state is not guarded: its terminal buses are voltage-pinned, so those columns are identical across steps diff --git a/test/test_mixed_cpb_flat_start.jl b/test/test_mixed_cpb_flat_start.jl index 6ecc9b76..352d61a7 100644 --- a/test/test_mixed_cpb_flat_start.jl +++ b/test/test_mixed_cpb_flat_start.jl @@ -2,7 +2,7 @@ # start, validated on the mixed (e, f) 2-slot layout. const MIXED_FS_PARITY_ATOL = 1e-7 -_mixed_fs_settings() = Dict{Symbol, Any}(:validate_voltage_magnitudes => false) +_mixed_fs_settings() = SolutionParameters(; validate_voltage_magnitudes = false) # Perturb the stored bus voltages of `sys` far from a flat 1∠0 start. Only the # *initial guess* changes: PQ |V|/θ and PV θ are not physical unknowns' @@ -30,7 +30,7 @@ end pf_h = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; enhanced_flat_start = true, - solver_settings = _mixed_fs_settings(), + solution_parameters = _mixed_fs_settings(), ) pf_ref = ACPowerFlow{NewtonRaphsonACPowerFlow}() @@ -52,7 +52,7 @@ end @testset "Mixed CPB flat start: modified-flat-start construction" begin sys = _mixed_perturb!(PSB.build_system(PSB.PSITestSystems, "c_sys5")) pf = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; - enhanced_flat_start = true, solver_settings = _mixed_fs_settings()) + enhanced_flat_start = true, solution_parameters = _mixed_fs_settings()) data = PowerFlowData(pf, sys) residual = PF.ACMixedCPBResidual(data, 1) @@ -115,7 +115,7 @@ end @testset "Mixed CPB flat start: multi-period warm start" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) pf = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; - time_steps = 2, solver_settings = _mixed_fs_settings()) + time_steps = 2, solution_parameters = _mixed_fs_settings()) data = PowerFlowData(pf, sys) # Converge step 1 in a single-step copy and inject its solution into the @@ -124,7 +124,7 @@ end # same loads, so step-1's converged state IS step-2's solution. d1 = PowerFlowData( ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _mixed_fs_settings()), sys) + solution_parameters = _mixed_fs_settings()), sys) @test PowerFlows.solve_power_flow!(d1) data.bus_magnitude[:, 1] .= d1.bus_magnitude[:, 1] data.bus_angles[:, 1] .= d1.bus_angles[:, 1] diff --git a/test/test_mixed_cpb_jacobian.jl b/test/test_mixed_cpb_jacobian.jl index 68ecd105..dff88c22 100644 --- a/test/test_mixed_cpb_jacobian.jl +++ b/test/test_mixed_cpb_jacobian.jl @@ -126,12 +126,12 @@ end end function _build_mixed_lcc_x(sys; correct_bustypes = false) - settings = Dict{Symbol, Any}(:validate_voltage_magnitudes => false) + settings = SolutionParameters(; validate_voltage_magnitudes = false) pf_polar = ACPowerFlow{NewtonRaphsonACPowerFlow}(; - correct_bustypes = correct_bustypes, solver_settings = settings) + correct_bustypes = correct_bustypes, solution_parameters = settings) @test PF.solve_and_store_power_flow!(pf_polar, sys) pf_mixed = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; - correct_bustypes = correct_bustypes, solver_settings = settings) + correct_bustypes = correct_bustypes, solution_parameters = settings) data = PF.PowerFlowData(pf_mixed, sys) R = PF.ACMixedCPBResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) diff --git a/test/test_mixed_cpb_lcc.jl b/test/test_mixed_cpb_lcc.jl index 48ea6b3d..7a1e8beb 100644 --- a/test/test_mixed_cpb_lcc.jl +++ b/test/test_mixed_cpb_lcc.jl @@ -1,5 +1,5 @@ function _mixed_lcc_settings() - return Dict{Symbol, Any}(:validate_voltage_magnitudes => false) + return SolutionParameters(; validate_voltage_magnitudes = false) end # Map the polar-converged solution stored in `sys` into the MCPB state vector @@ -13,12 +13,12 @@ function _mixed_lcc_residual_norm( ) pf_polar = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = correct_bustypes, - solver_settings = _mixed_lcc_settings(), + solution_parameters = _mixed_lcc_settings(), ) @test PF.solve_and_store_power_flow!(pf_polar, sys) pf_mixed = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = correct_bustypes, - solver_settings = _mixed_lcc_settings(), + solution_parameters = _mixed_lcc_settings(), ) data = PF.PowerFlowData(pf_mixed, sys) R = PF.ACMixedCPBResidual(data, 1) diff --git a/test/test_mixed_cpb_polar_parity.jl b/test/test_mixed_cpb_polar_parity.jl index 2c874309..09263f63 100644 --- a/test/test_mixed_cpb_polar_parity.jl +++ b/test/test_mixed_cpb_polar_parity.jl @@ -4,104 +4,9 @@ # `test_rectangular_ci_polar_parity.jl`'s fixture matrix, extended to validate # BOTH the Newton-Raphson and Trust-Region solvers on the MCPB Jacobian. # -# This file is the single home of the `_mixed_polar_parity` / -# `_mixed_polar_parity_data` helpers and the `MIXED_PARITY_ATOL` / -# `_mixed_pf_settings` definitions: the ReTest runner auto-includes every -# `test_*.jl` into one module, so these must be defined exactly once. - -const MIXED_PARITY_ATOL = 1e-7 -# ACTIVSg2000: zero-injection buses with G_ii ≈ 0 make the MCPB Jacobian more -# ill-conditioned than the small synthetic systems. The imag-first column -# ordering + KLU partial pivoting keep it solvable, but the converged-state -# round-off floor is looser than 1e-7; 1e-5 still pins formulation parity. -const MIXED_PARITY_ATOL_2K = 1e-5 - -_mixed_pf_settings() = Dict{Symbol, Any}(:validate_voltage_magnitudes => false) - -# Assert MCPB matches polar (and, when requested, rectangular CI) on the four -# reported bus quantities, for an arbitrary AC solver. Mirrors -# `_rect_polar_parity`, parametrized over `solver` so the same fixture matrix -# validates Newton-Raphson and Trust-Region against the MCPB Jacobian. -function _mixed_polar_parity( - sys_p::PSY.System, - sys_h::PSY.System; - sys_r::Union{Nothing, PSY.System} = nothing, - pf_kwargs::NamedTuple = NamedTuple(), - atol::Float64 = MIXED_PARITY_ATOL, - solver = NewtonRaphsonACPowerFlow, -) - pf_p = ACPowerFlow{solver}(; pf_kwargs...) - pf_h = ACMixedPowerFlow{solver}(; - pf_kwargs..., - solver_settings = _mixed_pf_settings(), - ) - res_p = solve_power_flow(pf_p, sys_p) - res_h = solve_power_flow(pf_h, sys_h) - @test res_p !== missing - @test res_h !== missing - bus_p = res_p["bus_results"] - bus_h = res_h["bus_results"] - @test maximum(abs.(bus_p.Vm .- bus_h.Vm)) < atol - @test maximum(abs.(bus_p.θ .- bus_h.θ)) < atol - # P_gen / Q_gen parity catches slack-recovery and Q-writeback bugs that - # Vm/θ parity alone cannot — the internal residual math can converge to the - # correct voltages while the reported generator outputs disagree. - @test maximum(abs.(bus_p.P_gen .- bus_h.P_gen)) < atol - @test maximum(abs.(bus_p.Q_gen .- bus_h.Q_gen)) < atol - if sys_r !== nothing - pf_r = ACRectangularPowerFlow{solver}(; - pf_kwargs..., - solver_settings = _mixed_pf_settings(), - ) - res_r = solve_power_flow(pf_r, sys_r) - @test res_r !== missing - bus_r = res_r["bus_results"] - @test maximum(abs.(bus_r.Vm .- bus_h.Vm)) < atol - @test maximum(abs.(bus_r.θ .- bus_h.θ)) < atol - @test maximum(abs.(bus_r.P_gen .- bus_h.P_gen)) < atol - @test maximum(abs.(bus_r.Q_gen .- bus_h.Q_gen)) < atol - end - return -end - -# Multi-period analogue of `_mixed_polar_parity`: solve both formulations in -# place and assert full per-time-step state-array parity. Exercises the minimal -# per-step `improve_x0` / per-ts offsets & caches (`time_step` threaded -# correctly). This checks per-step correctness, not warm-start efficiency. -function _mixed_polar_parity_data( - pf_p::ACPowerFlow, - pf_h::PF.ACMixedPowerFlow, - sys_p::PSY.System, - sys_h::PSY.System; - atol::Float64 = MIXED_PARITY_ATOL, -) - data_p = PowerFlowData(pf_p, sys_p) - data_h = PowerFlowData(pf_h, sys_h) - @test PowerFlows.solve_power_flow!(data_p) - @test PowerFlows.solve_power_flow!(data_h) - n_ts = size(data_p.bus_magnitude, 2) - for ts in 1:n_ts - @test maximum( - abs.(data_p.bus_magnitude[:, ts] - data_h.bus_magnitude[:, ts]), - ) < atol - @test maximum( - abs.(data_p.bus_angles[:, ts] - data_h.bus_angles[:, ts]), - ) < atol - @test maximum( - abs.( - data_p.bus_active_power_injections[:, ts] - - data_h.bus_active_power_injections[:, ts] - ), - ) < atol - @test maximum( - abs.( - data_p.bus_reactive_power_injections[:, ts] - - data_h.bus_reactive_power_injections[:, ts] - ), - ) < atol - end - return -end +# The `_mixed_polar_parity` / `_mixed_polar_parity_data` helpers and the +# `MIXED_PARITY_ATOL` / `_mixed_pf_settings` definitions live in +# `test_utils/cross_file_fixtures.jl` (also used by test_mixed_cpb_power_flow.jl). # Solvers validated against the MCPB Jacobian: Newton-Raphson and Trust-Region # (the shared `_trust_region_step` dogleg runs on the mixed sparse J unchanged). @@ -222,10 +127,10 @@ end pf_polar = ACPolarPowerFlow{LevenbergMarquardtACPowerFlow}() res_polar = solve_power_flow(pf_polar, deepcopy(sys)) pf_rect = ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}(; - solver_settings = _mixed_pf_settings()) + solution_parameters = _mixed_pf_settings()) res_rect = solve_power_flow(pf_rect, deepcopy(sys)) pf_mixed = ACMixedPowerFlow{LevenbergMarquardtACPowerFlow}(; - solver_settings = _mixed_pf_settings()) + solution_parameters = _mixed_pf_settings()) res_mixed = solve_power_flow(pf_mixed, deepcopy(sys)) @test res_polar !== missing @test res_rect !== missing diff --git a/test/test_mixed_cpb_power_flow.jl b/test/test_mixed_cpb_power_flow.jl index 46682c53..96586131 100644 --- a/test/test_mixed_cpb_power_flow.jl +++ b/test/test_mixed_cpb_power_flow.jl @@ -2,11 +2,10 @@ # validated against polar NR and rectangular CI NR within a tight 1e-7 parity # tolerance (Vm/θ/P_gen/Q_gen). # -# The shared parity helpers (`_mixed_polar_parity`, -# `_mixed_polar_parity_data`), the `MIXED_PARITY_ATOL` constant and -# `_mixed_pf_settings` live in `test_mixed_cpb_polar_parity.jl` (single -# definition site — the ReTest runner auto-includes every `test_*.jl` into one -# module). This file only exercises the NR-specific scenario coverage. +# The shared parity helpers (`_mixed_polar_parity`, `_mixed_polar_parity_data`), the +# `MIXED_PARITY_ATOL` constant and `_mixed_pf_settings` live in +# `test_utils/cross_file_fixtures.jl`. This file only exercises the NR-specific scenario +# coverage. @testset "Mixed CPB Power Flow: NR parity with polar and rectangular" begin fixtures = [ @@ -112,7 +111,7 @@ end pf_h = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, enhanced_flat_start = true, - solver_settings = _mixed_pf_settings(), + solution_parameters = _mixed_pf_settings(), ) res_p = solve_power_flow(pf_p, sys_p) res_h = solve_power_flow(pf_h, sys_h) @@ -130,7 +129,7 @@ end pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 3) pf_h = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 3, - solver_settings = _mixed_pf_settings(), + solution_parameters = _mixed_pf_settings(), ) _mixed_polar_parity_data(pf_p, pf_h, sys_p, sys_h) end @@ -150,27 +149,10 @@ end time_steps = 2, generator_slack_participation_factors = spf) pf_h = ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 2, generator_slack_participation_factors = spf, - solver_settings = _mixed_pf_settings()) + solution_parameters = _mixed_pf_settings()) _mixed_polar_parity_data(pf_p, pf_h, sys_p, sys_h) end -# Same topology as polar's `_two_swing_system()` (test_jacobian.jl), named distinctly -# since ReTest merges every test_*.jl into one module. The nonzero swing-2 angle -# exercises real off-diagonal ∂P/∂θ terms. Shared by test_mixed_cpb_jacobian.jl and -# test_mixed_cpb_polar_parity.jl. -function _two_swing_mixed_system() - sys = System(100.0) - b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.06, 0.0) - b2 = _add_simple_bus!(sys, 2, ACBusTypes.REF, 230, 1.05, 0.05) - b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230, 1.0, 0.0) - _add_simple_source!(sys, b1, 0.0, 0.0) - _add_simple_source!(sys, b2, 0.0, 0.0) - _add_simple_load!(sys, b3, 40, 15) - _add_simple_line!(sys, b1, b3, 5e-3, 5e-3, 1e-3) - _add_simple_line!(sys, b2, b3, 5e-3, 5e-3, 1e-3) - return sys -end - @testset "Mixed CPB Power Flow: multi-swing (two swings in one island each self-balance)" begin @testset "$(V)" for V in (NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow) sys = _two_swing_mixed_system() diff --git a/test/test_nr_cache_reuse.jl b/test/test_nr_cache_reuse.jl index e255b02b..87e758b4 100644 --- a/test/test_nr_cache_reuse.jl +++ b/test/test_nr_cache_reuse.jl @@ -24,9 +24,18 @@ # (generator on "Bus8" violates Q-max — see test_solve_power_flow.jl). @test any(data.bus_type[:, 1] .!= original_bus_types) - # Fresh from-scratch reference solve of the same config must agree. + # Reference: replicate the retry loop on a fresh `data_ref`, forcing + # `polar_nr_cache` to `nothing` before every retry so it never reuses. data_ref = PowerFlows.PowerFlowData(pf, sys) - converged_ref = PowerFlows._ac_power_flow(data_ref, pf, 1) + converged_ref = false + for _ in 1:(PowerFlows.MAX_REACTIVE_POWER_ITERATIONS) + data_ref.polar_nr_cache[] = nothing + converged_ref = PowerFlows._newton_power_flow(pf, data_ref, 1) + if !converged_ref || !PowerFlows.get_check_reactive_power_limits(pf) || + PowerFlows._check_q_limit_bounds!(data_ref, 1) + break + end + end @test converged_ref x_ref = _calc_x(data_ref, 1) @@ -82,3 +91,149 @@ end end end end + +@testset "NR cache reuse: LCC systems reuse the polar workspace across time steps" begin + # An LCC system reuses the polar workspace (J, linSolveCache) across time steps. + sys, _ = simple_lcc_system() + time_steps = 6 + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + time_steps = time_steps, + correct_bustypes = true, + ) + data = PowerFlowData(pf, sys) + # `prepare_ts_data!` is c_sys14-specific (hardcoded CSV shape); this system's per-step + # data starts as every column equal to the snapshot, so perturb each step distinctly + # so every step must actually iterate. + for t in 1:time_steps + data.bus_active_power_injections[:, t] .*= (1.0 + 0.001 * t) + end + @test solve_power_flow!(data) + cache = data.polar_nr_cache[] + @test !isnothing(cache) + J_before, lsc_before = cache.J, cache.linSolveCache + + data.bus_active_power_injections[:, time_steps] .*= 1.0007 + @test PowerFlows._newton_power_flow(pf, data, time_steps) + cache_after = data.polar_nr_cache[] + @test cache_after.J === J_before + @test cache_after.linSolveCache === lsc_before +end + +@testset "NR cache reuse: VSC Jacobian ∂KCL/∂|V_ac| slot clears when the bus returns to PV" begin + # A Jacobian refilled in place across a PV→PQ→PV flip matches a fresh PV build. + sys = _vsc_system_pv_terminal() + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) + data = PowerFlowData(pf, sys) + dcn = PowerFlows.get_dc_network(data) + n = first(size(data.bus_type)) + nconv = PowerFlows.n_vsc_converters(dcn) + base = 2n + 2nconv + c = findfirst( + cc -> data.bus_type[dcn.converter_ac_bus_ix[cc], 1] == PSY.ACBusTypes.PV, + 1:nconv) + @test !isnothing(c) + ix = dcn.converter_ac_bus_ix[c] + vk = base + dcn.converter_dc_node_ix[c] + col = 2 * ix - 1 + + residual = PowerFlows.ACPowerFlowResidual(data, 1) + jac = PowerFlows.ACPowerFlowJacobian(residual, 1) + x = PowerFlows.calculate_x0(data, 1) + residual(x, 1) + jac(1) + @test jac.Jv[vk, col] == 0.0 # fresh PV build: structural zero + + data.bus_type[ix, 1] = PSY.ACBusTypes.PQ + jac(1) + @test jac.Jv[vk, col] != 0.0 # PQ: the loss-coupling derivative now enters + + data.bus_type[ix, 1] = PSY.ACBusTypes.PV + jac(1) # refilled IN PLACE, same Jacobian object, as a reused cache would do + @test jac.Jv[vk, col] == 0.0 # must match a fresh PV build, not the stale PQ value +end + +@testset "Singular-Jacobian fallback matrix and factorization reuse" begin + # The fallback matrix `M` shares its pattern with `Jᵀ*J`, so a later Jacobian with the + # same structural pattern but different values refreshes `M` in place. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + data = PowerFlowData(pf, sys) + residual = PowerFlows.ACPowerFlowResidual(data, 1) + jac = PowerFlows.ACPowerFlowJacobian(residual, 1) + x = PowerFlows.calculate_x0(data, 1) + residual(x, 1) + jac(1) + + M = PowerFlows._build_singular_J_fallback(jac.Jv, x) + F = jac.Jv' * jac.Jv + @test SparseArrays.nnz(M) == SparseArrays.nnz(F) + @test M.colptr == F.colptr && M.rowval == F.rowval + + # A later Newton iterate has the SAME structural pattern but different numeric values. + Jv2 = copy(jac.Jv) + SparseArrays.nonzeros(Jv2) .+= 1e-4 .* Random.randn(SparseArrays.nnz(Jv2)) + @test PowerFlows._refresh_singular_J_fallback!(copy(M), Jv2, x) +end + +@testset "Discrete-control symbolic factorization count is honest" begin + # Each real symbolic factorization in the continuation is counted exactly once. + sys = _make_solvable_tap_shunt_system() + pf = ACPolarPowerFlow(; control_discrete_devices = true) + data = PowerFlowData(pf, sys) + @test solve_power_flow!(data) + @test PowerFlows.get_control_symbolic_factor_count(data) == 2 +end + +@testset "NR cache reuse: rectangular/mixed formulations reuse the linear-solver cache" begin + for (FormulationT, label) in ( + (ACRectangularPowerFlow, "rectangular"), (ACMixedPowerFlow, "mixed"), + ) + @testset "$label" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) + pf = FormulationT{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + data = PowerFlowData(pf, sys) + @test solve_power_flow!(data) + cache = data.solver_cache[] + @test typeof(cache).name.wrapper === PowerFlows.RectMixedNRCache + lsc_before = cache.linSolveCache + sv_before = cache.stateVector + + # Bus types (hence the Jacobian pattern) are unchanged by an injection-only + # perturbation, so the symbolic factorization and state-vector buffers are reused. + data.bus_active_power_injections[:, 1] .*= 1.001 + @test solve_power_flow!(data) + cache_after = data.solver_cache[] + @test cache_after === cache + @test cache_after.linSolveCache === lsc_before + @test cache_after.stateVector === sv_before + + # A PV→PQ Q-limit flip changes the per-bus block size (rect PV is 3 slots vs PQ's 2), + # which must invalidate the cache rather than reuse a mismatched pattern. + pf_q = FormulationT{NewtonRaphsonACPowerFlow}(; + correct_bustypes = true, check_reactive_power_limits = true) + sys_q = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) + data_q = PowerFlowData(pf_q, sys_q) + original_bus_types = deepcopy(data_q.bus_type[:, 1]) + @test PowerFlows._ac_power_flow(data_q, pf_q, 1) + @test any(data_q.bus_type[:, 1] .!= original_bus_types) + @test typeof(data_q.solver_cache[]).name.wrapper === PowerFlows.RectMixedNRCache + end + end +end + +@testset "Polar residual and Jacobian hold a concretely typed data field" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + data = PowerFlowData(pf, sys) + residual = PowerFlows.ACPowerFlowResidual(data, 1) + J = PowerFlows.ACPowerFlowJacobian(residual, 1) + @test isconcretetype(fieldtype(typeof(residual), :data)) + @test isconcretetype(fieldtype(typeof(J), :data)) + x0 = PowerFlows.calculate_x0(data, 1) + residual(x0, 1) + J(1) + @test (@allocated residual(x0, 1)) == 0 + @test (@allocated J(1)) == 0 +end diff --git a/test/test_pardiso_backend.jl b/test/test_pardiso_backend.jl index 6f67b2f3..6a5e62d5 100644 --- a/test/test_pardiso_backend.jl +++ b/test/test_pardiso_backend.jl @@ -27,14 +27,14 @@ import Pardiso sys = build_system(PSITestSystems, "c_sys14") res_klu = solve_power_flow( ACPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:linear_solver => "KLU")), + solution_parameters = SolutionParameters(; linear_solver = "KLU")), sys, ) for solver in (NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow) res_par = solve_power_flow( ACPowerFlow{solver}(; - solver_settings = Dict{Symbol, Any}( - :linear_solver => "MKLPardiso")), + solution_parameters = SolutionParameters(; + linear_solver = "MKLPardiso")), sys, ) @test isapprox( diff --git a/test/test_post_processing.jl b/test/test_post_processing.jl index e31fccd3..fa297737 100644 --- a/test/test_post_processing.jl +++ b/test/test_post_processing.jl @@ -324,3 +324,71 @@ end ) end end + +@testset "update_system! after correct_bustypes demotes a source-less PV bus to PQ" begin + # Bus 2 is declared PV but has no available generator, so `correct_bustypes` demotes it + # to PQ in `data.bus_type` without touching `bus.bustype`. `update_system!` must not try + # to redistribute reactive power onto a device that doesn't exist. + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PV, 230, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 10.0, 2.0) + _add_simple_line!(sys, b1, b2, 0.01, 0.1, 0.0) + + pf = PF.ACPowerFlow{PF.NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + data = PF.PowerFlowData(pf, sys) + @test PF.solve_power_flow!(data) + @test isnothing(PF.update_system!(sys, data)) + @test get_bustype(get_component(ACBus, sys, "bus_2")) == ACBusTypes.PV +end + +@testset "REF redistribution errors loudly on zero-sum slack participation factors" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230, 1.0, 0.0) + _add_simple_line!(sys, b1, b2, 0.01, 0.1, 0.0) + _add_simple_load!(sys, b2, 10.0, 2.0) + g1 = _add_simple_thermal_standard!(sys, b1, 0.05, 0.01) + g2 = _add_simple_thermal_standard!(sys, b1, 0.05, 0.01) + gspf = Dict{Tuple{DataType, String}, Float64}( + (typeof(g1), get_name(g1)) => 0.0, + (typeof(g2), get_name(g2)) => 0.0, + ) + @test_throws ErrorException PF._power_redistribution_ref( + sys, 0.12, 0.02, b1, PF.DEFAULT_MAX_REDISTRIBUTION_ITERATIONS, + PF._build_bus_injector_map(sys), gspf, + ) + # No generator's active power was corrupted to NaN before the error. + @test !isnan(get_active_power(g1, PSY.SU)) + @test !isnan(get_active_power(g2, PSY.SU)) +end + +@testset "AC write_results: Q_load includes switched-shunt withdrawal" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") + pq_buses = sort( + collect(get_components(x -> get_bustype(x) == ACBusTypes.PQ, ACBus, sys)); + by = get_number, + ) + b = last(pq_buses) + add_component!( + sys, + SwitchedAdmittance(; name = "sa_test", available = true, bus = b, + number_engaged = [1], number_of_steps = [1], Y_increase = [0.0 + 0.2im]), + ) + + res = solve_power_flow(ACPowerFlow(), sys) + bus_df = res["bus_results"] + fl = res["flow_results"] + row = only(eachrow(filter(:bus_number => ==(get_number(b)), bus_df))) + p_br = + sum(fl.P_from_to[fl.bus_from .== get_number(b)]; init = 0.0) + + sum(fl.P_to_from[fl.bus_to .== get_number(b)]; init = 0.0) + q_br = + sum(fl.Q_from_to[fl.bus_from .== get_number(b)]; init = 0.0) + + sum(fl.Q_to_from[fl.bus_to .== get_number(b)]; init = 0.0) + # Q_net must balance against the branch flows leaving the bus; before the fix, Q_load + # omitted the shunt's withdrawal and this residual was off by ~-B*Vm^2*base_power. + @test isapprox(row.P_net, p_br; atol = 1e-3) + @test isapprox(row.Q_net, q_br; atol = 1e-3) +end diff --git a/test/test_psi_utils.jl b/test/test_psi_utils.jl index 8626e8fb..16045d3b 100644 --- a/test/test_psi_utils.jl +++ b/test/test_psi_utils.jl @@ -34,3 +34,11 @@ append!(device_types, InteractiveUtils.subtypes(T)) end end + +@testset "SwitchedAdmittance: empty `number_engaged` is 0, a mismatched length errors" begin + y_increase = ComplexF64[0.01 + 0.02im, 0.03 + 0.04im] + @test PF._switched_admittance(nothing, Int[], y_increase) == 0.0 + 0.0im + @test PF._switched_admittance(nothing, [1, 2], y_increase) == + (0.01 + 0.02im) + 2 * (0.03 + 0.04im) + @test_throws DimensionMismatch PF._switched_admittance(nothing, [1], y_increase) +end diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index bc729d88..298e8995 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -184,7 +184,6 @@ function compare_systems_loosely(sys1::PSY.System, sys2::PSY.System; :active_power_limits_to, :reactive_power_limits_from, :reactive_power_limits_to, - :transfer_setpoint, ]), # PowerFlowFileParser does not preserve the v33 FACTS SHMX/TRMX fields during # re-import; both PSY fields are reconstructed from the parser's 9999.0 default. @@ -420,17 +419,32 @@ function test_psse_export_strict_equality( exclude_metadata_keys = ["case_name"], exclude_export_settings_keys = ["original_name"], ) + parsed1 = JSON3.read(metadata1, Dict) + parsed2 = JSON3.read(metadata2, Dict) + case_name1 = parsed1["case_name"] + case_name2 = parsed2["case_name"] + open(raw1, "r") do handle1 open(raw2, "r") do handle2 - @test countlines(handle1) == countlines(handle2) - for (line1, line2) in zip(readlines(handle1), readlines(handle2)) + # `countlines` reads a handle to EOF; comparing lengths from the same + # `readlines` vectors (rather than `countlines` first) is what makes the + # per-line comparison below see anything at all. The case-identification + # record carries the export's own name and a `write_comments` header carries + # a wall-clock timestamp; both legitimately differ between calls, mirroring + # `exclude_metadata_keys` on the JSON side below. + lines1 = readlines(handle1) + lines2 = readlines(handle2) + @test length(lines1) == length(lines2) + timestamp_re = r"RAW via PowerFlows\.jl, \d{4}-\d{2}-\d{2}T" + for (line1, line2) in zip(lines1, lines2) + (line1 == case_name1 && line2 == case_name2) && continue + (occursin(timestamp_re, line1) && occursin(timestamp_re, line2)) && + continue @test line1 == line2 end end end - parsed1 = JSON3.read(metadata1, Dict) - parsed2 = JSON3.read(metadata2, Dict) for key in exclude_metadata_keys parsed1[key] = nothing parsed2[key] = nothing @@ -479,24 +493,6 @@ function test_psse_exporter_version(sys_name::String, version::Symbol, folder_na get_psse_export_paths(joinpath(export_location, "basic2"))...) end -# Test configurations: (test_name, sys_name, version, folder_name) -# ReTest chokes on @testset over a loop. -#= -test_configs = [ - ( - "PSSE Exporter with case16_sys.raw, v33", - "pti_case16_complete_sys", - :v33, - "case16_sys.raw", - ), - ( - "PSSE Exporter with modified_case25_sys.raw, v35", - "pti_modified_case25_v35_sys", - :v35, - "modified_case25_sys.raw", - ), -]=# - @testset "PSSE Exporter with case16_sys.raw, v33" begin test_psse_exporter_version("pti_case16_complete_sys", :v33, "case16_sys.raw") end @@ -944,6 +940,7 @@ function test_psse_exporter_inner( @test_logs((:error, r"values do not match"), match_mode = :any, min_level = Logging.Error, compare_systems_loosely(sys, reread_sys2)) + @test compare_systems_loosely(sys2, reread_sys2) test_power_flow(pf, sys2, reread_sys2; exclude_reactive_flow = true) end @@ -1108,3 +1105,148 @@ end @test occursin("ITMXN=17", text) @test occursin("TOLN=0.001", text) end + +@testset "PSSE Exporter: LCC SETVL is written in stored MW, not scaled by SBASE" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 0.1, 0.05) + lcc = _add_simple_lcc!(sys, b1, b2, 0.01, 0.01, 0.01) # transfer_setpoint = 50 (MW) + + export_location = joinpath(test_psse_export_dir, "v35", "lcc_setvl") + exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) + write_export(exporter, "lcc_setvl"; overwrite = true) + raw_path, _ = get_psse_export_paths(joinpath(export_location, "lcc_setvl")) + lcc_line = only(filter(l -> occursin("LCC", l), readlines(raw_path))) + fields = PF._split_record(lcc_line) + @test parse(Float64, strip(fields[4])) ≈ 50.0 # SETVL, not 50 * SBASE = 5000 + + sys2 = read_system_with_metadata(joinpath(export_location, "lcc_setvl")) + lcc2 = only(PSY.get_components(PSY.TwoTerminalLCCLine, sys2)) + @test PSY.get_transfer_setpoint(lcc2) ≈ PSY.get_transfer_setpoint(lcc) +end + +@testset "PSSE Exporter: 3W transformer built from star circuits writes no `nothing` field" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) + b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 138.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 0.1, 0.05) + _add_simple_load!(sys, b3, 0.05, 0.02) + xfmr = _add_simple_transformer_3w!(sys, b1, b2, b3, 90) + # The pairwise fields are `nothing` when a 3W is built directly from star-leg circuits. + @test isnothing(PSY.get_r_12(xfmr, PSY.SU)) + + export_location = joinpath(test_psse_export_dir, "v35", "xfmr3w_no_pairwise") + exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) + write_export(exporter, "xfmr3w"; overwrite = true) + raw_path, _ = get_psse_export_paths(joinpath(export_location, "xfmr3w")) + @test !occursin("nothing", read(raw_path, String)) +end + +@testset "PSSE Exporter: generic HVDC synthetic generators follow PF's own injection sign" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 0.0, 0.0) + hvdc = TwoTerminalGenericHVDCLine(; + name = "hvdc_1_2", + available = true, + active_power_flow = 30.0, + arc = Arc(b1, b2), + active_power_limits_from = (min = -100.0, max = 100.0), + active_power_limits_to = (min = -100.0, max = 100.0), + reactive_power_limits_from = (min = 0.0, max = 0.0), + reactive_power_limits_to = (min = 0.0, max = 0.0), + base_power = 100.0, + ) + add_component!(sys, hvdc) + + # PF's own convention: FROM withdraws from the AC network, TO supplies it. + (P_from, P_to) = PF.get_hvdc_injections(hvdc, sys) + @test P_from < 0.0 && P_to > 0.0 + + export_location = joinpath(test_psse_export_dir, "v35", "generic_hvdc_sign") + exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) + write_export(exporter, "generic_hvdc_sign"; overwrite = true) + sys2 = read_system_with_metadata(joinpath(export_location, "generic_hvdc_sign")) + + gen_from = + only( + PSY.get_components( + g -> endswith(PSY.get_name(g), "_FR"), + ThermalStandard, + sys2, + ), + ) + gen_to = + only( + PSY.get_components( + g -> endswith(PSY.get_name(g), "_TO"), + ThermalStandard, + sys2, + ), + ) + @test PSY.get_active_power(gen_from, PSY.SU) ≈ P_from + @test PSY.get_active_power(gen_to, PSY.SU) ≈ P_to +end + +@testset "PSSE Exporter: droop VSC DCSET follows the AC-supply sign convention" begin + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 0.0, 0.0) + vsc = _add_simple_vsc!(sys, b1, b2; active_power_flow = 0.2) + # One converter must keep a real DC-voltage reference or the record has no TYPE-1 + # terminal and re-parsing rejects it; only the `to` side is droop, the case under test. + PSY.set_dc_control_from!(vsc, PSY.VSCDCControlModes.DC_VOLTAGE) + PSY.set_dc_control_to!(vsc, PSY.VSCDCControlModes.DC_VOLTAGE_DROOP) + + export_location = joinpath(test_psse_export_dir, "v35", "vsc_droop_dcset") + exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) + write_export(exporter, "vsc_droop"; overwrite = true) + sys2 = read_system_with_metadata(joinpath(export_location, "vsc_droop")) + vsc2 = only(PSY.get_components(PSY.TwoTerminalVSCLine, sys2)) + # The format has no droop mode, so the `to` side reimports as DC_POWER, whose own + # convention is positive == supplies the AC network at that bus. Flow is FROM -> TO + # (positive), so `to` supplies its AC network: the setpoint must be positive. + @test PSY.get_dc_control_to(vsc2) == PSY.VSCDCControlModes.DC_POWER + @test PSY.get_dc_setpoint_to(vsc2) > 0.0 +end + +@testset "PSSE Exporter: switching-device RATE1 round-trips through SBASE" begin + # PFFP's switch/breaker importer stores RATE1 unscaled, so a 12.06 CU rating must + # export as 12.06, not 1206.0, to round-trip. + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 0.1, 0.05) + arc = Arc(; from = b1, to = b2) + add_component!(sys, arc) + sw = DiscreteControlledACBranch(; + name = "brk_1_2", + available = true, + active_power_flow = 0.0, + reactive_power_flow = 0.0, + arc = arc, + r = 0.001, + x = 0.01, + rating = 12.06, # 1206 MVA on a 100 MVA system base + discrete_branch_type = DiscreteControlledBranchType.BREAKER, + branch_status = DiscreteControlledBranchStatus.CLOSED, + ) + add_component!(sys, sw) + @test PSY.get_rating(sw, PSY.NU) ≈ 1206.0 + + export_location = joinpath(test_psse_export_dir, "v35", "breaker_rate1") + exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) + write_export(exporter, "breaker_rate1"; overwrite = true) + sys2 = read_system_with_metadata(joinpath(export_location, "breaker_rate1")) + sw2 = only(PSY.get_components(PSY.DiscreteControlledACBranch, sys2)) + @test PSY.get_rating(sw2, PSY.NU) ≈ PSY.get_rating(sw, PSY.NU) +end diff --git a/test/test_psse_solution_records.jl b/test/test_psse_solution_records.jl index 3bd5a1a5..cee67766 100644 --- a/test/test_psse_solution_records.jl +++ b/test/test_psse_solution_records.jl @@ -185,3 +185,72 @@ end @test v.itmxn == 42 @test v.toln == 0.5 end + +@testset "A non-blank second title line does not hide the solution-record block" begin + dir = mktempdir() + path = joinpath(dir, "case.raw") + open(path, "w") do io + println(io, "0, 100.0, 35, 0, 1, 60.0") + println(io, "a case") + println(io, "2024, SUMMER PEAK") # a bare leading integer, unlike a blank title line + print(io, SOLUTION_RECORDS_GOLDEN_DEFAULT) + println(io, "0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA") + end + v = PF.read_solution_records(path) + @test !isnothing(v) + @test v.itmxn == 20 +end + +@testset "FLATST is parsed but not applied; PowerFlows' own default stands" begin + dir = mktempdir() + path = joinpath(dir, "case.raw") + open(path, "w") do io + println(io, "0, 100.0, 35, 0, 1, 60.0") + println(io, "a case") + println(io) + println( + io, + "SOLVER, FNSL, ACTAPS=0, AREAIN=0, PHSHFT=0, DCTAPS=0, SWSHNT=0, " * + "FLATST=0, VARLIM=0, NONDIV=0", + ) + println(io, "0 / END") + end + v = PF.read_solution_records(path) + @test v.flatst == 0 # the raw record field is still parsed + params = read_solution_parameters(path) + @test params.enhanced_flat_start == SolutionParameters().enhanced_flat_start +end + +@testset "Discrete control under fast-decoupled solving is dropped, not passed through" begin + dir = mktempdir() + path = joinpath(dir, "case.raw") + open(path, "w") do io + println(io, "0, 100.0, 35, 0, 1, 60.0") + println(io, "a case") + println(io) + println( + io, + "SOLVER, FDNS, ACTAPS=1, AREAIN=0, PHSHFT=0, DCTAPS=0, SWSHNT=1, " * + "FLATST=0, VARLIM=0, NONDIV=0", + ) + println(io, "0 / END") + end + params = + @test_logs (:warn, r"fast-decoupled") match_mode = :any read_solution_parameters( + path, + ) + @test !params.control_discrete_devices +end + +@testset "An unparseable case SBASE errors instead of defaulting to 100 MVA" begin + dir = mktempdir() + path = joinpath(dir, "case.raw") + open(path, "w") do io + println(io, "0, notanumber, 35, 0, 1, 60.0") + println(io, "a case") + println(io) + print(io, SOLUTION_RECORDS_GOLDEN_DEFAULT) + println(io, "0 / END") + end + @test_throws ErrorException read_solution_parameters(path) +end diff --git a/test/test_rectangular_ci_jacobian.jl b/test/test_rectangular_ci_jacobian.jl index 959b3aaa..02d7604f 100644 --- a/test/test_rectangular_ci_jacobian.jl +++ b/test/test_rectangular_ci_jacobian.jl @@ -52,7 +52,7 @@ PF.solve_and_store_power_flow!(pf_polar, sys) pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}(:validate_voltage_magnitudes => false), + solution_parameters = SolutionParameters(; validate_voltage_magnitudes = false), ) data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) @@ -120,12 +120,10 @@ end end @testset "Rectangular CI Jacobian: two swings in one island (multi-swing)" begin - # `_rect_two_swing_system` is defined in test_rectangular_ci_power_flow.jl; - # all test_*.jl files share one module scope and are fully included before - # any testset body runs. + # `_rect_two_swing_system` / `_rect_pf_settings` live in test_utils/cross_file_fixtures.jl. sys = _rect_two_swing_system() pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) J = PF.ACRectangularCIJacobian(R, 1) diff --git a/test/test_rectangular_ci_lcc.jl b/test/test_rectangular_ci_lcc.jl index 24676721..88af4027 100644 --- a/test/test_rectangular_ci_lcc.jl +++ b/test/test_rectangular_ci_lcc.jl @@ -1,5 +1,5 @@ function _rect_lcc_settings() - return Dict{Symbol, Any}(:validate_voltage_magnitudes => false) + return SolutionParameters(; validate_voltage_magnitudes = false) end @testset "Rectangular CI LCC: residual zero at polar-converged state" begin @@ -8,7 +8,7 @@ end pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() @test PF.solve_and_store_power_flow!(pf_p, sys) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_lcc_settings()) + solution_parameters = _rect_lcc_settings()) data = PF.PowerFlowData(pf_r, sys) R = PF.ACRectangularCIResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) @@ -19,7 +19,7 @@ end function _rect_lcc_verify(sys::System; label::String, perturbation::Float64 = 0.02) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - correct_bustypes = true, solver_settings = _rect_lcc_settings()) + correct_bustypes = true, solution_parameters = _rect_lcc_settings()) data = PF.PowerFlowData(pf_r, sys) R = PF.ACRectangularCIResidual(data, 1) J = PF.ACRectangularCIJacobian(R, 1) @@ -98,7 +98,7 @@ end pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() PF.solve_and_store_power_flow!(pf_p, sys) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_lcc_settings()) + solution_parameters = _rect_lcc_settings()) data = PF.PowerFlowData(pf_r, sys) R = PF.ACRectangularCIResidual(data, 1) J = PF.ACRectangularCIJacobian(R, 1) @@ -123,7 +123,7 @@ end sys_r = deepcopy(sys) pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_lcc_settings()) + solution_parameters = _rect_lcc_settings()) res_p = solve_power_flow(pf_p, sys_p) res_r = solve_power_flow(pf_r, sys_r) @test res_p !== missing @@ -146,9 +146,9 @@ end Dict{Symbol, Any}(:iwamoto_fallback => true)), ] @testset "$label" begin - settings = merge(extra_settings, _rect_lcc_settings()) + settings = PF._override(_rect_lcc_settings(), extra_settings) pf_r = ACRectangularPowerFlow{solver}(; - solver_settings = settings) + solution_parameters = settings) res_r = solve_power_flow(pf_r, deepcopy(sys)) @test res_r !== missing @test maximum(abs.(res_p["bus_results"].Vm - res_r["bus_results"].Vm)) < 1e-7 diff --git a/test/test_rectangular_ci_polar_parity.jl b/test/test_rectangular_ci_polar_parity.jl index 2d9c72b7..b620f691 100644 --- a/test/test_rectangular_ci_polar_parity.jl +++ b/test/test_rectangular_ci_polar_parity.jl @@ -1,89 +1,9 @@ # Solve representative scenarios with both polar NR and rectangular CI; assert # Vm/θ parity within RECT_PARITY_ATOL. - -const RECT_PARITY_ATOL = 1e-7 - -_rect_parity_settings() = Dict{Symbol, Any}(:validate_voltage_magnitudes => false) - -function _rect_polar_parity( - sys_p::PSY.System, - sys_r::PSY.System; - pf_kwargs::NamedTuple = NamedTuple(), - pf_r_extra_settings::Dict{Symbol, Any} = Dict{Symbol, Any}(), - atol::Float64 = RECT_PARITY_ATOL, -) - pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}(; pf_kwargs...) - pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - pf_kwargs..., - solver_settings = merge(_rect_parity_settings(), pf_r_extra_settings), - ) - res_p = solve_power_flow(pf_p, sys_p) - res_r = solve_power_flow(pf_r, sys_r) - @test res_p !== missing - @test res_r !== missing - bus_p = res_p["bus_results"] - bus_r = res_r["bus_results"] - @test maximum(abs.(bus_p.Vm - bus_r.Vm)) < atol - @test maximum(abs.(bus_p.θ - bus_r.θ)) < atol - # P_gen / Q_gen parity catches slack-recovery and Q-writeback bugs that - # Vm/θ parity alone cannot — the internal residual math can converge to the - # correct voltages while the reported generator outputs disagree (e.g., if - # the subnetwork slack is over-attributed to REF instead of distributed - # across participating buses). - @test maximum(abs.(bus_p.P_gen - bus_r.P_gen)) < atol - @test maximum(abs.(bus_p.Q_gen - bus_r.Q_gen)) < atol - return -end - -# Multi-period analogue of `_rect_polar_parity`: solve both formulations in -# place and assert full state-array parity. -function _rect_polar_parity_data( - pf_p::ACPowerFlow, - pf_r::ACRectangularPowerFlow, - sys_p::PSY.System, - sys_r::PSY.System, -) - data_p = PowerFlowData(pf_p, sys_p) - data_r = PowerFlowData(pf_r, sys_r) - @test PowerFlows.solve_power_flow!(data_p) - @test PowerFlows.solve_power_flow!(data_r) - @test maximum(abs.(data_p.bus_magnitude - data_r.bus_magnitude)) < RECT_PARITY_ATOL - @test maximum(abs.(data_p.bus_angles - data_r.bus_angles)) < RECT_PARITY_ATOL - @test maximum( - abs.(data_p.bus_active_power_injections - - data_r.bus_active_power_injections), - ) < RECT_PARITY_ATOL - @test maximum( - abs.(data_p.bus_reactive_power_injections - - data_r.bus_reactive_power_injections), - ) < RECT_PARITY_ATOL - return -end - -function _build_zip_2bus_system(; - power_pq::Tuple{Float64, Float64} = (0.0, 0.0), - current_pq::Tuple{Float64, Float64} = (0.0, 0.0), - impedance_pq::Tuple{Float64, Float64} = (0.0, 0.0), - zip_on_ref::Bool = false, -) - sys = System(100.0) - b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.1, 0.0) - b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230, 1.1, 0.0) - _add_simple_line!(sys, b1, b2, 5e-3, 5e-3, 1e-3) - _add_simple_source!(sys, b1, 0.0, 0.0) - zip_bus = zip_on_ref ? b1 : b2 - _add_simple_zip_load!( - sys, - zip_bus; - constant_power_active_power = power_pq[1], - constant_power_reactive_power = power_pq[2], - constant_current_active_power = current_pq[1], - constant_current_reactive_power = current_pq[2], - constant_impedance_active_power = impedance_pq[1], - constant_impedance_reactive_power = impedance_pq[2], - ) - return sys -end +# +# `RECT_PARITY_ATOL`, `_rect_parity_settings`, `_rect_polar_parity`, +# `_rect_polar_parity_data` and `_build_zip_2bus_system` live in +# `test_utils/cross_file_fixtures.jl` (also used by test_mixed_cpb_*.jl). @testset "Rectangular CI polar parity: ZIP loads (constant current)" begin sys_p = _build_zip_2bus_system(; current_pq = (2.0, 1.0)) @@ -101,7 +21,7 @@ end pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = _rect_parity_settings(), + solution_parameters = _rect_parity_settings(), ) res_p = solve_power_flow(pf_p, sys_p) res_r = solve_power_flow(pf_r, sys_r) @@ -222,7 +142,7 @@ end ACPowerFlow{NewtonRaphsonACPowerFlow}(), sys_p) @test PF.solve_and_store_power_flow!( ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_parity_settings()), sys_r) + solution_parameters = _rect_parity_settings()), sys_r) for g_p in get_components(Generator, sys_p) g_r = get_component(typeof(g_p), sys_r, get_name(g_p)) @test isapprox( @@ -237,7 +157,7 @@ end pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 3) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 3, - solver_settings = _rect_parity_settings()) + solution_parameters = _rect_parity_settings()) _rect_polar_parity_data(pf_p, pf_r, sys_p, sys_r) end @@ -256,6 +176,51 @@ end time_steps = 2, generator_slack_participation_factors = spf) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 2, generator_slack_participation_factors = spf, - solver_settings = _rect_parity_settings()) + solution_parameters = _rect_parity_settings()) _rect_polar_parity_data(pf_p, pf_r, sys_p, sys_r) end + +# `rect_finalize_bus_injections!` used to write P_gen/Q_gen back with the constant-power +# withdrawal ONLY, dropping the ZIP constant-current term. Since `ACRectangularCIResidual`'s +# constructor rebuilds its `P_net_set` FROM the previously-written `bus_active_power_injections` +# (mirroring polar), the dropped term compounded every re-solve on the same `data` — a PV bus +# carrying a 0.1 pu constant-current load drifted 0.398 → 0.296 → 0.194 over three solves. +function _pv_zip_drift_system() + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PV, 230, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_thermal_standard!(sys, b2, 0.5, 0.0) + # 1.0 pu on the load's own 10 MVA device base = 0.1 pu at the 100 MVA system base. + _add_simple_zip_load!(sys, b2; constant_current_active_power = 1.0) + _add_simple_line!(sys, b1, b2, 5e-3, 5e-2, 1e-3) + return sys +end + +@testset "Rectangular CI: repeated solves on the same data do not drift ZIP-PV injections" begin + sys_r = _pv_zip_drift_system() + sys_p = deepcopy(sys_r) + pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; + correct_bustypes = true, solution_parameters = _rect_parity_settings()) + pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + + data_p = PowerFlowData(pf_p, sys_p) + @test PowerFlows.solve_power_flow!(data_p) + bus_ix_p = PF.get_bus_lookup(data_p)[2] + p_gen_polar = data_p.bus_active_power_injections[bus_ix_p, 1] + q_gen_polar = data_p.bus_reactive_power_injections[bus_ix_p, 1] + + data_r = PowerFlowData(pf_r, sys_r) + bus_ix_r = PF.get_bus_lookup(data_r)[2] + p_gen_solves = Float64[] + q_gen_solves = Float64[] + for _ in 1:3 + @test PowerFlows.solve_power_flow!(data_r) + push!(p_gen_solves, data_r.bus_active_power_injections[bus_ix_r, 1]) + push!(q_gen_solves, data_r.bus_reactive_power_injections[bus_ix_r, 1]) + end + @test all(isapprox.(p_gen_solves, p_gen_solves[1]; atol = 1e-9)) + @test all(isapprox.(q_gen_solves, q_gen_solves[1]; atol = 1e-9)) + @test p_gen_solves[end] ≈ p_gen_polar atol = RECT_PARITY_ATOL + @test q_gen_solves[end] ≈ q_gen_polar atol = RECT_PARITY_ATOL +end diff --git a/test/test_rectangular_ci_power_flow.jl b/test/test_rectangular_ci_power_flow.jl index 8342fcc6..299cbb5c 100644 --- a/test/test_rectangular_ci_power_flow.jl +++ b/test/test_rectangular_ci_power_flow.jl @@ -1,19 +1,15 @@ -function _rect_pf_settings() - return Dict{Symbol, Any}(:validate_voltage_magnitudes => false) -end - @testset "Rectangular CI Power Flow: convergence" begin @testset "c_sys5 converges" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) @test PF.solve_and_store_power_flow!(pf_rect, sys) end @testset "c_sys14 converges" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) @test PF.solve_and_store_power_flow!(pf_rect, sys) end end @@ -23,8 +19,7 @@ end # maxIterations = 1 from flat start cannot converge c_sys14; the solver must # report non-convergence (results = missing) rather than error or hang. pf = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = merge(_rect_pf_settings(), - Dict{Symbol, Any}(:maxIterations => 1))) + solution_parameters = PF._override(_rect_pf_settings(); maxIterations = 1)) @test_logs( (:error, r"did not converge in 1 of 1"), match_mode = :any, @@ -37,8 +32,7 @@ end # maxIterations = 1 from flat start cannot converge c_sys14; the solver must # report non-convergence (results = missing) rather than error or hang. pf = ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}(; - solver_settings = merge(_rect_pf_settings(), - Dict{Symbol, Any}(:maxIterations => 1))) + solution_parameters = PF._override(_rect_pf_settings(); maxIterations = 1)) @test_logs( (:error, r"did not converge in 1 of 1"), match_mode = :any, @@ -61,7 +55,7 @@ end sys_r = deepcopy(sys_p) pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) res_p = solve_power_flow(pf_p, sys_p) res_r = solve_power_flow(pf_r, sys_r) @test res_p !== missing @@ -124,9 +118,9 @@ end for (label, solver, extra_settings) in strategies @testset "$label" begin sys_r = deepcopy(sys_p) - settings = merge(extra_settings, _rect_pf_settings()) + settings = PF._override(_rect_pf_settings(), extra_settings) pf_r = ACRectangularPowerFlow{solver}(; - solver_settings = settings) + solution_parameters = settings) res_r = solve_power_flow(pf_r, sys_r) @test res_r !== missing @test maximum( @@ -148,7 +142,7 @@ end pf_polar = ACPolarPowerFlow{LevenbergMarquardtACPowerFlow}() res_polar = solve_power_flow(pf_polar, deepcopy(sys)) pf_rect = ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) res_rect = solve_power_flow(pf_rect, deepcopy(sys)) @test res_rect !== missing @test maximum( @@ -166,22 +160,25 @@ end # Tight tolerance + generous iteration budget: LM refactorizes the sparse QR # every iteration and needs more iterations than NR on a 2000-bus system. - lm_settings = Dict{Symbol, Any}(:tol => 1e-10, :maxIterations => 100) - ref_settings = Dict{Symbol, Any}(:tol => 1e-10) + lm_settings = SolutionParameters(; tol = 1e-10, maxIterations = 100) + ref_settings = SolutionParameters(; tol = 1e-10) # Reference: Newton-Raphson on the polar formulation (trusted for ACTIVSg2000 # elsewhere in the suite). pf_ref = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; - correct_bustypes = true, solver_settings = ref_settings) + correct_bustypes = true, solution_parameters = ref_settings) res_ref = solve_power_flow(pf_ref, sys) pf_lm_polar = ACPolarPowerFlow{LevenbergMarquardtACPowerFlow}(; - correct_bustypes = true, solver_settings = lm_settings) + correct_bustypes = true, solution_parameters = lm_settings) res_lm_polar = solve_power_flow(pf_lm_polar, sys) pf_lm_rect = ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}(; correct_bustypes = true, - solver_settings = merge(_rect_pf_settings(), lm_settings)) + solution_parameters = PF._override( + lm_settings; + validate_voltage_magnitudes = false, + )) res_lm_rect = solve_power_flow(pf_lm_rect, sys) @test res_lm_polar !== missing @@ -227,9 +224,9 @@ end @testset "LM Marquardt diagonal scaling option" begin # Formulation-dispatched default: rectangular on, polar off. @test PF._default_marquardt_scaling( - ACPolarPowerFlow{LevenbergMarquardtACPowerFlow}()) == false + ACPolarPowerFlow{LevenbergMarquardtACPowerFlow}) == false @test PF._default_marquardt_scaling( - ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}()) == true + ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}) == true # Known J with distinct column 2-norms: col1 = 5, col2 = 12. Index type must # match `LMWorkspace`'s `J_INDEX_TYPE` (Int64 on Apple, Int32 elsewhere). @@ -245,14 +242,15 @@ end @test ws_off.D == ones(2) # identity damping ⇒ polar unaffected @test PF.LMWorkspace(J).marquardt_scaling == false # defaults to off - # update_lambda! writes √λ·D into the damping diagonal. + # update_lambda! refreshes N = JᵀJ + λ·D² in place; check the λ·D² + # contribution against JᵀJ's own diagonal (JᵀJ[i,i] = colnorm(J,i)²). λ = 4.0 - PF.update_lambda!(ws_on, λ) - PF.update_lambda!(ws_off, λ) - @test ws_on.A.nzval[ws_on.λ_diag_indices[1]] ≈ sqrt(λ) * 5.0 - @test ws_on.A.nzval[ws_on.λ_diag_indices[2]] ≈ sqrt(λ) * 12.0 - @test ws_off.A.nzval[ws_off.λ_diag_indices[1]] ≈ sqrt(λ) # == identity - @test ws_off.A.nzval[ws_off.λ_diag_indices[2]] ≈ sqrt(λ) + PF.update_lambda!(ws_on, J, λ) + PF.update_lambda!(ws_off, J, λ) + @test ws_on.N[1, 1] ≈ 5.0^2 + λ * 5.0^2 + @test ws_on.N[2, 2] ≈ 12.0^2 + λ * 12.0^2 + @test ws_off.N[1, 1] ≈ 5.0^2 + λ # == identity damping + @test ws_off.N[2, 2] ≈ 12.0^2 + λ # Integration: rectangular LM converges with the default (scaling on) and # with the explicit override (scaling off); both match the polar NR @@ -263,11 +261,10 @@ end res_rect_default = solve_power_flow( ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}(; - solver_settings = _rect_pf_settings()), deepcopy(sys)) + solution_parameters = _rect_pf_settings()), deepcopy(sys)) res_rect_off = solve_power_flow( ACRectangularPowerFlow{LevenbergMarquardtACPowerFlow}(; - solver_settings = merge(_rect_pf_settings(), - Dict{Symbol, Any}(:marquardt_scaling => false))), + solution_parameters = _rect_pf_settings(), marquardt_scaling = false), deepcopy(sys)) for res in (res_rect_default, res_rect_off) @@ -304,7 +301,7 @@ end @testset "rectangular CI residual dispatch" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") pf = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) data = PF.PowerFlowData(pf, sys) residual, _, x = PF.initialize_power_flow_variables(pf, data, 1) bus_types = PF.get_bus_type(data) @@ -329,7 +326,7 @@ end # (the 1/|V|² current-balance terms are otherwise unguarded). sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") pf = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = _rect_pf_settings()) + solution_parameters = _rect_pf_settings()) data = PF.PowerFlowData(pf, sys) residual, J, x = PF.initialize_power_flow_variables(pf, data, 1) bus_types = PF.get_bus_type(data) @@ -345,28 +342,12 @@ end @test all(isfinite, J.Jv.nzval) end -# Synthetic two-swing island, built in code. Mirrors test_jacobian.jl's polar -# `_two_swing_system()`; duplicated locally since the rect and mixed-CPB -# multi-swing work is split across disjoint test files. -function _rect_two_swing_system() - sys = System(100.0) - b1 = _add_simple_bus!(sys, 1, PSY.ACBusTypes.REF, 230, 1.06, 0.0) - b2 = _add_simple_bus!(sys, 2, PSY.ACBusTypes.REF, 230, 1.05, 0.05) - b3 = _add_simple_bus!(sys, 3, PSY.ACBusTypes.PQ, 230, 1.0, 0.0) - _add_simple_source!(sys, b1, 0.0, 0.0) - _add_simple_source!(sys, b2, 0.0, 0.0) - _add_simple_load!(sys, b3, 40, 15) - _add_simple_line!(sys, b1, b3, 5e-3, 5e-3, 1e-3) - _add_simple_line!(sys, b2, b3, 5e-3, 5e-3, 1e-3) - return sys -end - @testset "Rectangular CI Power Flow: multi-swing (two swings in one island)" begin @testset "$(nameof(V))" for V in (NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow) sys_p = _rect_two_swing_system() sys_r = deepcopy(sys_p) pf_p = ACPowerFlow{V}() - pf_r = ACRectangularPowerFlow{V}(; solver_settings = _rect_pf_settings()) + pf_r = ACRectangularPowerFlow{V}(; solution_parameters = _rect_pf_settings()) res_p = solve_power_flow(pf_p, sys_p) res_r = solve_power_flow(pf_r, sys_r) @test res_p !== missing diff --git a/test/test_reduced_ac_power_flow.jl b/test/test_reduced_ac_power_flow.jl index 4293f597..27548a3b 100644 --- a/test/test_reduced_ac_power_flow.jl +++ b/test/test_reduced_ac_power_flow.jl @@ -421,3 +421,130 @@ end @test isapprox(get_angle(actual), get_angle(expected); atol = 1e-5) end end + +# Dispatched membership predicate: the house rule forbids an `isa` gate even in tests. +_is_series_chain(::PNM.BranchesSeries) = true +_is_series_chain(::PSY.ACTransmission) = false + +# lines: vector of (name, from, to, r, x); bus 1 is REF+gen, bus 2 is PQ+load, others bare. +function _zir_merge_test_sys(lines, nbus) + sys = System(100.0) + buses = Dict{Int, ACBus}() + for n in 1:nbus + b = ACBus(; number = n, name = "zbus_$n", available = true, + bustype = n == 1 ? ACBusTypes.REF : ACBusTypes.PQ, + angle = 0.0, magnitude = 1.0, voltage_limits = (min = 0.9, max = 1.1), + base_voltage = 230.0) + add_component!(sys, b) + buses[n] = b + end + for (name, f, t, r, x) in lines + arc = Arc(; from = buses[f], to = buses[t]) + add_component!(sys, arc) + add_component!( + sys, + Line(; name = name, available = true, + active_power_flow = 0.0, reactive_power_flow = 0.0, arc = arc, r = r, x = x, + b = (from = 0.0, to = 0.0), rating = 4.0, + angle_limits = (min = -pi, max = pi)), + ) + end + add_component!( + sys, + PowerLoad(; name = "zload2", available = true, + bus = buses[2], active_power = 1.0, reactive_power = 0.2, base_power = 100.0, + max_active_power = 1.0, max_reactive_power = 0.2), + ) + add_component!( + sys, + ThermalStandard(; name = "zgen1", available = true, + status = OperationalStates.ONLINE, bus = buses[1], active_power = 1.05, + reactive_power = 0.25, rating = 5.0, + active_power_limits = (min = 0.0, max = 5.0), + reactive_power_limits = (min = -5.0, max = 5.0), ramp_limits = nothing, + operation_cost = ThermalGenerationCost(nothing), base_power = 100.0, + time_limits = nothing, prime_mover_type = PrimeMovers.OT, + fuel = ThermalFuels.OTHER), + ) + return sys +end + +@testset "DC BRANCH_FLOWS: anti-parallel member of a parallel group keeps its own sign" begin + # ZIR merges bus 3 into bus 2, so L2 (3->1) becomes anti-parallel to L1 (1,2) within the + # same parallel group. Control has the identical circuit without the merge: L1 (1->2) and + # L2 (2->1) as separate direct arcs, so L2's flow there is ground truth for the merged case. + grouped_sys = + _zir_merge_test_sys( + [("L1", 1, 2, 0.01, 0.1), ("ZI23", 2, 3, 0.0, 1e-5), ("L2", 3, 1, 0.01, 0.1)], 3, + ) + control_sys = _zir_merge_test_sys([("L1", 1, 2, 0.01, 0.1), ("L2", 2, 1, 0.01, 0.1)], 2) + + nrd = PNM.get_network_reduction_data(PNM.Ybus(grouped_sys)) + grouped_groups = collect(PNM.get_parallel_branch_map(nrd)) + @test length(grouped_groups) == 1 + (group_arc, group) = only(grouped_groups) + @test Set(group_arc) == Set((1, 2)) + @test all(!_is_series_chain, group) + + for model in (DCPowerFlow(), PTDFDCPowerFlow()) + grouped = + solve_power_flow(model, grouped_sys, PF.FlowReporting.BRANCH_FLOWS)["1"]["flow_results"] + control = + solve_power_flow(model, control_sys, PF.FlowReporting.BRANCH_FLOWS)["1"]["flow_results"] + l2_grouped = only(eachrow(filter(:bus_from => ==(3), grouped))) + l2_control = + only(eachrow(filter(:bus_from => ==(2), filter(:bus_to => ==(1), control)))) + @test isapprox(l2_grouped.P_from_to, l2_control.P_from_to; atol = 1e-6) + @test isapprox(l2_grouped.P_to_from, l2_control.P_to_from; atol = 1e-6) + # Power is delivered from the REF bus to the load on bus 2 through both parallel + # paths, so along L2's own declared direction (3 -> 1) the flow runs backward. + @test l2_grouped.P_from_to < 0.0 + end +end + +@testset "DC BRANCH_FLOWS: reversed sibling chains under degree-2 reduction" begin + # Two chains between REF bus 1 and PQ bus 2: 1-5-2 and 1-4-3-2. The reduction's canonical + # arc direction for the second chain (traversed from its lowest-index interior bus toward + # its smaller neighbour) is opposite the first, so the group holds anti-parallel chains. + sys_reduced = _zir_merge_test_sys( + [("L15", 1, 5, 0.01, 0.10), ("L52", 5, 2, 0.01, 0.12), + ("L14", 1, 4, 0.015, 0.05), ("L43", 4, 3, 0.015, 0.05), + ("L32", 3, 2, 0.015, 0.05)], + 5, + ) + reductions = PNM.NetworkReduction[PNM.DegreeTwoReduction(; + reduce_reactive_power_injectors = false, + )] + nrd = PNM.get_network_reduction_data( + PNM.Ybus(sys_reduced; network_reductions = reductions), + ) + grouped_groups = [ + (arc, group) for (arc, group) in PNM.get_parallel_branch_map(nrd) if + any(_is_series_chain, group) + ] + @test length(grouped_groups) == 1 + (group_arc, group) = only(grouped_groups) + @test Set(group_arc) == Set((1, 2)) + @test all(_is_series_chain, group) + + sys_full = _zir_merge_test_sys( + [("L15", 1, 5, 0.01, 0.10), ("L52", 5, 2, 0.01, 0.12), + ("L14", 1, 4, 0.015, 0.05), ("L43", 4, 3, 0.015, 0.05), + ("L32", 3, 2, 0.015, 0.05)], + 5, + ) + full = + solve_power_flow(DCPowerFlow(), sys_full, PF.FlowReporting.BRANCH_FLOWS)["1"]["flow_results"] + reduced = solve_power_flow( + DCPowerFlow(; network_reductions = reductions), + sys_reduced, + PF.FlowReporting.BRANCH_FLOWS, + )["1"]["flow_results"] + + @test Set(reduced.flow_name) == Set(full.flow_name) + for row in eachrow(full) + reduced_row = only(eachrow(filter(:flow_name => ==(row.flow_name), reduced))) + @test isapprox(reduced_row.P_from_to, row.P_from_to; atol = 1e-3) + @test isapprox(reduced_row.P_to_from, row.P_to_from; atol = 1e-3) + end +end diff --git a/test/test_residual_condition_diagnostics.jl b/test/test_residual_condition_diagnostics.jl index cd4a94d0..08c0c5a4 100644 --- a/test/test_residual_condition_diagnostics.jl +++ b/test/test_residual_condition_diagnostics.jl @@ -3,7 +3,7 @@ # assert a numeric κ̂ pin `linear_solver = "KLU"` for determinism across platforms # (on Apple the default is AppleAccelerate); the AppleAccelerate path is covered # explicitly below. -const _KLU_SETTINGS = Dict{Symbol, Any}(:linear_solver => "KLU") +const _KLU_SETTINGS = SolutionParameters(; linear_solver = "KLU") # Build a Schur operator at the flat start of `sys` under `backend` and return its # smallest eigenvalue alongside the dense ground truth (smallest-magnitude @@ -88,7 +88,7 @@ end @testset "log_solver_diagnostics is off by default" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = _KLU_SETTINGS) + solution_parameters = _KLU_SETTINGS) @test isempty(_solver_diagnostic_lines(pf, sys)) end @@ -97,7 +97,7 @@ end for solver in (NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow, LevenbergMarquardtACPowerFlow) pf = ACPowerFlow{solver}(; correct_bustypes = true, - log_solver_diagnostics = true, solver_settings = _KLU_SETTINGS) + log_solver_diagnostics = true, solution_parameters = _KLU_SETTINGS) lines = _solver_diagnostic_lines(pf, sys) @test length(lines) >= 2 for line in lines @@ -119,7 +119,7 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") for PFType in (PF.ACRectangularPowerFlow, PF.ACMixedPowerFlow) pf = PFType{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - log_solver_diagnostics = true, solver_settings = _KLU_SETTINGS) + log_solver_diagnostics = true, solution_parameters = _KLU_SETTINGS) lines = _solver_diagnostic_lines(pf, sys) @test length(lines) >= 2 for line in lines @@ -135,7 +135,7 @@ end runchecks = false, ) pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; log_solver_diagnostics = true, - solver_settings = _KLU_SETTINGS) + solution_parameters = _KLU_SETTINGS) lines = _solver_diagnostic_lines(pf, sys) @test length(lines) >= 2 for line in lines @@ -151,7 +151,8 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, log_solver_diagnostics = true, - solver_settings = Dict{Symbol, Any}(:linear_solver => "AppleAccelerateLU")) + solution_parameters = SolutionParameters(; + linear_solver = "AppleAccelerateLU")) lines = _solver_diagnostic_lines(pf, sys) @test length(lines) >= 2 for line in lines @@ -167,8 +168,8 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") for solver in (NewtonRaphsonACPowerFlow, TrustRegionACPowerFlow) pf = ACPowerFlow{solver}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}( - :linear_solver => "KLU", :stop_at_fold => true)) + solution_parameters = SolutionParameters(; + linear_solver = "KLU", stop_at_fold = true)) data = PowerFlowData(pf, sys) @test solve_power_flow!(data) end @@ -342,7 +343,7 @@ end @testset "the monitor line reports sign(det J)" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - log_solver_diagnostics = true, solver_settings = _KLU_SETTINGS) + log_solver_diagnostics = true, solution_parameters = _KLU_SETTINGS) lines = _solver_diagnostic_lines(pf, sys) @test length(lines) >= 2 for line in lines @@ -355,8 +356,8 @@ end # convergence, and must say WHY in terms of sign(det J). sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}( - :linear_solver => "KLU", :stop_at_fold => true)) + solution_parameters = SolutionParameters(; + linear_solver = "KLU", stop_at_fold = true)) data = PF.PowerFlowData(pf, sys) data.bus_active_power_withdrawals .*= 6.0 data.bus_reactive_power_withdrawals .*= 6.0 @@ -376,8 +377,8 @@ end sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") function final_state(; stop_at_fold, scale, maxiter) pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}(:linear_solver => "KLU", - :stop_at_fold => stop_at_fold, :maxIterations => maxiter)) + solution_parameters = SolutionParameters(; linear_solver = "KLU", + stop_at_fold = stop_at_fold, maxIterations = maxiter)) data = PF.PowerFlowData(pf, sys) data.bus_active_power_withdrawals .*= scale data.bus_reactive_power_withdrawals .*= scale diff --git a/test/test_solution_parameters.jl b/test/test_solution_parameters.jl index 7cfe0986..bfdc0700 100644 --- a/test/test_solution_parameters.jl +++ b/test/test_solution_parameters.jl @@ -1,8 +1,8 @@ @testset "solver_kwargs exposes solver parameters and hides the controls" begin kwargs = PF.solver_kwargs(SolutionParameters()) - # An unset iteration cap must not be splatted: passing `nothing` would override each - # solver's own default with `nothing`. - @test !haskey(kwargs, :maxIterations) + # `maxIterations` is a static field of the returned NamedTuple; an unresolved value + # reports the sentinel, resolved to a solver's default by the model constructor. + @test kwargs.maxIterations == PF.UNSET_MAX_ITERATIONS @test kwargs.tol == PF.DEFAULT_NR_TOL @test kwargs.validate_voltage_magnitudes == PF.DEFAULT_VALIDATE_VOLTAGES @test kwargs.λ_0 == PF.DEFAULT_λ_0 @@ -42,7 +42,7 @@ end @test !PF.get_control_discrete_devices(DCPowerFlow()) end -@testset "Legacy keywords and solver_settings still configure a model" begin +@testset "Legacy keywords still configure a model" begin # The named keywords remain a supported spelling and fold into the stored parameters. pf = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; check_reactive_power_limits = true, @@ -53,25 +53,31 @@ end @test PF.get_control_discrete_devices(pf) @test !PF.get_enhanced_flat_start(pf) - # The deprecated dictionary still reaches the solver. - pf_dict = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:iwamoto => true, :maxIterations => 11), - ) - @test PF.get_solver_kwargs(pf_dict).iwamoto - @test PF.get_solver_kwargs(pf_dict).maxIterations == 11 - - # An explicit keyword wins over the dictionary and over `solution_parameters`. + # An explicit keyword wins over `solution_parameters`. pf_mixed = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; solution_parameters = SolutionParameters(; control_discrete_devices = true), control_discrete_devices = false, ) @test !PF.get_control_discrete_devices(pf_mixed) +end - # A dictionary key that names no parameter is dropped loudly rather than silently - # splatted into a solver that would ignore it. - @test_logs (:warn,) match_mode = :any ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = Dict{Symbol, Any}(:not_a_parameter => 1), - ) +@testset "check_reactive_power_limits overrides per call" begin + # Regression: a per-call `check_reactive_power_limits = true` was silently ignored + # because `_solve_with_q_limits!` read only the stored parameter. c_sys14 bus 8's Q + # violates its limit unless the flag is honored. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) + pf = ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) + data = PF.PowerFlowData(pf, sys) + bt0 = copy(data.bus_type[:, 1]) + @test PF.solve_power_flow!(data; check_reactive_power_limits = true) + flips = findall(data.bus_type[:, 1] .!= bt0) + @test !isempty(flips) + for i in flips + bt0[i] == PSY.ACBusTypes.PV || continue + q = data.bus_reactive_power_injections[i, 1] + (qmin, qmax) = data.bus_reactive_power_bounds[i, 1] + @test qmin - 1e-6 <= q <= qmax + 1e-6 + end end @testset "Parameter validation still runs against the solver type" begin diff --git a/test/test_solve_power_flow.jl b/test/test_solve_power_flow.jl index 70839d48..bda781aa 100644 --- a/test/test_solve_power_flow.jl +++ b/test/test_solve_power_flow.jl @@ -400,7 +400,7 @@ end "change `force_build` to `true` in the test." pf_tr = ACPowerFlow{TrustRegionACPowerFlow}(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}(:maxIterations => 200, :factor => 0.1), + solution_parameters = SolutionParameters(; maxIterations = 200, factor = 0.1), ) data_tr = PowerFlowData(pf_tr, sys) solve_power_flow!(data_tr) @@ -437,7 +437,7 @@ end sys_sienna = build_system(MatpowerTestSystems, "matpower_ACTIVSg2000_sys") pf_sienna = ACPowerFlow(; correct_bustypes = true, - solver_settings = Dict{Symbol, Any}(:tol => 1e-11), + solution_parameters = SolutionParameters(; tol = 1e-11), ) data_sienna = PowerFlowData(pf_sienna, sys_sienna) solve_power_flow!(data_sienna) @@ -903,23 +903,3 @@ end @testset "AC arc_angle_differences validation" begin foreach(test_ac_arc_angle_differences, AC_SOLVERS_TO_TEST) end - -@testset "ACPowerFlow solver_settings accepts narrowly-typed Dicts" begin - # Regression: previously the kwarg required Dict{Symbol, Any} exactly, so a - # plain `Dict(:k => 50)` (inferred as Dict{Symbol, Int64}) was rejected. - # `solver_settings` is now the deprecated spelling of `solution_parameters`; its - # entries are folded into the typed parameters and still reach the solver. - pf_int = ACPowerFlow(; solver_settings = Dict(:maxIterations => 50)) - @test PowerFlows.get_solution_parameters(pf_int) isa SolutionParameters - @test PowerFlows.get_solver_kwargs(pf_int)[:maxIterations] === 50 - - pf_bool = ACPowerFlow(; - solver_settings = Dict(:validate_voltage_magnitudes => false), - ) - @test PowerFlows.get_solver_kwargs(pf_bool)[:validate_voltage_magnitudes] === false - - pf_any = ACPowerFlow(; - solver_settings = Dict{Symbol, Any}(:maxIterations => 50), - ) - @test PowerFlows.get_solver_kwargs(pf_any)[:maxIterations] === 50 -end diff --git a/test/test_utils/common.jl b/test/test_utils/common.jl index 1fe23cd0..14d99964 100644 --- a/test/test_utils/common.jl +++ b/test/test_utils/common.jl @@ -40,12 +40,14 @@ function modify_rts_system!(sys::System) ref_bus = get_bus(sys, 113) # "Arne" @assert get_bustype(ref_bus) == ACBusTypes.REF # NOTE: we are not testing the correctness of _power_redistribution_ref here, it is used on both sides of the test + bus_injectors = PF._build_bus_injector_map(sys) PF._power_redistribution_ref( sys, 2.4375, 0.1875, ref_bus, PF.DEFAULT_MAX_REDISTRIBUTION_ITERATIONS, + bus_injectors, ) # For PV bus, active and voltage are fixed; update reactive and angle @@ -56,6 +58,7 @@ function modify_rts_system!(sys::System) 0.37267, pv_bus, PF.DEFAULT_MAX_REDISTRIBUTION_ITERATIONS, + bus_injectors, ) set_angle!(pv_bus, -0.13778) @@ -1292,7 +1295,7 @@ end # ── Shared VSC test builders ──────────────────────────────────────────────────────────────────── -const VSC_SETTINGS = Dict{Symbol, Any}(:model_dc_network => true) +const VSC_SOLUTION_PARAMETERS = SolutionParameters(; model_dc_network = true) # One point-to-point VSC line between the first two PQ buses of c_sys14: from = DC-voltage control # (DC slack), to = (P, Q) control. Extra `TwoTerminalVSCLine` fields pass through `vsc_kwargs...` @@ -1642,10 +1645,7 @@ solve still converged. Returns the captured log records. Deliberately does NOT assert WHICH area is de-enrolled first or how many are: the greedy rule picks `findmax(abs, gaps)` at a NON-CONVERGED iterate, where gaps measure divergence, not infeasibility -- a feasible area can show the larger gap (Area2 at 0.3 pu measured 54.0 vs -Area3's 49.3). Pinning the order is what made these tests platform-dependent. -`collect_test_logs` rather than `@test_logs`: ReTest has no -`record(::ReTestSet, ::Test.LogTestFailure)`, so a `@test_logs` failure surfaces as an opaque -MethodError instead of naming the unmatched pattern.""" +Area3's 49.3). Pinning the order is what made these tests platform-dependent.""" function _assert_schedule_relaxed(data, area_name::String; time_step::Int = 1) logs, converged = Test.collect_test_logs(; min_level = Logging.Warn) do solve_power_flow!(data) diff --git a/test/test_utils/cross_file_fixtures.jl b/test/test_utils/cross_file_fixtures.jl new file mode 100644 index 00000000..da40d13a --- /dev/null +++ b/test/test_utils/cross_file_fixtures.jl @@ -0,0 +1,496 @@ +# Fixtures and helpers called from more than one test_*.jl file. Under ParallelTestRunner +# each test file runs in its own worker, so anything shared across files must live here +# (included by includes.jl) rather than in the file that happens to use it first. + +# --- area interchange (test_area_interchange_enrollment/_solve, test_jacobian, +# test_residual_condition_diagnostics) --- + +function _find_tie(ties::Vector{PF.AreaTie}, fix::Int, tix::Int) + return only( + filter( + tie -> + (tie.from_bus_ix == fix && tie.to_bus_ix == tix) || + (tie.from_bus_ix == tix && tie.to_bus_ix == fix), + ties, + ), + ) +end + +_set_slack!(sys, bus_name) = + PSY.set_bustype!(PSY.get_component(PSY.ACBus, sys, bus_name), PSY.ACBusTypes.SLACK) + +function _add_area_interchange!( + sys, + from_name::String, + to_name::String, + flow::Float64; + name::String = "$(from_name)_$(to_name)", +) + PSY.add_component!( + sys, + PSY.AreaInterchange(; + name = name, + available = true, + active_power_flow = flow, + from_area = PSY.get_component(PSY.Area, sys, from_name), + to_area = PSY.get_component(PSY.Area, sys, to_name), + flow_limits = (from_to = 0.0, to_from = 0.0), + ), + ) + return +end + +# Shared by the rule-9 (unenforceable-schedule) and happy-path tests. Area1 owns REF, +# never SLACK; Area2/Area3 can each +# optionally hold SLACK (Area3's Bus 9 has a small gen so it's PV-eligible). AreaInterchange: +# Area2->Area1 0.3, Area3->Area1 0.2 => pdes(Area1)=-0.5, pdes(Area2)=0.3, pdes(Area3)=0.2. +function _three_area_transfer_fixture(; slack_area3::Bool = true) + sys = _make_three_area_system() + bus9 = PSY.get_component(PSY.ACBus, sys, "Bus 9") + gen9 = PSY.ThermalStandard(; + name = "Bus9Gen", + available = true, + status = PSY.OperationalStates.ONLINE, + bus = bus9, + active_power = 0.1, + reactive_power = 0.0, + rating = 1.0, + active_power_limits = (min = 0.0, max = 1.0), + reactive_power_limits = (min = -1.0, max = 1.0), + ramp_limits = nothing, + operation_cost = PSY.ThermalGenerationCost(nothing), + base_power = 100.0, + ) + PSY.add_component!(sys, gen9) + _set_slack!(sys, "Bus 6") + slack_area3 && _set_slack!(sys, "Bus 9") + _add_area_interchange!(sys, "Area2", "Area1", 0.3; name = "A2_A1") + _add_area_interchange!(sys, "Area3", "Area1", 0.2; name = "A3_A1") + return sys +end + +# A boundary-crossing 3W transformer winding whose star bus's Y-bus diagonal is polluted by +# BOTH a sibling winding of the same transformer and an unrelated extra line -- neither is a +# member of the boundary-crossing winding's own corridor. Tertiary winding disabled: not +# needed here. +function _make_3w_boundary_fixture() + sys = System(100.0) + area_a = PSY.Area(; name = "AreaA") + area_b = PSY.Area(; name = "AreaB") + PSY.add_component!(sys, area_a) + PSY.add_component!(sys, area_b) + + bus1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230) + bus2 = _add_simple_bus!(sys, 2, ACBusTypes.PV, 230) + bus3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230) + bus4 = _add_simple_bus!(sys, 4, ACBusTypes.PQ, 230) + bus5 = _add_simple_bus!(sys, 5, ACBusTypes.PQ, 230) + PSY.set_area!(bus1, area_a) + PSY.set_area!(bus2, area_b) + PSY.set_area!(bus3, area_a) + PSY.set_area!(bus4, area_b) + PSY.set_area!(bus5, area_a) + + _add_simple_source!(sys, bus1, 0.0, 0.0) + _add_simple_thermal_standard!(sys, bus2, 0.1, 0.0) + _add_simple_load!(sys, bus3, 5.0, 2.0) + _add_simple_load!(sys, bus4, 5.0, 2.0) + _add_simple_load!(sys, bus5, 2.0, 1.0) + + _add_simple_line!(sys, bus1, bus3) + _add_simple_line!(sys, bus2, bus4) + + xfmr = _add_simple_transformer_3w!(sys, bus3, bus4, bus3, 99) + star_bus = PSY.get_star_bus(xfmr) + PSY.set_area!(star_bus, area_a) + _add_simple_line!(sys, star_bus, bus5) + + PSY.set_bustype!(bus2, ACBusTypes.SLACK) + return sys +end + +# --- jacobian (test_jacobian, test_rectangular_ci_jacobian, test_mixed_cpb_power_flow, +# test_rectangular_ci_power_flow) --- + +function verify_jacobian( + sys::PSY.System; + pf::PF.ACPowerFlow = PF.ACPowerFlow{NewtonRaphsonACPowerFlow}(; + correct_bustypes = true, + ), + label::String = "", + perturbation::Float64 = 0.02, + seed::Int = 42, +) + data = PF.PowerFlowData(pf, sys) + time_step = 1 + residual = PF.ACPowerFlowResidual(data, time_step) + J = PF.ACPowerFlowJacobian(residual, time_step) + x0 = PF.calculate_x0(data, time_step) + # Verify away from the flat-start state. At flat start θ=0 for every bus, + # which silently zeroes all `sin(Δθ)` cross-terms — a sign flip in the + # symbolic Jacobian for those entries would not be detected. A small + # deterministic perturbation breaks the symmetry. + if perturbation > 0 + Random.seed!(seed) + x0 .+= perturbation .* randn(length(x0)) + end + residual(x0, time_step) + J(time_step) + verify_jacobian_asymptotic( + residual, deepcopy(J.Jv), x0, time_step; label = label, + ) +end + +# Two-swing island: buses 1 and 2 are both REF in one island, bus 3 is a PQ load. The +# second swing has a nonzero fixed angle so the check exercises real off-diagonal ∂P/∂θ terms. +function _two_swing_system() + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.06, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.REF, 230, 1.05, 0.05) + b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_source!(sys, b2, 0.0, 0.0) + _add_simple_load!(sys, b3, 40, 15) + _add_simple_line!(sys, b1, b3, 5e-3, 5e-3, 1e-3) + _add_simple_line!(sys, b2, b3, 5e-3, 5e-3, 1e-3) + return sys +end + +# --- LCC discrete control (test_lcc_discrete_control, test_discrete_control) --- + +"""Parse the bundled two-LCC fixture and add enrollable controlled devices: a stepping +switched shunt and a shunt FACTS device at bus 101 (PQ, 230 kV, largest load). The fixture +has no transformers, so no tap device is enrolled. `p_set_mw` overrides both LCC transfer +setpoints (0.0 exercises the i_dc = 0 tap-pinning branch). + +Every branch carries x = 1e-4 pu against a much larger r, so bus 101 is electrically bolted to +the REF bus and the network is resistance-dominated — a reactive move there shifts angle far +more than magnitude. Device ratings and setpoints are therefore sized past anything realistic, +so the devices clear `CONTROL_GAIN_FLOOR` and enroll instead of being frozen as insensitive, +and their setpoints sit above the reachable voltage so the continuation keeps driving them.""" +function build_lcc_control_system(; p_set_mw::Union{Nothing, Float64} = nothing) + raw = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") + sys = make_system(PFP.PowerModelsData(raw); runchecks = false) + bus101 = get_bus(sys, 101) + add_component!( + sys, + SwitchedAdmittance(; name = "ctrl_shunt_101", available = true, + bus = bus101, number_engaged = [0], number_of_steps = [8], + Y_increase = [0.0 + 0.5im], admittance_limits = (min = 1.05, max = 1.08), + control_mode = PSY.SwitchedAdmittanceControlMode.DISCRETE_VOLTAGE, + ), + ) + add_component!( + sys, + FACTSControlDevice(; + name = "ctrl_facts_101", + available = true, + bus = bus101, + control_mode = PSY.FACTSOperationModes.NML, + voltage_setpoint = 1.06, + max_shunt_current = 1000.0, + max_reactive_power = 9999.0, + shunt_control_type = PSY.FACTSShuntControlType.STATCOM, + regulated_bus_number = 0, + ), + ) + if p_set_mw !== nothing + # `initialize_LCCParameters!` seeds `p_set` from this setter's value in MW. + for l in get_components(TwoTerminalLCCLine, sys) + set_transfer_setpoint!(l, p_set_mw) + end + end + return sys +end + +# --- Mixed CPB polar parity (test_mixed_cpb_polar_parity, test_mixed_cpb_power_flow) --- + +const MIXED_PARITY_ATOL = 1e-7 +# ACTIVSg2000: zero-injection buses with G_ii ≈ 0 make the MCPB Jacobian more +# ill-conditioned than the small synthetic systems. The imag-first column +# ordering + KLU partial pivoting keep it solvable, but the converged-state +# round-off floor is looser than 1e-7; 1e-5 still pins formulation parity. +const MIXED_PARITY_ATOL_2K = 1e-5 + +_mixed_pf_settings() = SolutionParameters(; validate_voltage_magnitudes = false) + +# Assert MCPB matches polar (and, when requested, rectangular CI) on the four +# reported bus quantities, for an arbitrary AC solver. Mirrors +# `_rect_polar_parity`, parametrized over `solver` so the same fixture matrix +# validates Newton-Raphson and Trust-Region against the MCPB Jacobian. +function _mixed_polar_parity( + sys_p::PSY.System, + sys_h::PSY.System; + sys_r::Union{Nothing, PSY.System} = nothing, + pf_kwargs::NamedTuple = NamedTuple(), + atol::Float64 = MIXED_PARITY_ATOL, + solver = NewtonRaphsonACPowerFlow, +) + pf_p = ACPowerFlow{solver}(; pf_kwargs...) + pf_h = ACMixedPowerFlow{solver}(; + pf_kwargs..., + solution_parameters = _mixed_pf_settings(), + ) + res_p = solve_power_flow(pf_p, sys_p) + res_h = solve_power_flow(pf_h, sys_h) + @test res_p !== missing + @test res_h !== missing + bus_p = res_p["bus_results"] + bus_h = res_h["bus_results"] + @test maximum(abs.(bus_p.Vm .- bus_h.Vm)) < atol + @test maximum(abs.(bus_p.θ .- bus_h.θ)) < atol + # P_gen / Q_gen parity catches slack-recovery and Q-writeback bugs that + # Vm/θ parity alone cannot — the internal residual math can converge to the + # correct voltages while the reported generator outputs disagree. + @test maximum(abs.(bus_p.P_gen .- bus_h.P_gen)) < atol + @test maximum(abs.(bus_p.Q_gen .- bus_h.Q_gen)) < atol + if sys_r !== nothing + pf_r = ACRectangularPowerFlow{solver}(; + pf_kwargs..., + solution_parameters = _mixed_pf_settings(), + ) + res_r = solve_power_flow(pf_r, sys_r) + @test res_r !== missing + bus_r = res_r["bus_results"] + @test maximum(abs.(bus_r.Vm .- bus_h.Vm)) < atol + @test maximum(abs.(bus_r.θ .- bus_h.θ)) < atol + @test maximum(abs.(bus_r.P_gen .- bus_h.P_gen)) < atol + @test maximum(abs.(bus_r.Q_gen .- bus_h.Q_gen)) < atol + end + return +end + +# Multi-period analogue of `_mixed_polar_parity`: solve both formulations in +# place and assert full per-time-step state-array parity. Exercises the minimal +# per-step `improve_x0` / per-ts offsets & caches (`time_step` threaded +# correctly). This checks per-step correctness, not warm-start efficiency. +function _mixed_polar_parity_data( + pf_p::ACPowerFlow, + pf_h::PF.ACMixedPowerFlow, + sys_p::PSY.System, + sys_h::PSY.System; + atol::Float64 = MIXED_PARITY_ATOL, +) + data_p = PowerFlowData(pf_p, sys_p) + data_h = PowerFlowData(pf_h, sys_h) + @test PowerFlows.solve_power_flow!(data_p) + @test PowerFlows.solve_power_flow!(data_h) + n_ts = size(data_p.bus_magnitude, 2) + for ts in 1:n_ts + @test maximum( + abs.(data_p.bus_magnitude[:, ts] - data_h.bus_magnitude[:, ts]), + ) < atol + @test maximum( + abs.(data_p.bus_angles[:, ts] - data_h.bus_angles[:, ts]), + ) < atol + @test maximum( + abs.( + data_p.bus_active_power_injections[:, ts] - + data_h.bus_active_power_injections[:, ts] + ), + ) < atol + @test maximum( + abs.( + data_p.bus_reactive_power_injections[:, ts] - + data_h.bus_reactive_power_injections[:, ts] + ), + ) < atol + end + return +end + +function _two_swing_mixed_system() + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.06, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.REF, 230, 1.05, 0.05) + b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 230, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_source!(sys, b2, 0.0, 0.0) + _add_simple_load!(sys, b3, 40, 15) + _add_simple_line!(sys, b1, b3, 5e-3, 5e-3, 1e-3) + _add_simple_line!(sys, b2, b3, 5e-3, 5e-3, 1e-3) + return sys +end + +# --- Rectangular CI polar parity (test_rectangular_ci_polar_parity, test_mixed_cpb_polar_parity, +# test_mixed_cpb_jacobian, test_mixed_cpb_residual) --- + +const RECT_PARITY_ATOL = 1e-7 + +_rect_parity_settings() = SolutionParameters(; validate_voltage_magnitudes = false) + +function _rect_polar_parity( + sys_p::PSY.System, + sys_r::PSY.System; + pf_kwargs::NamedTuple = NamedTuple(), + pf_r_extra_settings::AbstractDict = Dict{Symbol, Any}(), + atol::Float64 = RECT_PARITY_ATOL, +) + pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}(; pf_kwargs...) + pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; + pf_kwargs..., + solution_parameters = PF._override(_rect_parity_settings(), pf_r_extra_settings), + ) + res_p = solve_power_flow(pf_p, sys_p) + res_r = solve_power_flow(pf_r, sys_r) + @test res_p !== missing + @test res_r !== missing + bus_p = res_p["bus_results"] + bus_r = res_r["bus_results"] + @test maximum(abs.(bus_p.Vm - bus_r.Vm)) < atol + @test maximum(abs.(bus_p.θ - bus_r.θ)) < atol + # P_gen / Q_gen parity catches slack-recovery and Q-writeback bugs that + # Vm/θ parity alone cannot — the internal residual math can converge to the + # correct voltages while the reported generator outputs disagree (e.g., if + # the subnetwork slack is over-attributed to REF instead of distributed + # across participating buses). + @test maximum(abs.(bus_p.P_gen - bus_r.P_gen)) < atol + @test maximum(abs.(bus_p.Q_gen - bus_r.Q_gen)) < atol + return +end + +# Multi-period analogue of `_rect_polar_parity`: solve both formulations in +# place and assert full state-array parity. +function _rect_polar_parity_data( + pf_p::ACPowerFlow, + pf_r::ACRectangularPowerFlow, + sys_p::PSY.System, + sys_r::PSY.System, +) + data_p = PowerFlowData(pf_p, sys_p) + data_r = PowerFlowData(pf_r, sys_r) + @test PowerFlows.solve_power_flow!(data_p) + @test PowerFlows.solve_power_flow!(data_r) + @test maximum(abs.(data_p.bus_magnitude - data_r.bus_magnitude)) < RECT_PARITY_ATOL + @test maximum(abs.(data_p.bus_angles - data_r.bus_angles)) < RECT_PARITY_ATOL + @test maximum( + abs.(data_p.bus_active_power_injections - + data_r.bus_active_power_injections), + ) < RECT_PARITY_ATOL + @test maximum( + abs.(data_p.bus_reactive_power_injections - + data_r.bus_reactive_power_injections), + ) < RECT_PARITY_ATOL + return +end + +function _build_zip_2bus_system(; + power_pq::Tuple{Float64, Float64} = (0.0, 0.0), + current_pq::Tuple{Float64, Float64} = (0.0, 0.0), + impedance_pq::Tuple{Float64, Float64} = (0.0, 0.0), + zip_on_ref::Bool = false, +) + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.1, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230, 1.1, 0.0) + _add_simple_line!(sys, b1, b2, 5e-3, 5e-3, 1e-3) + _add_simple_source!(sys, b1, 0.0, 0.0) + zip_bus = zip_on_ref ? b1 : b2 + _add_simple_zip_load!( + sys, + zip_bus; + constant_power_active_power = power_pq[1], + constant_power_reactive_power = power_pq[2], + constant_current_active_power = current_pq[1], + constant_current_reactive_power = current_pq[2], + constant_impedance_active_power = impedance_pq[1], + constant_impedance_reactive_power = impedance_pq[2], + ) + return sys +end + +# --- Rectangular CI power flow (test_rectangular_ci_power_flow, test_rectangular_ci_jacobian) --- + +function _rect_pf_settings() + return SolutionParameters(; validate_voltage_magnitudes = false) +end + +function _rect_two_swing_system() + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, PSY.ACBusTypes.REF, 230, 1.06, 0.0) + b2 = _add_simple_bus!(sys, 2, PSY.ACBusTypes.REF, 230, 1.05, 0.05) + b3 = _add_simple_bus!(sys, 3, PSY.ACBusTypes.PQ, 230, 1.0, 0.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_source!(sys, b2, 0.0, 0.0) + _add_simple_load!(sys, b3, 40, 15) + _add_simple_line!(sys, b1, b3, 5e-3, 5e-3, 1e-3) + _add_simple_line!(sys, b2, b3, 5e-3, 5e-3, 1e-3) + return sys +end + +# --- VSC (test_vsc_power_flow, test_area_interchange_solve, test_nr_cache_reuse) --- + +# Build c_sys5 and add one point-to-point VSC line: the `from` converter controls DC voltage +# (DC slack), the `to` converter controls (P, Q). This is the physically well-posed config: one +# terminal fixes V_dc, the other sets power. +function _build_vsc_system(; g = 50.0) + sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys5"; add_forecasts = false)) + buses = sort!(collect(PSY.get_components(PSY.ACBus, sys)); by = PSY.get_number) + from_bus = buses[1] + to_bus = buses[4] + arc = _get_or_make_arc(sys, from_bus, to_bus) + vsc = PSY.TwoTerminalVSCLine(; + name = "vsc_test", + available = true, + arc = arc, + active_power_flow = 0.5, + rating = 1.0, + active_power_limits_from = (min = -1.0, max = 1.0), + active_power_limits_to = (min = -1.0, max = 1.0), + g = g, + # from converter: DC-voltage control (DC slack), no AC-voltage control + dc_control_from = PSY.VSCDCControlModes.DC_VOLTAGE, + ac_control_from = PSY.VSCACControlModes.AC_REACTIVE_POWER, + dc_setpoint_from = 1.0, + ac_setpoint_from = 1.0, + # to converter: power control (P, Q) + dc_control_to = PSY.VSCDCControlModes.DC_POWER, + ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, + dc_setpoint_to = 0.5, + ac_setpoint_to = 1.0, + ) + PSY.add_component!(sys, vsc) + return sys +end + +# Regression: the polar VSC Jacobian must be bus-type aware. Column `2ix-1` is the |V_ac| state +# only for PQ buses; for PV it is Q_gen and for REF it is P_gen (see state_indexing_helpers.jl). +# A lossy converter whose AC terminal is a PV (or REF) bus has a nonzero ∂KCL/∂|V_ac| loss term — +# writing it into column `2ix-1` (which is not |V_ac| there) corrupts the Jacobian. |V_ac| is fixed +# at PV/REF buses, so that derivative must not enter the Jacobian at all. +function _vsc_system_pv_terminal(; g = 45.0) + sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false)) + pick(t) = first( + sort!( + collect(PSY.get_components(b -> PSY.get_bustype(b) == t, PSY.ACBus, sys)); + by = PSY.get_number, + ), + ) + from_bus = pick(PSY.ACBusTypes.PQ) # DC-voltage slack converter on a PQ bus + to_bus = pick(PSY.ACBusTypes.PV) # lossy power-control converter on a PV bus + arc = _get_or_make_arc(sys, from_bus, to_bus) + vsc = PSY.TwoTerminalVSCLine(; + name = "vsc_pv", + available = true, + arc = arc, + active_power_flow = 0.3, + rating = 2.0, + active_power_limits_from = (min = -2.0, max = 2.0), + active_power_limits_to = (min = -2.0, max = 2.0), + g = g, + dc_control_from = PSY.VSCDCControlModes.DC_VOLTAGE, + ac_control_from = PSY.VSCACControlModes.AC_REACTIVE_POWER, + dc_setpoint_from = 1.03, + reactive_power_from = 0.0, + dc_control_to = PSY.VSCDCControlModes.DC_POWER, + ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, + dc_setpoint_to = 0.35, + reactive_power_to = 0.05, + converter_loss_to = PSY.LossCurve( + PSY.QuadraticCurve(0.01, 0.02, 0.005), + PSY.NaturalUnit(), + ), + ) + PSY.add_component!(sys, vsc) + return sys +end diff --git a/test/test_vsc_limits.jl b/test/test_vsc_limits.jl index c4a14b7c..b7dc1445 100644 --- a/test/test_vsc_limits.jl +++ b/test/test_vsc_limits.jl @@ -9,7 +9,7 @@ reactive_power_limits_to = (min = -0.1, max = 0.1), ) data = PowerFlowData( - ACPowerFlow{solver}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{solver}(; solution_parameters = VSC_SOLUTION_PARAMETERS), sys, ) converged = @test_logs (:warn, r"Q = .* outside") match_mode = :any begin @@ -30,7 +30,9 @@ end rating_to = 0.3, ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) converged = @test_logs (:warn, r"S = .* exceeds s_max") match_mode = :any begin @@ -46,7 +48,9 @@ end active_power_limits_to = (min = -0.2, max = 0.2), ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) converged = @test_logs (:warn, r"P = .* outside") match_mode = :any begin @@ -64,7 +68,9 @@ end reactive_power_limits_to = (min = -0.5, max = 0.5), ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) # @test_logs with no patterns: asserts no Warn-or-above output diff --git a/test/test_vsc_power_flow.jl b/test/test_vsc_power_flow.jl index 3f45daee..4977d303 100644 --- a/test/test_vsc_power_flow.jl +++ b/test/test_vsc_power_flow.jl @@ -1,46 +1,15 @@ # VSC HVDC power-flow tests. I0: lowering of a point-to-point TwoTerminalVSCLine into the internal # DCNetwork (isolated 2-node). Later increments add the residual/Jacobian/solver tests. -# DC-network modeling is on by default; `VSC_SETTINGS` (test_utils/common.jl) passes it -# explicitly for clarity. - -# Build c_sys5 and add one point-to-point VSC line: the `from` converter controls DC voltage -# (DC slack), the `to` converter controls (P, Q). This is the physically well-posed config: one -# terminal fixes V_dc, the other sets power. -function _build_vsc_system(; g = 50.0) - sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys5"; add_forecasts = false)) - buses = sort!(collect(PSY.get_components(PSY.ACBus, sys)); by = PSY.get_number) - from_bus = buses[1] - to_bus = buses[4] - arc = _get_or_make_arc(sys, from_bus, to_bus) - vsc = PSY.TwoTerminalVSCLine(; - name = "vsc_test", - available = true, - arc = arc, - active_power_flow = 0.5, - rating = 1.0, - active_power_limits_from = (min = -1.0, max = 1.0), - active_power_limits_to = (min = -1.0, max = 1.0), - g = g, - # from converter: DC-voltage control (DC slack), no AC-voltage control - dc_control_from = PSY.VSCDCControlModes.DC_VOLTAGE, - ac_control_from = PSY.VSCACControlModes.AC_REACTIVE_POWER, - dc_setpoint_from = 1.0, - ac_setpoint_from = 1.0, - # to converter: power control (P, Q) - dc_control_to = PSY.VSCDCControlModes.DC_POWER, - ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, - dc_setpoint_to = 0.5, - ac_setpoint_to = 1.0, - ) - PSY.add_component!(sys, vsc) - return sys -end +# DC-network modeling is on by default; `VSC_SOLUTION_PARAMETERS` (test_utils/common.jl) passes +# it explicitly for clarity. @testset "VSC I0: TwoTerminalVSCLine lowers to an isolated 2-node DCNetwork" begin sys = _build_vsc_system(; g = 50.0) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) dcn = PF.get_dc_network(data) @@ -68,7 +37,9 @@ end @testset "VSC I0: pure-AC system has an empty DCNetwork (regression-safe)" begin sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys5"; add_forecasts = false)) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) dcn = PF.get_dc_network(data) @@ -99,7 +70,9 @@ end ) PSY.add_component!(sys, vsc) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) dcn = PF.get_dc_network(data) @@ -116,7 +89,9 @@ end @testset "VSC I1: polar NR solves a point-to-point VSC and meets setpoints" begin sys = _build_vsc_pq_system(; g = 50.0, p_set = 0.4, q_set = 0.1, vdc = 1.05) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) @test solve_power_flow!(data) @@ -140,7 +115,9 @@ end @testset "VSC I1: analytic polar Jacobian matches finite differences" begin sys = _build_vsc_pq_system(; g = 40.0, p_set = 0.3, q_set = -0.05, vdc = 1.02) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) @test solve_power_flow!(data) @@ -196,7 +173,9 @@ end reactive_power_to = 0.0, ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) @test solve_power_flow!(data) @@ -230,7 +209,9 @@ end ac_setpoint_to = 1.0, ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) @test solve_power_flow!(data) @@ -261,7 +242,9 @@ end ), ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) @test solve_power_flow!(data) @@ -272,53 +255,12 @@ end jac(1) verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC polar lossy") end - -# Regression: the polar VSC Jacobian must be bus-type aware. Column `2ix-1` is the |V_ac| state -# only for PQ buses; for PV it is Q_gen and for REF it is P_gen (see state_indexing_helpers.jl). -# A lossy converter whose AC terminal is a PV (or REF) bus has a nonzero ∂KCL/∂|V_ac| loss term — -# writing it into column `2ix-1` (which is not |V_ac| there) corrupts the Jacobian. |V_ac| is fixed -# at PV/REF buses, so that derivative must not enter the Jacobian at all. -function _vsc_system_pv_terminal(; g = 45.0) - sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false)) - pick(t) = first( - sort!( - collect(PSY.get_components(b -> PSY.get_bustype(b) == t, PSY.ACBus, sys)); - by = PSY.get_number, - ), - ) - from_bus = pick(PSY.ACBusTypes.PQ) # DC-voltage slack converter on a PQ bus - to_bus = pick(PSY.ACBusTypes.PV) # lossy power-control converter on a PV bus - arc = _get_or_make_arc(sys, from_bus, to_bus) - vsc = PSY.TwoTerminalVSCLine(; - name = "vsc_pv", - available = true, - arc = arc, - active_power_flow = 0.3, - rating = 2.0, - active_power_limits_from = (min = -2.0, max = 2.0), - active_power_limits_to = (min = -2.0, max = 2.0), - g = g, - dc_control_from = PSY.VSCDCControlModes.DC_VOLTAGE, - ac_control_from = PSY.VSCACControlModes.AC_REACTIVE_POWER, - dc_setpoint_from = 1.03, - reactive_power_from = 0.0, - dc_control_to = PSY.VSCDCControlModes.DC_POWER, - ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, - dc_setpoint_to = 0.35, - reactive_power_to = 0.05, - converter_loss_to = PSY.LossCurve( - PSY.QuadraticCurve(0.01, 0.02, 0.005), - PSY.NaturalUnit(), - ), - ) - PSY.add_component!(sys, vsc) - return sys -end - @testset "VSC: analytic polar Jacobian matches FD for a lossy converter on a PV bus" begin sys = _vsc_system_pv_terminal(; g = 45.0) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) # check at the flat start to isolate the Jacobian structure from solver convergence @@ -345,7 +287,9 @@ end dc_setpoint_to = 0.3, ) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) dcn = PF.get_dc_network(data) @@ -362,7 +306,10 @@ end refs = Vector{Tuple{Float64, Float64, Float64}}() for S in solvers sys = _build_vsc_pq_system(; g = 50.0, p_set = 0.4, q_set = 0.1, vdc = 1.05) - data = PowerFlowData(PF.ACPolarPowerFlow{S}(; solver_settings = VSC_SETTINGS), sys) + data = PowerFlowData( + PF.ACPolarPowerFlow{S}(; solution_parameters = VSC_SOLUTION_PARAMETERS), + sys, + ) @test solve_power_flow!(data) dcn = PF.get_dc_network(data) push!(refs, (dcn.p_c[2, 1], dcn.q_c[2, 1], dcn.node_vdc[2, 1])) @@ -381,7 +328,7 @@ end ("mixed", PF.ACMixedPowerFlow{NewtonRaphsonACPowerFlow}), ) sys = _build_vsc_pq_system(; g = 50.0, p_set = 0.4, q_set = 0.1, vdc = 1.05) - data = PowerFlowData(PF_T(; solver_settings = VSC_SETTINGS), sys) + data = PowerFlowData(PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS), sys) @test solve_power_flow!(data) dcn = PF.get_dc_network(data) sol[name] = ( @@ -415,7 +362,9 @@ end PSY.NaturalUnit(), ), ) - pf = PF.ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS) + pf = PF.ACMixedPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) @@ -441,7 +390,7 @@ end ), ) pf = PF.ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; - solver_settings = VSC_SETTINGS, + solution_parameters = VSC_SOLUTION_PARAMETERS, ) data = PowerFlowData(pf, sys) @test solve_power_flow!(data) @@ -500,7 +449,7 @@ end @testset "VSC: DC-voltage droop — analytic Jacobian matches FD ($name)" for (name, PF_T) in _ALL_AC_FORMULATIONS - pf = PF_T(; solver_settings = VSC_SETTINGS) + pf = PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS) data = PowerFlowData(pf, _vsc_droop_system()) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) @@ -512,7 +461,10 @@ end @testset "VSC: DC-voltage droop — polar, rectangular, and mixed all agree" begin sol = Dict{String, Any}() for (name, PF_T) in _ALL_AC_FORMULATIONS - data = PowerFlowData(PF_T(; solver_settings = VSC_SETTINGS), _vsc_droop_system()) + data = PowerFlowData( + PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS), + _vsc_droop_system(), + ) @test solve_power_flow!(data) dcn = PF.get_dc_network(data) sol[name] = ( @@ -534,7 +486,7 @@ end PF_T, ) in _ALL_AC_FORMULATIONS - pf = PF_T(; solver_settings = VSC_SETTINGS) + pf = PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS) data = PowerFlowData(pf, _vsc_ac_voltage_system()) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) @@ -547,7 +499,10 @@ end sol = Dict{String, Any}() for (name, PF_T) in _ALL_AC_FORMULATIONS data = - PowerFlowData(PF_T(; solver_settings = VSC_SETTINGS), _vsc_ac_voltage_system()) + PowerFlowData( + PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS), + _vsc_ac_voltage_system(), + ) @test solve_power_flow!(data) dcn = PF.get_dc_network(data) sol[name] = ( @@ -584,7 +539,7 @@ end dc_setpoint_to = 0.3, reactive_power_to = 0.05, ) - pf = PF_T(; time_steps = time_steps, solver_settings = VSC_SETTINGS) + pf = PF_T(; time_steps = time_steps, solution_parameters = VSC_SOLUTION_PARAMETERS) data = PowerFlowData(pf, sys) prepare_ts_data!(data, time_steps) @test solve_power_flow!(data) @@ -607,7 +562,9 @@ end @testset "VSC I5: 3-terminal MTDC lowers and solves across all formulations" begin sys = _build_mtdc_system() data0 = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) dcn0 = PF.get_dc_network(data0) @@ -623,7 +580,7 @@ end ("mixed", PF.ACMixedPowerFlow{NewtonRaphsonACPowerFlow}), ) sys_k = _build_mtdc_system() - data = PowerFlowData(PF_T(; solver_settings = VSC_SETTINGS), sys_k) + data = PowerFlowData(PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS), sys_k) @test solve_power_flow!(data) dcn = PF.get_dc_network(data) # Converter order follows component iteration (hash order), so assert order-independently: @@ -651,7 +608,9 @@ end # `mtdc_line_results` (TModelHVDCLine). A non-applicable table comes back empty (uniform schema). @testset "VSC results: MTDC populates converter and DC-line tables" begin sys = _build_mtdc_system() - pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS) + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) results = solve_power_flow(pf, sys) @test DataFrames.nrow(results["vsc_results"]) == 0 # no point-to-point VSC lines @@ -676,7 +635,9 @@ end dc_control_to = PSY.VSCDCControlModes.DC_POWER, dc_setpoint_to = 0.3, ) - pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS) + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) results = solve_power_flow(pf, sys) @test DataFrames.nrow(results["mtdc_results"]) == 0 @test DataFrames.nrow(results["mtdc_line_results"]) == 0 @@ -746,10 +707,10 @@ end base = PSY.get_base_power(sys) # strict DC_VOLTAGE terminal keeps its voltage setpoint as DCSET @test PF._vsc_export_dcset(vsc, :from, base) == PSY.get_dc_setpoint_from(vsc) - # droop terminal's DCSET is the scheduled active-power demand (MW, to side receives -P_flow) + # droop terminal's DCSET is its MW feed into the AC network: +P_flow on the `to` side @test isapprox( PF._vsc_export_dcset(vsc, :to, base), - -PSY.get_active_power_flow(vsc, PSY.SU) * base; + PSY.get_active_power_flow(vsc, PSY.SU) * base; atol = 1.0, ) end @@ -836,13 +797,17 @@ end @testset "VSC: two InterconnectingConverters on one AC bus are rejected" begin bad = _build_parallel_ic_system(; shared_ac = true) @test_throws ErrorException PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), bad, ) # sharing only the DC node (distinct AC buses) is a valid MTDC topology and still solves ok = _build_parallel_ic_system(; shared_ac = false) data = PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), ok, ) @test solve_power_flow!(data) @@ -894,7 +859,7 @@ end ("mixed", PF.ACMixedPowerFlow{NewtonRaphsonACPowerFlow}), ) sys = _vsc_system_ref_terminal(; g = 45.0) - pf = PF_T(; solver_settings = VSC_SETTINGS) + pf = PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS) data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) @@ -928,7 +893,7 @@ end ("mixed", PF.ACMixedPowerFlow{NewtonRaphsonACPowerFlow}), ) sys = _vsc_system_pv_terminal(; g = 45.0) - pf = PF_T(; solver_settings = VSC_SETTINGS) + pf = PF_T(; solution_parameters = VSC_SOLUTION_PARAMETERS) data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) @@ -987,7 +952,9 @@ end ) PSY.add_component!(sys_pv, vsc) @test_throws ErrorException PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys_pv, ) @@ -1026,7 +993,9 @@ end PSY.add_component!(sys_dup, vsc_k) end @test_throws ErrorException PowerFlowData( - ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys_dup, ) end @@ -1037,7 +1006,9 @@ end @testset "VSC: RobustHomotopy rejects DC networks; FDDecoupled rejects AC-voltage control" begin sys = _build_vsc_pq_system(; g = 50.0, p_set = 0.4, q_set = 0.1, vdc = 1.05) data = PowerFlowData( - ACPowerFlow{PF.RobustHomotopyPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{PF.RobustHomotopyPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys, ) @test_throws ArgumentError solve_power_flow!(data) @@ -1053,7 +1024,9 @@ end dc_setpoint_to = 0.25, ) data_vac = PowerFlowData( - ACPowerFlow{PF.FastDecoupledACPowerFlow}(; solver_settings = VSC_SETTINGS), + ACPowerFlow{PF.FastDecoupledACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ), sys_vac, ) @test_throws ArgumentError solve_power_flow!(data_vac) @@ -1082,7 +1055,10 @@ end for (label, S) in (("nr", NewtonRaphsonACPowerFlow), ("fd", PF.FastDecoupledACPowerFlow)) sys, _, _ = _vsc_system(; lossy_kwargs...) - data = PowerFlowData(ACPowerFlow{S}(; solver_settings = VSC_SETTINGS), sys) + data = PowerFlowData( + ACPowerFlow{S}(; solution_parameters = VSC_SOLUTION_PARAMETERS), + sys, + ) @test solve_power_flow!(data) dcn = PF.get_dc_network(data) sol[label] = ( @@ -1111,7 +1087,7 @@ end reactive_power_to = 0.05, ) pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; - time_steps = time_steps, solver_settings = VSC_SETTINGS) + time_steps = time_steps, solution_parameters = VSC_SOLUTION_PARAMETERS) data = PowerFlowData(pf, sys) prepare_ts_data!(data, time_steps) @test solve_power_flow!(data) @@ -1137,7 +1113,9 @@ end @testset "get_hvdc_results: MTDC converter and DC-line tables" begin sys = _build_mtdc_system() - pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS) + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) data = PowerFlowData(pf, sys) @test solve_power_flow!(data) res = get_hvdc_results(sys, data) diff --git a/test/test_vsc_results.jl b/test/test_vsc_results.jl index e65bd086..b1d4150c 100644 --- a/test/test_vsc_results.jl +++ b/test/test_vsc_results.jl @@ -8,7 +8,9 @@ _conv_ix_by_bus_number(dcn, number::Int) = only(findall(==(number), dcn.converter_ac_bus_number)) @testset "VSC results: point-to-point line write-back via solve_and_store_power_flow!" begin - pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS) + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) build() = _build_vsc_pq_system(; name = "vsc_results", active_power_flow = 0.0, @@ -54,7 +56,9 @@ _conv_ix_by_bus_number(dcn, number::Int) = end @testset "VSC results: MTDC InterconnectingConverter write-back" begin - pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; solver_settings = VSC_SETTINGS) + pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; + solution_parameters = VSC_SOLUTION_PARAMETERS, + ) data = PowerFlowData(pf, _build_mtdc_system()) @test solve_power_flow!(data) From ea80440e0a527fc5fa3dbe169d89cb3161bdc83f Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Wed, 23 Sep 2026 08:53:55 -0600 Subject: [PATCH 06/14] Read LCC transfer_setpoint through PSY's unit conversion PSY stores a power-mode transfer_setpoint per-unit on the LCC's base. The solver divided that stored value by the system base again, scheduling LCCs on PSS/E-built systems at 1/100 of their setpoint, and the export wrote the per-unit value as SETVL. Both now go through `_lcc_transfer_setpoint(lcc, units)`, which wraps `PSY.get_value(..., Val(:mw), units)`; the export asks for MW. Current-mode setpoints are amperes and pass through unchanged. Hand-built test LCCs now set per-unit values, and the test env pins PSB to jd/pf_units_fix (PowerSystemCaseBuilder.jl#229), which stores the setpoint per-unit on its legacy path too. --- src/lcc_utils.jl | 2 +- src/powersystems_utils.jl | 9 +++++++++ src/psse_export.jl | 5 ++--- test/Project.toml | 4 +++- test/test_dc_power_flow.jl | 2 +- test/test_jacobian.jl | 2 +- test/test_mixed_cpb_jacobian.jl | 2 +- test/test_psse_export.jl | 6 +++--- test/test_rectangular_ci_lcc.jl | 2 +- test/test_solve_power_flow.jl | 4 ++-- test/test_utils/common.jl | 2 +- test/test_utils/cross_file_fixtures.jl | 5 +++-- 12 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/lcc_utils.jl b/src/lcc_utils.jl index aeb2f0c0..084b7b54 100644 --- a/src/lcc_utils.jl +++ b/src/lcc_utils.jl @@ -858,7 +858,7 @@ function initialize_LCCParameters!( # lcc_p_set = I_dc_A * V_dc_V / system_base_MVA lcc_setpoint_at_rectifier .= (PSY.get_transfer_setpoint.(lccs) .>= 0.0) - lcc_p_set .= abs.(PSY.get_transfer_setpoint.(lccs) ./ base_power) # only one direction is supported, no reverse flow possible + lcc_p_set .= abs.(_lcc_transfer_setpoint.(lccs, PSY.NU) ./ base_power) # only one direction is supported, no reverse flow possible lcc_rectifier_tap .= PSY.get_rectifier_tap_setting.(lccs) lcc_inverter_tap .= PSY.get_inverter_tap_setting.(lccs) # Fixed tap targets used to pin the tap state for 0-current (0-MW) converters. diff --git a/src/powersystems_utils.jl b/src/powersystems_utils.jl index cb00b31c..934b5c45 100644 --- a/src/powersystems_utils.jl +++ b/src/powersystems_utils.jl @@ -259,3 +259,12 @@ function convert_zip_to_constant_power!(p_load::AbstractArray{T, N}, i_load .= zero(T) z_load .= zero(T) end + +# PSY stores a power-mode `transfer_setpoint` per-unit on the LCC's base (the system base); a +# current-mode one is amperes, which no power base converts. +function _lcc_transfer_setpoint(lcc::PSY.TwoTerminalLCCLine, units) + if PSY.get_power_mode(lcc) + return IS._strip_units(PSY.get_value(lcc, Val(:transfer_setpoint), Val(:mw), units)) + end + return PSY.get_transfer_setpoint(lcc) +end diff --git a/src/psse_export.jl b/src/psse_export.jl index 6cb4c27d..b791f4c8 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -2261,9 +2261,8 @@ function _compute_dcline_common_fields( NAME = _is_valid_psse_name(dcline_name) ? dcline_name : last(dcline_name, 12) NAME = _psse_quote_string(NAME) MDC = Int(PSY.get_power_mode(dcline)) - # PSY stores transfer_setpoint in the same units as SETVL (MW or A per power_mode; - # never per-unit), so it is written through unchanged. - SETVL = PSY.get_transfer_setpoint(dcline) + # SETVL is MW (power mode) or A (current mode). + SETVL = _lcc_transfer_setpoint(dcline, PSY.NU) VSCHD = PSY.get_scheduled_dc_voltage(dcline) # RDC is a DC-circuit resistance: PSY per-unitizes it against the DC base (VSCHD^2 / # baseMVA), not the rectifier AC commutating base, so the inverse conversion must use diff --git a/test/Project.toml b/test/Project.toml index 5bf1f638..a16eb09b 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -37,7 +37,9 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" PowerFlows = {path = ".."} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} # PSCB/parser branches carry the PSS/E import-contract fixes the round-trip tests need -PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "lk/switched-shunt-solved-case"} +# jd/pf_units_fix (PowerSystemCaseBuilder.jl#229) stores LCC transfer_setpoint per-unit on the +# legacy parser path; revert the pin to psy6 once #229 merges. +PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "jd/pf_units_fix"} PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems diff --git a/test/test_dc_power_flow.jl b/test/test_dc_power_flow.jl index 0bc3e88c..46ba872e 100644 --- a/test/test_dc_power_flow.jl +++ b/test/test_dc_power_flow.jl @@ -117,7 +117,7 @@ end # In the normalized initialization equation R * I_dc^2 + I_dc - P_set = 0, # zero resistance reduces to I_dc = P_set. - PSY.set_transfer_setpoint!(lcc, 25.0) + PSY.set_transfer_setpoint!(lcc, 0.25) for T in (DCPowerFlow, PTDFDCPowerFlow, vPTDFDCPowerFlow) data = PowerFlowData(T(; correct_bustypes = true), sys) @test !isnan(data.lcc.i_dc[1, 1]) diff --git a/test/test_jacobian.jl b/test/test_jacobian.jl index d647cf8c..f4e4c8a5 100644 --- a/test/test_jacobian.jl +++ b/test/test_jacobian.jl @@ -42,7 +42,7 @@ end _add_simple_source!(sys, b1, 0.0, 0.0) lcc = _add_simple_lcc!(sys, b2, b3, 0.05, 0.05, 0.08) PSY.set_inverter_extinction_angle!(lcc, 1.0) # interior, off the ϕ clamp - PSY.set_transfer_setpoint!(lcc, -50.0) # setpoint at inverter + PSY.set_transfer_setpoint!(lcc, -0.5) # setpoint at inverter verify_jacobian(sys; label = "polar 3-bus LCC, inverter-side setpoint", perturbation = 0.01) end diff --git a/test/test_mixed_cpb_jacobian.jl b/test/test_mixed_cpb_jacobian.jl index dff88c22..81ea471a 100644 --- a/test/test_mixed_cpb_jacobian.jl +++ b/test/test_mixed_cpb_jacobian.jl @@ -164,7 +164,7 @@ end # state; exercises the widened lcc_nz cache (rows 21–24) in MCPB. sys, lcc = simple_lcc_system() PSY.set_inverter_extinction_angle!(lcc, 1.0) # interior, off ϕ clamp - PSY.set_transfer_setpoint!(lcc, -50.0) # setpoint at inverter + PSY.set_transfer_setpoint!(lcc, -0.5) # setpoint at inverter R, x = _build_mixed_lcc_x(sys) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index 298e8995..de5a2e13 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -1106,13 +1106,13 @@ end @test occursin("TOLN=0.001", text) end -@testset "PSSE Exporter: LCC SETVL is written in stored MW, not scaled by SBASE" begin +@testset "PSSE Exporter: LCC SETVL is written in MW from the per-unit setpoint" begin sys = System(100.0) b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) _add_simple_source!(sys, b1, 0.0, 0.0) _add_simple_load!(sys, b2, 0.1, 0.05) - lcc = _add_simple_lcc!(sys, b1, b2, 0.01, 0.01, 0.01) # transfer_setpoint = 50 (MW) + lcc = _add_simple_lcc!(sys, b1, b2, 0.01, 0.01, 0.01) # transfer_setpoint = 0.5 pu export_location = joinpath(test_psse_export_dir, "v35", "lcc_setvl") exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) @@ -1120,7 +1120,7 @@ end raw_path, _ = get_psse_export_paths(joinpath(export_location, "lcc_setvl")) lcc_line = only(filter(l -> occursin("LCC", l), readlines(raw_path))) fields = PF._split_record(lcc_line) - @test parse(Float64, strip(fields[4])) ≈ 50.0 # SETVL, not 50 * SBASE = 5000 + @test parse(Float64, strip(fields[4])) ≈ 50.0 # SETVL in MW sys2 = read_system_with_metadata(joinpath(export_location, "lcc_setvl")) lcc2 = only(PSY.get_components(PSY.TwoTerminalLCCLine, sys2)) diff --git a/test/test_rectangular_ci_lcc.jl b/test/test_rectangular_ci_lcc.jl index 88af4027..29529916 100644 --- a/test/test_rectangular_ci_lcc.jl +++ b/test/test_rectangular_ci_lcc.jl @@ -68,7 +68,7 @@ end _add_simple_source!(sys, b1, 0.0, 0.0) lcc = _add_simple_lcc!(sys, b2, b3, 0.05, 0.05, 0.08) PSY.set_inverter_extinction_angle!(lcc, 1.0) # interior, off the ϕ clamp - PSY.set_transfer_setpoint!(lcc, -50.0) # setpoint at inverter + PSY.set_transfer_setpoint!(lcc, -0.5) # setpoint at inverter _rect_lcc_verify(sys; label = "rect CI LCC inverter-side setpoint") end diff --git a/test/test_solve_power_flow.jl b/test/test_solve_power_flow.jl index bda781aa..b6ee668b 100644 --- a/test/test_solve_power_flow.jl +++ b/test/test_solve_power_flow.jl @@ -726,7 +726,7 @@ end # repeat with a different setpoint sys, lcc = simple_lcc_system() - PSY.set_transfer_setpoint!(lcc, -25.0) + PSY.set_transfer_setpoint!(lcc, -0.25) lcc_results = solve_power_flow(pf, sys)["lcc_results"] solve_and_store_power_flow!(pf, sys) check_lcc_consistency(lcc, lcc_results) @@ -809,7 +809,7 @@ function test_lcc_ac_solver(ACSolver) # bugs in the LCC Hessian assembly. ACSolver === RobustHomotopyPowerFlow && return - PSY.set_transfer_setpoint!(lcc, -25.0) + PSY.set_transfer_setpoint!(lcc, -0.25) data = PowerFlowData(pf, sys) solve_power_flow!(data) diff --git a/test/test_utils/common.jl b/test/test_utils/common.jl index 14d99964..0c6385ac 100644 --- a/test/test_utils/common.jl +++ b/test/test_utils/common.jl @@ -486,7 +486,7 @@ function _add_simple_lcc!( arc = Arc(bus1, bus2), active_power_flow = 0.0, r = r, - transfer_setpoint = 50, + transfer_setpoint = 0.5, # 50 MW scheduled_dc_voltage = 800.0, rectifier_bridges = 1, rectifier_delay_angle_limits = (min = 0.0, max = π / 2), diff --git a/test/test_utils/cross_file_fixtures.jl b/test/test_utils/cross_file_fixtures.jl index da40d13a..1d1452e2 100644 --- a/test/test_utils/cross_file_fixtures.jl +++ b/test/test_utils/cross_file_fixtures.jl @@ -195,9 +195,10 @@ function build_lcc_control_system(; p_set_mw::Union{Nothing, Float64} = nothing) ), ) if p_set_mw !== nothing - # `initialize_LCCParameters!` seeds `p_set` from this setter's value in MW. + # `transfer_setpoint` is stored per-unit on the system base. + base = get_base_power(sys, PSY.NU) for l in get_components(TwoTerminalLCCLine, sys) - set_transfer_setpoint!(l, p_set_mw) + set_transfer_setpoint!(l, p_set_mw / base) end end return sys From 9de738c735e0e090757fded742b19374b8eb06f1 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Wed, 23 Sep 2026 09:52:07 -0600 Subject: [PATCH 07/14] Read LCC transfer_setpoint through PSY's unit-aware getter Replaces PowerFlows' local _lcc_transfer_setpoint with PSY.get_transfer_setpoint(lcc, units) from PowerSystems.jl#1815. Requires #1815 on PSY psy6. --- src/lcc_utils.jl | 2 +- src/powersystems_utils.jl | 9 --------- src/psse_export.jl | 2 +- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/lcc_utils.jl b/src/lcc_utils.jl index 084b7b54..2d05766d 100644 --- a/src/lcc_utils.jl +++ b/src/lcc_utils.jl @@ -858,7 +858,7 @@ function initialize_LCCParameters!( # lcc_p_set = I_dc_A * V_dc_V / system_base_MVA lcc_setpoint_at_rectifier .= (PSY.get_transfer_setpoint.(lccs) .>= 0.0) - lcc_p_set .= abs.(_lcc_transfer_setpoint.(lccs, PSY.NU) ./ base_power) # only one direction is supported, no reverse flow possible + lcc_p_set .= abs.(PSY.get_transfer_setpoint.(lccs, PSY.NU) ./ base_power) # only one direction is supported, no reverse flow possible lcc_rectifier_tap .= PSY.get_rectifier_tap_setting.(lccs) lcc_inverter_tap .= PSY.get_inverter_tap_setting.(lccs) # Fixed tap targets used to pin the tap state for 0-current (0-MW) converters. diff --git a/src/powersystems_utils.jl b/src/powersystems_utils.jl index 934b5c45..cb00b31c 100644 --- a/src/powersystems_utils.jl +++ b/src/powersystems_utils.jl @@ -259,12 +259,3 @@ function convert_zip_to_constant_power!(p_load::AbstractArray{T, N}, i_load .= zero(T) z_load .= zero(T) end - -# PSY stores a power-mode `transfer_setpoint` per-unit on the LCC's base (the system base); a -# current-mode one is amperes, which no power base converts. -function _lcc_transfer_setpoint(lcc::PSY.TwoTerminalLCCLine, units) - if PSY.get_power_mode(lcc) - return IS._strip_units(PSY.get_value(lcc, Val(:transfer_setpoint), Val(:mw), units)) - end - return PSY.get_transfer_setpoint(lcc) -end diff --git a/src/psse_export.jl b/src/psse_export.jl index b791f4c8..7ac722f2 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -2262,7 +2262,7 @@ function _compute_dcline_common_fields( NAME = _psse_quote_string(NAME) MDC = Int(PSY.get_power_mode(dcline)) # SETVL is MW (power mode) or A (current mode). - SETVL = _lcc_transfer_setpoint(dcline, PSY.NU) + SETVL = PSY.get_transfer_setpoint(dcline, PSY.NU) VSCHD = PSY.get_scheduled_dc_voltage(dcline) # RDC is a DC-circuit resistance: PSY per-unitizes it against the DC base (VSCHD^2 / # baseMVA), not the rectifier AC commutating base, so the inverse conversion must use From fbe3283a93d30cfba388db1bedd05f51a6431e3a Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Wed, 23 Sep 2026 15:08:18 -0600 Subject: [PATCH 08/14] Break the PowerFlowData/NR-cache reference cycle; pass input_basis Residuals, Jacobians and HomotopyHessian no longer store data; it is passed explicitly (R(data, x, t), J(data, t), ACPowerFlowJacobian(data, residual, t)). data.polar_nr_cache held a PolarNRCache whose residual and Jacobian pointed back at data. The D type parameter is dropped from those types and from PolarNRCache, and a testset guards against the field returning. The reduced-power-flow helpers now check the reductions data actually applied, as a set, instead of comparing pf's reductions to themselves. PowerSystems psy6 requires input_basis on unit-bearing keyword constructors; pass input_basis = PSY.CU at existing call sites. Pin PowerSystemCaseBuilder to psy6, which now carries jd/pf_units_fix. --- .claude/CLAUDE.md | 2 +- docs/src/tutorials/discrete_control_14bus.jl | 2 + .../benchmarks/discrete_control_scaling.jl | 16 ++- scripts/benchmarks/method_comparison.jl | 6 +- .../profiling/profile_power_flow_solvers.jl | 12 +-- src/PowerFlowData.jl | 3 +- src/RobustHomotopy/homotopy_hessian.jl | 28 ++--- src/RobustHomotopy/robust_homotopy_method.jl | 18 ++-- src/ac_power_flow_jacobian.jl | 48 +++++---- src/ac_power_flow_residual.jl | 20 ++-- src/discrete_control/control_sensitivity.jl | 34 +++--- src/fast_decoupled_method.jl | 81 +++++++------- src/gradient_descent_ac_power_flow.jl | 8 +- src/levenberg-marquardt.jl | 22 ++-- src/mixed_cpb_power_flow_jacobian.jl | 28 ++--- src/mixed_cpb_power_flow_residual.jl | 15 +-- src/power_flow_method.jl | 102 +++++++++++------- src/power_flow_setup.jl | 50 ++++----- src/psse_export.jl | 1 + src/rectangular_ci_power_flow_jacobian.jl | 29 +++-- src/rectangular_ci_power_flow_residual.jl | 16 +-- src/residual_condition_diagnostics.jl | 5 +- test/Project.toml | 5 +- test/test_ac_nr_allocations.jl | 20 ++-- test/test_area_interchange_enrollment.jl | 18 ++-- test/test_area_interchange_solve.jl | 44 ++++---- test/test_dc_phase_shifters.jl | 20 ++-- test/test_dc_power_flow.jl | 1 + test/test_discrete_control.jl | 13 ++- test/test_distributed_slack.jl | 4 +- test/test_fast_decoupled.jl | 31 +++--- test/test_gradient_descent_ac_power_flow.jl | 6 +- test/test_homotopy_hessian.jl | 53 +++++---- test/test_hvdc.jl | 4 +- test/test_iterative_methods.jl | 4 +- test/test_jacobian.jl | 8 +- test/test_lcc_zero_setpoint.jl | 26 ++--- test/test_mixed_cpb_flat_start.jl | 12 +-- test/test_mixed_cpb_jacobian.jl | 70 ++++++------ test/test_mixed_cpb_lcc.jl | 2 +- test/test_mixed_cpb_residual.jl | 6 +- test/test_nested_reduction_flow_reporting.jl | 6 +- test/test_nr_cache_reuse.jl | 38 +++---- test/test_pardiso_backend.jl | 8 +- test/test_psse_export.jl | 13 +-- test/test_rectangular_ci_jacobian.jl | 64 ++++++----- test/test_rectangular_ci_lcc.jl | 18 ++-- test/test_rectangular_ci_power_flow.jl | 4 +- test/test_rectangular_ci_residual.jl | 6 +- test/test_rectangular_ci_setup.jl | 6 +- test/test_reduced_ac_power_flow.jl | 6 +- test/test_reduced_dc_power_flow.jl | 4 +- test/test_residual_condition_diagnostics.jl | 14 +-- test/test_solve_power_flow.jl | 10 +- test/test_state_indexing_helpers.jl | 2 +- test/test_utils/common.jl | 84 ++++++++------- test/test_utils/cross_file_fixtures.jl | 22 ++-- test/test_utils/jacobian_verification.jl | 21 ++-- .../test_utils/validate_reduced_power_flow.jl | 19 +++- test/test_vsc_power_flow.jl | 83 +++++++------- test/test_zip_state_roundtrip.jl | 4 +- 61 files changed, 720 insertions(+), 605 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index edda99a8..d827a485 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -76,7 +76,7 @@ Exported solver-model types and functions (see `src/PowerFlows.jl`): **LCC / network reduction.** PNM's zero-impedance reduction merges LCC-terminal buses; `data.lcc.arcs` stores REDUCED tuples. Post-processing (`get_lcc_names`, `arc_to_lcc`) must key with reduced tuples via `get_arc_tuple(PSY.get_arc(lcc), nrd)` where `nrd = PNM.get_network_reduction_data(data.power_network_matrix)` — keying with raw tuples is a KeyError. Degree-2 parity tests must build systems with `reduce_reactive_power_injectors=false` (the default drops susceptive-FA shunts). -**Perf NR-cache reuse (polar).** `PolarNRCache` (slot `data.polar_nr_cache::RefValue{Union{Nothing,AbstractNRCache}}`) reuses residual/Jacobian/symbolic factorization across Q-limit retries and time steps; LCC or a changed subnetwork/slack forces a rebuild. `data.solver_cache::RefValue{Union{Nothing,SolverCache}}` holds the analogous per-solve cache — `DCSolverCache` (DC/PTDF) or `FastDecoupledCache` (FDNR factor-once B′/B″); the getters dispatch on the concrete subtype, so a cross-use is a loud `MethodError`, not a silent mis-read (no sentinel tag). +**Perf NR-cache reuse (polar).** `PolarNRCache` (slot `data.polar_nr_cache::RefValue{Union{Nothing,AbstractNRCache}}`) reuses residual/Jacobian/symbolic factorization across Q-limit retries and time steps; LCC or a changed subnetwork/slack forces a rebuild. Residuals, Jacobians and `HomotopyHessian` never store `data` — it is passed explicitly (`R(data, x, t)`, `J(data, t)`, `ACPowerFlowJacobian(data, residual, t)`), because the cache hangs off `data` and a stored back-reference would form a cycle; `test_nr_cache_reuse.jl` guards this. `data.solver_cache::RefValue{Union{Nothing,SolverCache}}` holds the analogous per-solve cache — `DCSolverCache` (DC/PTDF) or `FastDecoupledCache` (FDNR factor-once B′/B″); the getters dispatch on the concrete subtype, so a cross-use is a loud `MethodError`, not a silent mis-read (no sentinel tag). **Benchmark measurement trap.** Repeated `_ac_power_flow`/`solve_power_flow!` on the same `data` warm-starts to 0-iteration convergence (lazy early-return). Perturb injections per rep or you measure nothing. Use iteration count (not wall-clock) as the robust metric; the wall-clock timer is noisy. Background heavy compute (10k benchmark, full perf suite) — never block synchronously in a subagent. diff --git a/docs/src/tutorials/discrete_control_14bus.jl b/docs/src/tutorials/discrete_control_14bus.jl index f7aadc84..66332b4e 100644 --- a/docs/src/tutorials/discrete_control_14bus.jl +++ b/docs/src/tutorials/discrete_control_14bus.jl @@ -81,6 +81,7 @@ facts = FACTSControlDevice(; voltage_setpoint = 1.0, max_shunt_current = 100.0, shunt_control_type = FACTSShuntControlType.STATCOM, + input_basis = CU, ) add_component!(sys, facts) @@ -176,6 +177,7 @@ facts_tight = FACTSControlDevice(; voltage_setpoint = 1.0, max_shunt_current = 5.0, shunt_control_type = FACTSShuntControlType.STATCOM, + input_basis = CU, ) add_component!(sys3, facts_tight) diff --git a/scripts/benchmarks/discrete_control_scaling.jl b/scripts/benchmarks/discrete_control_scaling.jl index fb30cffe..cf764626 100644 --- a/scripts/benchmarks/discrete_control_scaling.jl +++ b/scripts/benchmarks/discrete_control_scaling.jl @@ -49,22 +49,27 @@ function _add_feeder!(sys::PSY.System, ref::PSY.ACBus, k::Int) PSY.add_component!(sys, PSY.PowerLoad(; name = "load_$k", available = true, bus = b_load, active_power = 0.5, reactive_power = 0.25, base_power = 100.0, - max_active_power = 100.0, max_reactive_power = 100.0)) + max_active_power = 100.0, max_reactive_power = 100.0, + input_basis = PSY.CU)) PSY.add_component!(sys, PSY.PowerLoad(; name = "shload_$k", available = true, bus = b_sh, active_power = 0.05, reactive_power = 0.025, base_power = 100.0, - max_active_power = 100.0, max_reactive_power = 100.0)) + max_active_power = 100.0, max_reactive_power = 100.0, + input_basis = PSY.CU)) PSY.add_component!(sys, PSY.Line(; name = "line_$k", available = true, active_power_flow = 0.0, reactive_power_flow = 0.0, arc = PSY.Arc(; from = ref, to = b_sh), r = 1e-2, x = 1e-2, b = (from = 0.0, to = 0.0), - rating = 10.0, angle_limits = (min = -pi / 2, max = pi / 2))) + rating = 10.0, angle_limits = (min = -pi / 2, max = pi / 2), + input_basis = PSY.CU)) PSY.add_component!(sys, PSY.TwoWindingTransformer(; name = "tap_$k", circuit = PSY.TransformerCircuit(; available = true, arc = PSY.Arc(; from = ref, to = b_load), r = 0.01, x = 0.10, tap = 1.0, rating = 1.0, base_power = 100.0, - control_objective = PSY.TransformerControlObjective.VOLTAGE))) + control_objective = PSY.TransformerControlObjective.VOLTAGE, + input_basis = PSY.CU), + input_basis = PSY.CU)) PSY.add_component!(sys, PSY.SwitchedAdmittance(; name = "shunt_$k", available = true, bus = b_sh, number_engaged = [0], number_of_steps = [4], @@ -80,7 +85,8 @@ function build_controlled_system(K::Int) PSY.add_component!(sys, ref) PSY.add_component!(sys, PSY.Source(; name = "source", available = true, bus = ref, - active_power = 0.0, reactive_power = 0.0, R_th = 0.0, X_th = 1e-5)) + active_power = 0.0, reactive_power = 0.0, R_th = 0.0, X_th = 1e-5, + input_basis = PSY.CU)) for k in 1:K _add_feeder!(sys, ref, k) end diff --git a/scripts/benchmarks/method_comparison.jl b/scripts/benchmarks/method_comparison.jl index f37258f5..b3d8c170 100644 --- a/scripts/benchmarks/method_comparison.jl +++ b/scripts/benchmarks/method_comparison.jl @@ -209,10 +209,10 @@ function run_trial(sys, solver_type, solver_settings, x_solved, n, bus_types, K; # consistently across all methods (including homotopy). residual_obj = PF.ACPowerFlowResidual(data, 1) if haskey(kwargs, :x0) - residual_obj(kwargs[:x0], 1) + residual_obj(data, kwargs[:x0], 1) else x0_default = PF.calculate_x0(data, 1) - residual_obj(x0_default, 1) + residual_obj(data, x0_default, 1) end init_res_L2 = norm(residual_obj.Rv, 2) init_res_Linf = norm(residual_obj.Rv, Inf) @@ -237,7 +237,7 @@ function run_trial(sys, solver_type, solver_settings, x_solved, n, bus_types, K; # Compute final power flow residual directly — don't rely on log capture, # which may be missing (homotopy) or absent on non-convergence. - residual_obj(x_final, 1) + residual_obj(data, x_final, 1) final_res_L2 = norm(residual_obj.Rv, 2) final_res_Linf = norm(residual_obj.Rv, Inf) diff --git a/scripts/profiling/profile_power_flow_solvers.jl b/scripts/profiling/profile_power_flow_solvers.jl index 66e8d57f..0f73e86f 100644 --- a/scripts/profiling/profile_power_flow_solvers.jl +++ b/scripts/profiling/profile_power_flow_solvers.jl @@ -191,7 +191,7 @@ function profile_ac(label, solver, backend, extra_settings = Dict{Symbol, Any}() _flatstart!(data, pq_idx) residual = PF.ACPowerFlowResidual(data, 1) p_res = collect_stats(() -> PF.ACPowerFlowResidual(data, 1)) - p_jac = collect_stats(() -> PF.ACPowerFlowJacobian(residual, 1)) + p_jac = collect_stats(() -> PF.ACPowerFlowJacobian(data, residual, 1)) # Full solve FROM A FLAT START — re-flat-start each call so it iterates fully. solve_from_flat = function () @@ -209,18 +209,18 @@ function profile_ac(label, solver, backend, extra_settings = Dict{Symbol, Any}() # representative and crash-free. cdata = PF.PowerFlowData(pf, SYS) cresidual = PF.ACPowerFlowResidual(cdata, 1) - J = PF.ACPowerFlowJacobian(cresidual, 1) + J = PF.ACPowerFlowJacobian(cdata, cresidual, 1) x = PF.calculate_x0(cdata, 1) - cresidual(x, 1) - J(1) + cresidual(cdata, x, 1) + J(cdata, 1) tag = PF.resolve_linear_solver_backend(backend) cache = PF.make_linear_solver_cache(tag, J.Jv) PF.symbolic_factor!(cache, J.Jv) PF.numeric_refactor!(cache, J.Jv) rbuf = copy(cresidual.Rv) - c_res = collect_stats(() -> cresidual(x, 1)) - c_jac = collect_stats(() -> J(1)) + c_res = collect_stats(() -> cresidual(cdata, x, 1)) + c_jac = collect_stats(() -> J(cdata, 1)) c_refac = collect_stats(() -> PF.numeric_refactor!(cache, J.Jv)) c_solve = collect_stats(() -> (copyto!(rbuf, cresidual.Rv); PF.solve!(cache, rbuf))) diff --git a/src/PowerFlowData.jl b/src/PowerFlowData.jl index f4888642..23f3a3bb 100644 --- a/src/PowerFlowData.jl +++ b/src/PowerFlowData.jl @@ -192,8 +192,7 @@ struct PowerFlowData{ # state-vector buffers so the Q-limit retry loop and the multi-period time-step loop skip # reconstructing these structure-invariant objects on every `_newton_power_flow` call. Its own # slot so it never contends with a DC/FD `solver_cache`. Typed as the `AbstractNRCache` forward - # supertype because the concrete `PolarNRCache` cannot be referenced here (construction cycle - # through `ACPowerFlowResidual`). + # supertype because `PolarNRCache` is defined later, in `power_flow_method.jl`. polar_nr_cache::Base.RefValue{Union{Nothing, AbstractNRCache}} end diff --git a/src/RobustHomotopy/homotopy_hessian.jl b/src/RobustHomotopy/homotopy_hessian.jl index 7abfa2ac..dffa23d2 100644 --- a/src/RobustHomotopy/homotopy_hessian.jl +++ b/src/RobustHomotopy/homotopy_hessian.jl @@ -1,6 +1,4 @@ struct HomotopyHessian - # PERF: data is stored in triplicate: here, inside pfResidual, and inside J. - data::ACPowerFlowData pfResidual::ACPowerFlowResidual J::ACPowerFlowJacobian PQ_V_mags::BitVector # true iff that coordinate in the state vector is V_mag at a PQ bus @@ -91,13 +89,15 @@ function _build_jtj_nz_cache( end """Compute value of gradient and Hessian at x.""" -function (hess::HomotopyHessian)(x::Vector{Float64}, t_k::Float64, time_step::Int) - hess.pfResidual(x, time_step) +function (hess::HomotopyHessian)( + data::ACPowerFlowData, x::Vector{Float64}, t_k::Float64, time_step::Int, +) + hess.pfResidual(data, x, time_step) Rv = hess.pfResidual.Rv - hess.J(time_step) + hess.J(data, time_step) Jv = hess.J.Jv _update_hessian_matrix_values!( - hess.Hv, Rv, hess.data, time_step, + hess.Hv, Rv, data, time_step, hess.edge_i, hess.edge_k, hess.edge_nz, hess.diag_nz, hess.diag_accum) _refresh_JtJ!(hess.Hv, Jv, hess.jtj_p1, hess.jtj_p2, hess.jtj_offsets) Hvnz = SparseArrays.nonzeros(hess.Hv) @@ -128,8 +128,11 @@ function _homotopy_gradient!( return grad end -function F_value(hess::HomotopyHessian, t_k::Float64, x::Vector{Float64}, time_step::Int) - hess.pfResidual(x, time_step) +function F_value( + hess::HomotopyHessian, data::ACPowerFlowData, t_k::Float64, x::Vector{Float64}, + time_step::Int, +) + hess.pfResidual(data, x, time_step) Rv = hess.pfResidual.Rv # Σ (x−1)² over PQ |V| coordinates. φ_sq = 0.0 @@ -146,12 +149,13 @@ end # slightly confusing that I have the field grad, and the argument grad. function gradient_value!(grad::Vector{Float64}, hess::HomotopyHessian, + data::ACPowerFlowData, t_k::Float64, x::Vector{Float64}, time_step::Int, ) - hess.pfResidual(x, time_step) - hess.J(time_step) # PERF bottleneck. Look into a different line search strategy? + hess.pfResidual(data, x, time_step) + hess.J(data, time_step) # PERF bottleneck. Look into a different line search strategy? # or otherwise reduce the number of gradient computations? # for a 10k bus system, computing J takes over 10x longer than computing F. _homotopy_gradient!(grad, hess, t_k, x, hess.J.Jv, hess.pfResidual.Rv) @@ -247,7 +251,7 @@ function HomotopyHessian(data::ACPowerFlowData, time_step::Int) end end pfResidual = ACPowerFlowResidual(data, time_step) - J = ACPowerFlowJacobian(pfResidual, time_step) + J = ACPowerFlowJacobian(data, pfResidual, time_step) # Allocate Hv with the maximal sparsity pattern of J' * J. Sparse `*` # currently preserves structural zeros, but that isn't a documented # SparseArrays contract, so we defensively fill nzval with ones to force @@ -281,7 +285,7 @@ function HomotopyHessian(data::ACPowerFlowData, time_step::Int) _build_hessian_edge_nz_cache(Hv, data, time_step) jtj_p1, jtj_p2, jtj_offsets = _build_jtj_nz_cache(J.Jv, Hv) return HomotopyHessian( - data, pfResidual, J, PQ_V_mags, zeros(n_state), Hv, + pfResidual, J, PQ_V_mags, zeros(n_state), Hv, zeros(n_state), pq_diag_nz, edge_i, edge_k, edge_nz, diag_nz, zeros(4, nbuses), jtj_p1, jtj_p2, jtj_offsets) diff --git a/src/RobustHomotopy/robust_homotopy_method.jl b/src/RobustHomotopy/robust_homotopy_method.jl index 46c9971a..f1afdcc1 100644 --- a/src/RobustHomotopy/robust_homotopy_method.jl +++ b/src/RobustHomotopy/robust_homotopy_method.jl @@ -33,7 +33,7 @@ function _newton_power_flow(pf::ACPolarPowerFlow{<:RobustHomotopyPowerFlow}, # the sparse structure of the Hessian is different at t_k = 0.0 and t_k > 0.0 # so we need to increase t_k once before we initialize the solver. t_k += Δt_k - homHess(x, t_k, time_step) + homHess(data, x, t_k, time_step) # options: KLUHessianSolver, CholeskyHessianSolver (fastest) hSolver = CholeskyHessianSolver(homHess.Hv) @@ -42,7 +42,8 @@ function _newton_power_flow(pf::ACPolarPowerFlow{<:RobustHomotopyPowerFlow}, success = true total_iters = 0 while true # go onto next t_k even if search doesn't terminate within max iterations. - converged_t_k, iters = _second_order_newton(homHess, t_k, time_step, x, hSolver) + converged_t_k, iters = + _second_order_newton(homHess, data, t_k, time_step, x, hSolver) total_iters += iters if t_k == 1.0 success = converged_t_k @@ -77,6 +78,7 @@ function info_helper(homHess::HomotopyHessian, t_k::Float64, F_val::Float64, msg end function _second_order_newton(homHess::HomotopyHessian, + data::ACPowerFlowData, t_k::Float64, time_step::Int, x::Vector{Float64}, @@ -85,19 +87,20 @@ function _second_order_newton(homHess::HomotopyHessian, tol::Float64 = DEFAULT_NR_TOL, ) i, converged, stop = 0, false, false - F_val = F_value(homHess, t_k, x, time_step) + F_val = F_value(homHess, data, t_k, x, time_step) last_tk = t_k == 1.0 δ = zeros(size(x, 1)) # PERF: allocating while i < maxIterations && !converged && !stop stop = _second_order_newton_step( homHess, + data, t_k, time_step, x, hSolver, δ, ) - F_val = F_value(homHess, t_k, x, time_step) + F_val = F_value(homHess, data, t_k, x, time_step) converged = (last_tk ? norm(homHess.pfResidual.Rv, Inf) : abs(F_val)) < tol i += 1 if converged @@ -110,15 +113,16 @@ function _second_order_newton(homHess::HomotopyHessian, end function _second_order_newton_step(homHess::HomotopyHessian, + data::ACPowerFlowData, t_k::Float64, time_step::Int, x::Vector{Float64}, hSolver::HessianSolver, δ::Vector{Float64}, ) - F_val = F_value(homHess, t_k, x, time_step) + F_val = F_value(homHess, data, t_k, x, time_step) last_step = t_k == 1.0 - homHess(x, t_k, time_step) + homHess(data, x, t_k, time_step) if !last_step && dot(homHess.grad, homHess.grad) < GRAD_ZERO && LinearAlgebra.isposdef(homHess.Hv) # stop case 1: hit local minimum. info_helper(homHess, t_k, F_val, "local minimum") @@ -130,7 +134,7 @@ function _second_order_newton_step(homHess::HomotopyHessian, δ .*= -1 # Create objective function - ϕ = α -> F_value(homHess, t_k, x + α * δ, time_step) + ϕ = α -> F_value(homHess, data, t_k, x + α * δ, time_step) # Perform line search φ_0 = F_val diff --git a/src/ac_power_flow_jacobian.jl b/src/ac_power_flow_jacobian.jl index 51153ffb..a8139028 100644 --- a/src/ac_power_flow_jacobian.jl +++ b/src/ac_power_flow_jacobian.jl @@ -6,15 +6,17 @@ A struct that represents the Jacobian matrix for AC power flow calculations. This struct uses the functor pattern, meaning instances of `ACPowerFlowJacobian` store the data (Jacobian matrix) internally and can be called as a function at the same time. Calling the instance as a function updates the stored Jacobian matrix. +Does not store the grid model `data`: it is threaded explicitly through the functor and +constructor calls instead, to avoid a reference cycle through `data.polar_nr_cache` (see +`PolarNRCache`). + # Fields -- `data::ACPowerFlowData`: The grid model data used for power flow calculations. - `Jv::SparseArrays.SparseMatrixCSC{Float64, $J_INDEX_TYPE}`: The Jacobian matrix, which is updated by `_update_jacobian_matrix_values!`. - `bus_slack_participation_factors::SparseVector{Float64, Int}`: Normalized per-bus slack participation factors for the current time step (from the `ACPowerFlowResidual`). Used for the distributed slack Jacobian entries. - `subnetworks::Dict{Int64, Vector{Int64}}`: Subnetwork mapping from REF bus to bus list (from the `ACPowerFlowResidual`). Used for the distributed slack Jacobian entries. - `independent_ref::Set{Int}`: Multi-swing REF bus indices, from `_multi_swing_ref_indices`. Computed once at construction because the Q-limit loop only flips PV↔PQ, never REF. """ -struct ACPowerFlowJacobian{D <: ACPowerFlowData} - data::D +struct ACPowerFlowJacobian Jv::SparseArrays.SparseMatrixCSC{Float64, J_INDEX_TYPE} # This is the Jacobian matrix, updated in place by `_update_jacobian_matrix_values!` bus_slack_participation_factors::SparseVector{Float64, Int} subnetworks::Dict{Int64, Vector{Int64}} @@ -36,24 +38,25 @@ struct ACPowerFlowJacobian{D <: ACPowerFlowData} end """ - (J::ACPowerFlowJacobian)(time_step::Int64) + (J::ACPowerFlowJacobian)(data::ACPowerFlowData, time_step::Int64) Update the Jacobian matrix `Jv` using `_update_jacobian_matrix_values!` and the provided data and time step. Defining this method allows an instance of `ACPowerFlowJacobian` to be called as a function, following the functor pattern. # Arguments +- `data::ACPowerFlowData`: The grid model data used for power flow calculations. - `time_step::Int64`: The time step for the calculations. # Example ```julia residual = ACPowerFlowResidual(data, time_step) -J = ACPowerFlowJacobian(residual, time_step) -J(time_step) # Updates the Jacobian matrix Jv +J = ACPowerFlowJacobian(data, residual, time_step) +J(data, time_step) # Updates the Jacobian matrix Jv ``` """ -function (J::ACPowerFlowJacobian)(time_step::Int64) - _update_jacobian_matrix_values!(J.Jv, J.data, time_step, +function (J::ACPowerFlowJacobian)(data::ACPowerFlowData, time_step::Int64) + _update_jacobian_matrix_values!(J.Jv, data, time_step, J.bus_slack_participation_factors, J.subnetworks, J.independent_ref, J.bus_active_constant_I, J.bus_reactive_constant_I, J.bus_active_constant_Z, J.bus_reactive_constant_Z, @@ -63,7 +66,7 @@ function (J::ACPowerFlowJacobian)(time_step::Int64) end """ - (J::ACPowerFlowJacobian)(J::SparseArrays.SparseMatrixCSC{Float64, $J_INDEX_TYPE}, time_step::Int64) + (J::ACPowerFlowJacobian)(data::ACPowerFlowData, J::SparseArrays.SparseMatrixCSC{Float64, $J_INDEX_TYPE}, time_step::Int64) Use the `ACPowerFlowJacobian` to update the provided Jacobian matrix `J` inplace. @@ -72,22 +75,24 @@ Update the internally stored Jacobian matrix `Jv` using `_update_jacobian_matrix This method allows an instance of ACPowerFlowJacobian to be called as a function, following the functor pattern. # Arguments -- `J::SparseArrays.SparseMatrixCSC{Float64, $J_INDEX_TYPE}`: A sparse matrix to be updated with new values of the Jacobian matrix. +- `data::ACPowerFlowData`: The grid model data used for power flow calculations. +- `Jv::SparseArrays.SparseMatrixCSC{Float64, $J_INDEX_TYPE}`: A sparse matrix to be updated with new values of the Jacobian matrix. - `time_step::Int64`: The time step for the calculations. # Example ```julia residual = ACPowerFlowResidual(data, time_step) -J = ACPowerFlowJacobian(residual, time_step) +J = ACPowerFlowJacobian(data, residual, time_step) Jv = SparseArrays.sparse(Float64[], J_INDEX_TYPE[], J_INDEX_TYPE[]) -J(Jv, time_step) # Updates the Jacobian matrix Jv and writes it to J +J(data, Jv, time_step) # Updates the Jacobian matrix Jv and writes it to J ``` """ function (J::ACPowerFlowJacobian)( + data::ACPowerFlowData, Jv::SparseArrays.SparseMatrixCSC{Float64, J_INDEX_TYPE}, time_step::Int64, ) - _update_jacobian_matrix_values!(J.Jv, J.data, time_step, + _update_jacobian_matrix_values!(J.Jv, data, time_step, J.bus_slack_participation_factors, J.subnetworks, J.independent_ref, J.bus_active_constant_I, J.bus_reactive_constant_I, J.bus_active_constant_Z, J.bus_reactive_constant_Z, @@ -98,7 +103,7 @@ function (J::ACPowerFlowJacobian)( end """ - ACPowerFlowJacobian(residual::ACPowerFlowResidual, time_step::Int64) -> ACPowerFlowJacobian + ACPowerFlowJacobian(data::ACPowerFlowData, residual::ACPowerFlowResidual, time_step::Int64) -> ACPowerFlowJacobian Constructor for `ACPowerFlowJacobian`. The returned instance has its sparsity pattern initialized and shares the residual's slack-participation, subnetwork, @@ -106,7 +111,8 @@ and ZIP-coefficient caches — the residual must be constructed first against th same `data` and `time_step`. # Arguments -- `residual::ACPowerFlowResidual`: The companion residual; supplies `data`, +- `data::ACPowerFlowData`: The grid model data used for power flow calculations. +- `residual::ACPowerFlowResidual`: The companion residual; supplies `bus_slack_participation_factors`, `subnetworks`, and the per-bus ZIP load coefficient vectors. - `time_step::Int64`: The time step for the calculations. @@ -114,8 +120,8 @@ same `data` and `time_step`. # Example ```julia residual = ACPowerFlowResidual(data, time_step) -J = ACPowerFlowJacobian(residual, time_step) -J(time_step) # Updates the Jacobian matrix stored internally in J. +J = ACPowerFlowJacobian(data, residual, time_step) +J(data, time_step) # Updates the Jacobian matrix stored internally in J. J.Jv # Access the Jacobian matrix stored internally in J. ``` """ @@ -158,23 +164,23 @@ function _get_or_build_jacobian_structure( end function ACPowerFlowJacobian( + data::ACPowerFlowData, residual::ACPowerFlowResidual, time_step::Int64, ) Jv0 = _get_or_build_jacobian_structure( - residual.data, + data, residual.bus_slack_participation_factors, residual.subnetworks, time_step, ) od_from, od_to, od_ybus_nz, od_jnz, diag_jnz, diag_ybus_nz, diag_accum = - _build_polar_nz_caches(residual.data, Jv0) + _build_polar_nz_caches(data, Jv0) return ACPowerFlowJacobian( - residual.data, Jv0, residual.bus_slack_participation_factors, residual.subnetworks, - _multi_swing_ref_indices(residual.data.bus_type, residual.subnetworks, time_step), + _multi_swing_ref_indices(data.bus_type, residual.subnetworks, time_step), residual.bus_active_constant_I, residual.bus_reactive_constant_I, residual.bus_active_constant_Z, diff --git a/src/ac_power_flow_residual.jl b/src/ac_power_flow_residual.jl index b4bfb515..39b724af 100644 --- a/src/ac_power_flow_residual.jl +++ b/src/ac_power_flow_residual.jl @@ -4,7 +4,6 @@ A struct to keep track of the residuals in the Newton-Raphson AC power flow calculation. # Fields -- `data::ACPowerFlowData`: The grid model data. - `Rv::Vector{Float64}`: A vector of the values of the residuals. - `P_net::Vector{Float64}`: A vector of net active power injections. - `Q_net::Vector{Float64}`: A vector of net reactive power injections. @@ -14,8 +13,7 @@ A struct to keep track of the residuals in the Newton-Raphson AC power flow calc - `P_slack_buf::Vector{Float64}`: Scratch buffer of length `n_buses` used by `_update_residual_values!` to write the per-subnetwork slack distribution in place, avoiding a per-iteration allocation when indexing `bus_slack_participation_factors` by `subnetwork_buses`. - `validate_indices::Vector{Int}`: precomputed `x`-indices of PQ-bus |V| entries for the per-iteration voltage-magnitude diagnostic. """ -struct ACPowerFlowResidual{D <: ACPowerFlowData} - data::D +struct ACPowerFlowResidual Rv::Vector{Float64} P_net::Vector{Float64} Q_net::Vector{Float64} @@ -55,7 +53,6 @@ function ACPowerFlowResidual(data::ACPowerFlowData, time_step::Int64) _build_bus_slack_participation_factors(data, bus_type, subnetworks, time_step) residual = ACPowerFlowResidual( - data, Vector{Float64}(undef, 2 * n_buses + state_tail_length(data, get_dc_network(data))), Vector{Float64}(undef, n_buses), @@ -105,7 +102,7 @@ function _refresh_residual_setpoints!( end """ - (Residual::ACPowerFlowResidual)(Rv::Vector{Float64}, x::Vector{Float64}, time_step::Int64) + (Residual::ACPowerFlowResidual)(data::ACPowerFlowData, Rv::Vector{Float64}, x::Vector{Float64}, time_step::Int64) Evaluate the AC power flow residuals and store the result in `Rv` using the provided state vector `x` and the current time step `time_step`. @@ -115,11 +112,13 @@ This makes the struct callable. Calling the `ACPowerFlowResidual` will also update the values of P, Q, V, Θ in the `data` struct. # Arguments +- `data::ACPowerFlowData`: The grid model data. - `Rv::Vector{Float64}`: The vector to store the calculated residuals. - `x::Vector{Float64}`: The state vector. - `time_step::Int64`: The current time step. """ function (Residual::ACPowerFlowResidual)( + data::ACPowerFlowData, Rv::Vector{Float64}, x::Vector{Float64}, time_step::Int64, @@ -136,7 +135,7 @@ function (Residual::ACPowerFlowResidual)( Residual.bus_reactive_constant_I, Residual.bus_active_constant_Z, Residual.bus_reactive_constant_Z, - Residual.data, + data, time_step, Residual.P_slack_buf, ) @@ -145,7 +144,7 @@ function (Residual::ACPowerFlowResidual)( end """ - (Residual::ACPowerFlowResidual)(x::Vector{Float64}, time_step::Int64) + (Residual::ACPowerFlowResidual)(data::ACPowerFlowData, x::Vector{Float64}, time_step::Int64) Update the AC power flow residuals inplace and store the result in the attribute `Rv` of the struct. The inputs are the values of state vector `x` and the current time step `time_step`. @@ -154,10 +153,13 @@ This makes the struct callable. Calling the `ACPowerFlowResidual` will also update the values of P, Q, V, Θ in the `data` struct. # Arguments +- `data::ACPowerFlowData`: The grid model data. - `x::Vector{Float64}`: The state vector values. - `time_step::Int64`: The current time step. """ -function (Residual::ACPowerFlowResidual)(x::Vector{Float64}, time_step::Int64) +function (Residual::ACPowerFlowResidual)( + data::ACPowerFlowData, x::Vector{Float64}, time_step::Int64, +) _update_residual_values!( Residual.Rv, x, @@ -170,7 +172,7 @@ function (Residual::ACPowerFlowResidual)(x::Vector{Float64}, time_step::Int64) Residual.bus_reactive_constant_I, Residual.bus_active_constant_Z, Residual.bus_reactive_constant_Z, - Residual.data, + data, time_step, Residual.P_slack_buf, ) diff --git a/src/discrete_control/control_sensitivity.jl b/src/discrete_control/control_sensitivity.jl index 037b803d..8b05699d 100644 --- a/src/discrete_control/control_sensitivity.jl +++ b/src/discrete_control/control_sensitivity.jl @@ -5,9 +5,9 @@ function _sensitivity_residual_jacobian(::ACPolarPowerFlow, data, ts::Int) residual = ACPowerFlowResidual(data, ts) # Not `initialize_power_flow_variables`: `improve_x0` would move x off the converged base. x = _sensitivity_x0(residual, data, ts) - residual(x, ts) - J = ACPowerFlowJacobian(residual, ts) - J(ts) + residual(data, x, ts) + J = ACPowerFlowJacobian(data, residual, ts) + J(data, ts) return residual, J end @@ -70,16 +70,16 @@ _sensitivity_x0(::ACPowerFlowResidual, data, ts::Int) = calculate_x0(data, ts) # `ACPowerFlowJacobian`'s p-dependent fields are the SAME vectors as the residual's (passed by # reference at construction), already current after `_refresh_residual_inputs!`: nothing to do. -_refresh_jacobian_yb_caches!(J, ::ACPowerFlowResidual, ts::Int) = return +_refresh_jacobian_yb_caches!(J, data, ::ACPowerFlowResidual, ts::Int) = return function _refresh_sensitivity_context!(ctx::_SensitivityContext, data, ts::Int)::Bool view(data.bus_type, :, ts) == ctx.bus_type || return false residual = ctx.residual _refresh_residual_inputs!(residual, data, ts) || return false - _refresh_jacobian_yb_caches!(ctx.J, residual, ts) + _refresh_jacobian_yb_caches!(ctx.J, data, residual, ts) x = _sensitivity_x0(residual, data, ts) - ctx.residual(x, ts) - ctx.J(ts) + ctx.residual(data, x, ts) + ctx.J(data, ts) try numeric_refactor!(ctx.lin_cache, ctx.J.Jv) catch e @@ -191,20 +191,20 @@ end # Unlike `ACPowerFlowJacobian`, these cache `Y_bus_eff`-derived values at construction, so a # tap/shunt move leaves them stale — rerun the constructor's population steps against the refresh. -function _refresh_jacobian_yb_caches!(J, r::ACRectangularCIResidual, ts::Int) +function _refresh_jacobian_yb_caches!(J, data, r::ACRectangularCIResidual, ts::Int) @inbounds for i in eachindex(J.Y_diag) J.Y_diag[i] = r.Y_bus_eff[i, i] end _populate_constant_yb_blocks!( - J.Jv, r.Y_bus_eff, r.bus_state_offset, view(r.data.bus_type, :, ts)) + J.Jv, r.Y_bus_eff, r.bus_state_offset, view(data.bus_type, :, ts)) return end -function _refresh_jacobian_yb_caches!(J, r::ACMixedCPBResidual, ts::Int) +function _refresh_jacobian_yb_caches!(J, data, r::ACMixedCPBResidual, ts::Int) @inbounds for i in eachindex(J.Y_diag) J.Y_diag[i] = r.Y_bus_eff[i, i] end _populate_mixed_constant_yb_blocks!( - J.Jv, r.Y_bus_eff, r.bus_state_offset, view(r.data.bus_type, :, ts)) + J.Jv, r.Y_bus_eff, r.bus_state_offset, view(data.bus_type, :, ts)) @inbounds for p in eachindex(J.offdiag_pv_y) J.offdiag_pv_y[p] = r.Y_bus_eff[J.offdiag_pv_i[p], J.offdiag_pv_k[p]] end @@ -322,17 +322,17 @@ end function _sensitivity_residual_jacobian(::ACRectangularPowerFlow, data, ts::Int) residual = ACRectangularCIResidual(data, ts) x = _sensitivity_x0(residual, data, ts) - residual(x, ts) - J = ACRectangularCIJacobian(residual, ts) - J(ts) + residual(data, x, ts) + J = ACRectangularCIJacobian(data, residual, ts) + J(data, ts) return residual, J end function _sensitivity_residual_jacobian(::ACMixedPowerFlow, data, ts::Int) residual = ACMixedCPBResidual(data, ts) x = _sensitivity_x0(residual, data, ts) - residual(x, ts) - J = ACMixedCPBJacobian(residual, ts) - J(ts) + residual(data, x, ts) + J = ACMixedCPBJacobian(data, residual, ts) + J(data, ts) return residual, J end diff --git a/src/fast_decoupled_method.jl b/src/fast_decoupled_method.jl index b6abca35..24b26735 100644 --- a/src/fast_decoupled_method.jl +++ b/src/fast_decoupled_method.jl @@ -293,7 +293,7 @@ _fd_finalize_jv(::Nothing) = nothing _fd_finalize_jv(J) = J.Jv """ - _fd_maybe_handoff!(handoff_solver, pf, sv, residual, J, time_step, tol, linear_solver, + _fd_maybe_handoff!(handoff_solver, pf, sv, residual, J, data, time_step, tol, linear_solver, solver_name, fd_iters) -> (converged::Bool, handoff_iters::Int) Run the opt-in handoff solver (`NewtonRaphsonACPowerFlow` / `TrustRegionACPowerFlow` / @@ -314,6 +314,7 @@ function _fd_maybe_handoff!( sv::StateVectorCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{Nothing, ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + ::ACPowerFlowData, time_step::Int64, tol::Float64, linear_solver::Union{Nothing, AbstractString}, @@ -329,6 +330,7 @@ function _fd_maybe_handoff!( sv::StateVectorCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{Nothing, ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, time_step::Int64, tol::Float64, linear_solver::Union{Nothing, AbstractString}, @@ -337,13 +339,13 @@ function _fd_maybe_handoff!( ) fd_met_tol = norm(residual.Rv, Inf) < tol fd_met_tol && return (fd_met_tol, 0) - J(time_step) # refresh Jacobian VALUES at current FD state + J(data, time_step) # refresh Jacobian VALUES at current FD state if handoff_solver === LevenbergMarquardtACPowerFlow # LM's inner method takes the raw state vector + an LMWorkspace (a different signature # from NR/TR) and mutates x0 in place; see src/levenberg-marquardt.jl. ws = LMWorkspace(J.Jv; marquardt_scaling = _default_marquardt_scaling(typeof(pf))) converged, i2 = _run_power_flow_method( - time_step, sv.x, residual, J, ws; + time_step, sv.x, residual, J, data, ws; tol, maxIterations = DEFAULT_NR_MAX_ITER, λ_0 = DEFAULT_λ_0, ) else @@ -351,7 +353,7 @@ function _fd_maybe_handoff!( hcache = make_linear_solver_cache(backend, J.Jv) symbolic_factor!(hcache, J.Jv) converged, i2 = _run_power_flow_method( - time_step, sv, hcache, residual, J, handoff_solver; + time_step, sv, hcache, residual, J, data, handoff_solver; tol, maxIterations = DEFAULT_NR_MAX_ITER, ) end @@ -489,7 +491,7 @@ function _fd_fixed_jacobian_power_flow( if !converged # Ensure J holds VALUES at x0. Polar's setup already calls `J(time_step)`; rect/mixed # constructors do not, so call it here unconditionally (cheap, once). - J(time_step) + J(data, time_step) backend = resolve_linear_solver_backend(linear_solver) # Reuse the frozen Jacobian's symbolic factorization when its pattern is unchanged; see # `FDFixedJacobianCache`. @@ -502,7 +504,7 @@ function _fd_fixed_jacobian_power_flow( v_state_idx = _fd_v_state_indices(residual) vm_view = view(data.bus_magnitude, :, time_step) - residual(sv.x, time_step) + residual(data, sv.x, time_step) ss = dot(residual.Rv, residual.Rv) sg = FDSafeguardState(sv.x, ss) converged = norm(residual.Rv, Inf) < stage_tol @@ -531,7 +533,7 @@ function _fd_fixed_jacobian_power_flow( # apply step, evaluate exact residual (syncs data: V/θ/P/Q) sv.x .+= sv.Δx_nr - residual(sv.x, time_step) + residual(data, sv.x, time_step) ss = dot(residual.Rv, residual.Rv) # V≈0 abort. @@ -541,7 +543,7 @@ function _fd_fixed_jacobian_power_flow( "$(fd_vm_abort); aborting FD stage." ) # restore best state before bailing out - _fd_restore_best!(sv, residual, sg, time_step) + _fd_restore_best!(sv, residual, sg, data, time_step) ss = sg.best_ss break end @@ -556,7 +558,7 @@ function _fd_fixed_jacobian_power_flow( factor *= 0.5 copyto!(sv.x, sg.cycle_x) @inbounds @. sv.x += factor * sv.Δx_nr - residual(sv.x, time_step) + residual(data, sv.x, time_step) ss = dot(residual.Rv, residual.Rv) _fd_update_best!(sg, sv.x, ss) if ss < fd_ndvfct * sg.prev_ss && @@ -571,8 +573,8 @@ function _fd_fixed_jacobian_power_flow( if refreeze_on_stall && !refrozen refrozen = true # refresh J at the best state, refactor in place, continue - _fd_restore_best!(sv, residual, sg, time_step) - J(time_step) + _fd_restore_best!(sv, residual, sg, data, time_step) + J(data, time_step) numeric_refactor!(cache, J.Jv) ss = sg.best_ss _fd_reset_safeguard!(sg, sv.x, ss) @@ -580,7 +582,7 @@ function _fd_fixed_jacobian_power_flow( i += 1 continue else - _fd_restore_best!(sv, residual, sg, time_step) + _fd_restore_best!(sv, residual, sg, data, time_step) ss = sg.best_ss @warn( "$solver_name: non-divergent backtracking exhausted; " * @@ -605,7 +607,7 @@ function _fd_fixed_jacobian_power_flow( if !fd_non_divergent && refreeze_on_stall && !refrozen && ss >= fd_ndvfct * sg.prev_ss refrozen = true - J(time_step) + J(data, time_step) numeric_refactor!(cache, J.Jv) _fd_reset_safeguard!(sg, sv.x, ss) end @@ -615,7 +617,7 @@ function _fd_fixed_jacobian_power_flow( # handoff is disabled or the FD state already met `tol`. Threads handoff iters into # the reported count so finalize happens ONCE, on the refined state. converged, i2 = _fd_maybe_handoff!( - handoff_solver, pf, sv, residual, J, time_step, tol, linear_solver, + handoff_solver, pf, sv, residual, J, data, time_step, tol, linear_solver, solver_name, i, ) @@ -627,7 +629,7 @@ function _fd_fixed_jacobian_power_flow( # Refresh J at the SOLUTION only when loss/voltage-stability factors are requested (they read # J.Jv in _finalize_power_flow); the FD loop never otherwise touches J, so skip the eval. if get_calculate_loss_factors(data) || get_calculate_voltage_stability_factors(data) - J(time_step) + J(data, time_step) end _finalize_formulation!(pf, data, x_final, residual, time_step) result = _finalize_power_flow( @@ -643,10 +645,11 @@ function _fd_restore_best!( sv::StateVectorCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, sg::FDSafeguardState, + data::ACPowerFlowData, time_step::Int64, ) copyto!(sv.x, sg.best_x) - residual(sv.x, time_step) + residual(data, sv.x, time_step) return end @@ -907,7 +910,7 @@ function _get_pq_data!( end """ - _sync_explicit_state!(sv, residual, time_step) + _sync_explicit_state!(sv, residual, data, time_step) Set the REF/PV "explicit" state entries (subnetwork slack P, REF Q, PV Q) to the values that zero their own residual rows given the current (V, θ). Per subnetwork (`residual.subnetworks` @@ -921,14 +924,15 @@ this to refresh `data`/`Rv`. function _sync_explicit_state!( sv::StateVectorCache, residual::ACPowerFlowResidual, + data::ACPowerFlowData, time_step::Int64, ) x = sv.x Rv = residual.Rv - bus_types = view(residual.data.bus_type, :, time_step) + bus_types = view(data.bus_type, :, time_step) sign = FD_EXPLICIT_SYNC_SIGN independent_ref = - _multi_swing_ref_indices(residual.data.bus_type, residual.subnetworks, time_step) + _multi_swing_ref_indices(data.bus_type, residual.subnetworks, time_step) for (ref_bus, subnet) in residual.subnetworks if ref_bus in independent_ref # Multi-swing island: each swing carries its own slack, so it closes its OWN P and Q @@ -978,8 +982,8 @@ function _fd_lcc_substep!( ) _fd_converter_substep!(data, time_step) _write_lcc_state_to_x!(sv.x, data, time_step) - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) return end @@ -1005,8 +1009,8 @@ function _fd_vsc_substep!( # `_read_vsc_state!` reads from) — front-anchored so a trailing area tail can't shift it. vsc_off = 2 * size(data.bus_type, 1) + 4 * size(data.lcc.p_set, 1) _write_vsc_state_to_x!(sv.x, dcn, vsc_off, time_step) - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) return end @@ -1152,8 +1156,8 @@ function _fd_area_substep!( sv.x[area_off + area.tail_ix] -= dp[area.tail_ix] end - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) return end @@ -1299,8 +1303,8 @@ function _fd_decoupled_power_flow( has_area = !iszero(n_controlled_areas(data)) # Sync explicit rows, then evaluate the residual so Rv / data reflect (V, θ, explicit P/Q). - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) # Make the converter state consistent with the start voltages so the LCC/VSC tail residuals # enter the loop already small (they are refreshed each cycle after the Q half-step). n_lcc > 0 && _fd_lcc_substep!(sv, residual, data, time_step) @@ -1338,8 +1342,8 @@ function _fd_decoupled_power_flow( end end if !diverged - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) end # --- Q half-step (i.5): rq = Rv_Q / Vm over pq; solve B″·ΔV = rq; DVLIM; V -= ΔV. @@ -1368,8 +1372,8 @@ function _fd_decoupled_power_flow( @inbounds for k in eachindex(v_x_idx) sv.x[v_x_idx[k]] += rq[k] end - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) end end @@ -1394,13 +1398,13 @@ function _fd_decoupled_power_flow( "$solver_name: a bus voltage magnitude was driven below $(fd_vm_abort); " * "aborting FD stage." ) - _fd_restore_best!(sv, residual, sg, time_step) + _fd_restore_best!(sv, residual, sg, data, time_step) ss = sg.best_ss break end if diverged # only reachable with fd_non_divergent = false - _fd_restore_best!(sv, residual, sg, time_step) + _fd_restore_best!(sv, residual, sg, data, time_step) ss = sg.best_ss break end @@ -1418,8 +1422,8 @@ function _fd_decoupled_power_flow( for _ in 1:fd_max_step_halvings factor *= 0.5 @inbounds @. sv.x = sg.cycle_x + factor * Δcycle - _sync_explicit_state!(sv, residual, time_step) - residual(sv.x, time_step) + _sync_explicit_state!(sv, residual, data, time_step) + residual(data, sv.x, time_step) # Re-solve the LCC/VSC converters at the rescaled voltages so the tail rows stay # zeroed during backtracking — otherwise the halved step un-solves the converter # state and the tail mismatch dominates `ss`, defeating the line search. @@ -1440,7 +1444,7 @@ function _fd_decoupled_power_flow( end end if !accepted - _fd_restore_best!(sv, residual, sg, time_step) + _fd_restore_best!(sv, residual, sg, data, time_step) ss = sg.best_ss @warn( "$solver_name: non-divergent backtracking exhausted; restoring best " * @@ -1464,7 +1468,8 @@ function _fd_decoupled_power_flow( # is disabled or the FD state already met `tol`. Threads handoff iters into the reported # count so finalize happens ONCE, on the refined state. converged, handoff_iters = _fd_maybe_handoff!( - handoff_solver, pf, sv, residual, J, time_step, tol, linear_solver, solver_name, + handoff_solver, pf, sv, residual, J, data, time_step, tol, linear_solver, + solver_name, i, ) i += handoff_iters @@ -1472,7 +1477,7 @@ function _fd_decoupled_power_flow( # Refresh J at the SOLUTION only when loss/voltage-stability factors are requested (they read # J.Jv in _finalize_power_flow); the FD loop never otherwise touches J, so skip the eval. if get_calculate_loss_factors(data) || get_calculate_voltage_stability_factors(data) - J(time_step) + J(data, time_step) end _finalize_formulation!(pf, data, sv.x, residual, time_step) result = _finalize_power_flow( diff --git a/src/gradient_descent_ac_power_flow.jl b/src/gradient_descent_ac_power_flow.jl index a385c612..1104a3c3 100644 --- a/src/gradient_descent_ac_power_flow.jl +++ b/src/gradient_descent_ac_power_flow.jl @@ -149,13 +149,13 @@ function _newton_power_flow( adam_step!(x0, state, cfg) # 4. Backtracking line search on ½‖F‖² - residual(x0, time_step) + residual(data, x0, time_step) new_loss = dot(residual.Rv, residual.Rv) for _ in 1:ADAM_MAX_BACKTRACKS new_loss <= old_loss && break _interpolate_x!(x0, x_save, ADAM_BACKTRACK_FACTOR) - residual(x0, time_step) + residual(data, x0, time_step) new_loss = dot(residual.Rv, residual.Rv) end @@ -164,7 +164,7 @@ function _newton_power_flow( if !converged i += 1 # Re-evaluate Jacobian for next iteration - J(time_step) + J(data, time_step) end end end @@ -172,7 +172,7 @@ function _newton_power_flow( # Recompute Jacobian at the solution for post-processing (loss/stability factors) if converged && (get_calculate_loss_factors(data) || get_calculate_voltage_stability_factors(data)) - J(time_step) + J(data, time_step) end return _finalize_power_flow( diff --git a/src/levenberg-marquardt.jl b/src/levenberg-marquardt.jl index 8b93457e..5c15f61d 100644 --- a/src/levenberg-marquardt.jl +++ b/src/levenberg-marquardt.jl @@ -187,6 +187,7 @@ function _newton_power_flow( x0, residual, J, + data, ws; tol, maxIterations, λ_0, stop_at_fold, ) @@ -204,6 +205,7 @@ function _run_power_flow_method( residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, ws::LMWorkspace; maxIterations::Int = DEFAULT_NR_MAX_ITER, tol::Float64 = DEFAULT_NR_TOL, @@ -214,11 +216,11 @@ function _run_power_flow_method( μ::Float64 = λ_0 λ::Float64 = 0.0 i, converged = 0, false - residual(x, time_step) + residual(data, x, time_step) resSize = dot(residual.Rv, residual.Rv) linf = norm(residual.Rv, Inf) @debug "initially: sum of squares $(siground(resSize)), L ∞ norm $(siground(linf)), λ = $λ" - monitor, diag_state = setup_solver_diagnostics(J, stop_at_fold) + monitor, diag_state = setup_solver_diagnostics(J, data, stop_at_fold) # LM factorizes JᵀJ + λ·D² (or, on the rare QR fallback, the augmented # [J; √λ·D]), not J itself, so the diagnostic keeps its own KLU factor of J # (symbolic once here, refreshed each iteration by the hook). @@ -231,13 +233,13 @@ function _run_power_flow_method( step_accepted = false while i < maxIterations && !converged && isfinite(λ) && μ < DEFAULT_μ_MAX λ, μ, step_accepted = - update_damping_factor!(x, residual, J, μ, time_step, ws, step_accepted) + update_damping_factor!(x, residual, J, data, μ, time_step, ws, step_accepted) if !isnothing(diag_state) # One-iterate lag: update_damping_factor! evaluated J at the pre-step # iterate but residual.Rv is already post-step, so κ̂/λ_min describe the # linearization J while the reported ‖F‖∞ is after the step. Not realigned. run_solver_diagnostics!( - diag_state, "LM iter $i", residual, J, time_step, + diag_state, "LM iter $i", residual, J, data, time_step, diag_cache, monitor, stop_at_fold) && return false, i end @@ -268,6 +270,7 @@ function compute_error( residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, λ::Float64, time_step::Int, residualSize::Float64, @@ -281,7 +284,7 @@ function compute_error( ws.temp_x .+= residual.Rv ws.x_trial .= x .+ Δx - residual(ws.x_trial, time_step) # M(x_c + Δx) + residual(data, ws.x_trial, time_step) # M(x_c + Δx) newResidualSize = dot(residual.Rv, residual.Rv) predicted_reduction = residualSize - dot(ws.temp_x, ws.temp_x) @@ -289,7 +292,7 @@ function compute_error( # Guard against zero/negative predicted reduction. if predicted_reduction <= 0.0 || !isfinite(predicted_reduction) - residual(x, time_step) + residual(data, x, time_step) return (0.0, false) end @@ -300,7 +303,7 @@ function compute_error( return (ρ, true) else # Bad step: restore data state to match x (not x_trial). - residual(x, time_step) + residual(data, x, time_step) return (ρ, false) end end @@ -310,6 +313,7 @@ function update_damping_factor!( residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, μ::Float64, time_step::Int, ws::LMWorkspace, @@ -319,10 +323,10 @@ function update_damping_factor!( # pre-loop init) leaves it evaluated at the held x. residualSize = dot(residual.Rv, residual.Rv) # J is current unless the previous step moved x; refresh only then. - previous_step_accepted && J(time_step) + previous_step_accepted && J(data, time_step) λ = μ * sqrt(residualSize) - ρ, accepted = compute_error(x, residual, J, λ, time_step, residualSize, ws) + ρ, accepted = compute_error(x, residual, J, data, λ, time_step, residualSize, ws) coef = 4.0 if ρ > 0.75 μ = max(μ / coef, 1e-8) diff --git a/src/mixed_cpb_power_flow_jacobian.jl b/src/mixed_cpb_power_flow_jacobian.jl index 160d1846..f84120d1 100644 --- a/src/mixed_cpb_power_flow_jacobian.jl +++ b/src/mixed_cpb_power_flow_jacobian.jl @@ -10,8 +10,7 @@ MCPB and rewritten each iteration. PQ off-diagonals are constant `±Y` `nonzeros(Jv)` through nzval-index caches built once at construction, so the hot path is `O(N + n_LCC)`. Field roles are in the inline comments below. """ -struct ACMixedCPBJacobian{D <: ACPowerFlowData} - data::D +struct ACMixedCPBJacobian Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} Y_diag::Vector{ComplexF64} # cached Y_bus_eff diagonal; avoids O(log nnz) sparse access per iteration @@ -43,11 +42,12 @@ struct ACMixedCPBJacobian{D <: ACPowerFlowData} end function ACMixedCPBJacobian( + data::ACPowerFlowData, residual::ACMixedCPBResidual, time_step::Int64, ) Jv0 = _create_mixed_cpb_jacobian_structure( - residual.data, + data, residual.Y_bus_eff, residual.bus_slack_participation_factors, residual.subnetworks, @@ -63,9 +63,9 @@ function ACMixedCPBJacobian( Jv0, residual.Y_bus_eff, residual.bus_state_offset, - view(residual.data.bus_type, :, time_step), + view(data.bus_type, :, time_step), ) - n_buses = first(size(residual.data.bus_type)) + n_buses = first(size(data.bus_type)) Y_diag = Vector{ComplexF64}(undef, n_buses) @inbounds for i in 1:n_buses Y_diag[i] = residual.Y_bus_eff[i, i] @@ -76,7 +76,7 @@ function ACMixedCPBJacobian( offdiag_pv_nz, offdiag_pv_i, offdiag_pv_k, offdiag_pv_y = _build_offdiag_pv_nz_cache( Jv0, residual.Y_bus_eff, residual.bus_state_offset, - view(residual.data.bus_type, :, time_step), + view(data.bus_type, :, time_step), ) # REF status is fixed for the life of a solve; reuse the residual's # already-computed set instead of reallocating it here. @@ -85,17 +85,16 @@ function ACMixedCPBJacobian( Jv0, residual.bus_state_offset, residual.subnetworks, residual.bus_slack_participation_factors, residual.independent_ref, ) - n_lccs = size(residual.data.lcc.p_set, 1) + n_lccs = size(data.lcc.p_set, 1) lcc_nz = _build_lcc_nz_cache( - Jv0, residual.data, residual.bus_state_offset, + Jv0, data, residual.bus_state_offset, residual.total_bus_state, n_lccs, ) vsc_nz = _build_vsc_nz_cache( - Jv0, get_dc_network(residual.data), residual.bus_state_offset, + Jv0, get_dc_network(data), residual.bus_state_offset, residual.total_bus_state, n_lccs, ) J = ACMixedCPBJacobian( - residual.data, Jv0, residual.Y_bus_eff, Y_diag, @@ -124,12 +123,12 @@ function ACMixedCPBJacobian( lcc_nz, vsc_nz, ) - J(time_step) # populate state-dependent entries (diagonals, PV off-diag, slack, LCC tail) + J(data, time_step) # populate state-dependent entries (diagonals, PV off-diag, slack, LCC tail) return J end -function (J::ACMixedCPBJacobian)(time_step::Int64) - _update_mixed_cpb_jacobian_values!(J.Jv, J.data, J.Y_diag, +function (J::ACMixedCPBJacobian)(data::ACPowerFlowData, time_step::Int64) + _update_mixed_cpb_jacobian_values!(J.Jv, data, J.Y_diag, J.e_state, J.f_state, J.P_eff_cache, J.Q_eff_cache, J.const_I_P, J.const_I_Q, J.Ir_acc, J.Ii_acc, J.bus_slack_participation_factors, J.independent_ref, @@ -141,10 +140,11 @@ function (J::ACMixedCPBJacobian)(time_step::Int64) end function (J::ACMixedCPBJacobian)( + data::ACPowerFlowData, Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, time_step::Int64, ) - _update_mixed_cpb_jacobian_values!(J.Jv, J.data, J.Y_diag, + _update_mixed_cpb_jacobian_values!(J.Jv, data, J.Y_diag, J.e_state, J.f_state, J.P_eff_cache, J.Q_eff_cache, J.const_I_P, J.const_I_Q, J.Ir_acc, J.Ii_acc, J.bus_slack_participation_factors, J.independent_ref, diff --git a/src/mixed_cpb_power_flow_residual.jl b/src/mixed_cpb_power_flow_residual.jl index 3860751f..f971df98 100644 --- a/src/mixed_cpb_power_flow_residual.jl +++ b/src/mixed_cpb_power_flow_residual.jl @@ -15,8 +15,7 @@ never allocates a `Set`; `validate_offsets` are the precomputed PQ/PV `x`- offsets for the voltage-magnitude diagnostic. Remaining fields are named after their roles. """ -struct ACMixedCPBResidual{D <: ACPowerFlowData} - data::D +struct ACMixedCPBResidual Rv::Vector{Float64} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} P_net_const::Vector{Float64} @@ -94,7 +93,6 @@ function ACMixedCPBResidual(data::ACPowerFlowData, time_step::Int64) fold_zip_constant_z!(Y_bus_eff, data, time_step) return ACMixedCPBResidual( - data, Vector{Float64}(undef, total_state), Y_bus_eff, P_net_const, @@ -119,6 +117,7 @@ function ACMixedCPBResidual(data::ACPowerFlowData, time_step::Int64) end function (R::ACMixedCPBResidual)( + data::ACPowerFlowData, Rv::Vector{Float64}, x::Vector{Float64}, time_step::Int64, @@ -128,18 +127,22 @@ function (R::ACMixedCPBResidual)( R.bus_slack_participation_factors, R.subnetworks, R.independent_ref, R.bus_state_offset, R.bus_block_size, R.total_bus_state, R.e_state, R.f_state, R.P_eff_cache, R.Q_eff_cache, - R.data, time_step, R.Ir_acc, R.Ii_acc) + data, time_step, R.Ir_acc, R.Ii_acc) copyto!(Rv, R.Rv) return end -function (R::ACMixedCPBResidual)(x::Vector{Float64}, time_step::Int64) +function (R::ACMixedCPBResidual)( + data::ACPowerFlowData, + x::Vector{Float64}, + time_step::Int64, +) _update_mixed_cpb_residual_values!(R.Rv, x, R.Y_bus_eff, R.P_net_const, R.Q_net_const, R.const_I_P, R.const_I_Q, R.P_net_set, R.bus_slack_participation_factors, R.subnetworks, R.independent_ref, R.bus_state_offset, R.bus_block_size, R.total_bus_state, R.e_state, R.f_state, R.P_eff_cache, R.Q_eff_cache, - R.data, time_step, R.Ir_acc, R.Ii_acc) + data, time_step, R.Ir_acc, R.Ii_acc) return end diff --git a/src/power_flow_method.jl b/src/power_flow_method.jl index e52bbfa8..f5cadf0a 100644 --- a/src/power_flow_method.jl +++ b/src/power_flow_method.jl @@ -58,10 +58,14 @@ is the linear-solver backend tag (its `typeof` keys reuse). `linSolveCache` is t concrete backend cache narrows without a runtime dispatch (see `_newton_workspace!`). `bus_type_snapshot` is the bus-type column the last structural rebuild (subnetworks, slack participation, PQ index list) was keyed on, so `_refresh_polar_residual!` can skip that rebuild -when bus types have not moved since.""" -struct PolarNRCache{C <: PFLinearSolverCache, D <: ACPowerFlowData} <: AbstractNRCache - residual::ACPowerFlowResidual{D} - J::ACPowerFlowJacobian{D} +when bus types have not moved since. + +Neither `residual` nor `J` stores `data`: this cache is itself reached through +`data.polar_nr_cache`, so `data` is threaded explicitly through every call instead of being +held by the residual/Jacobian, which would close a reference cycle back to `data`.""" +struct PolarNRCache{C <: PFLinearSolverCache} <: AbstractNRCache + residual::ACPowerFlowResidual + J::ACPowerFlowJacobian linSolveCache::C stateVector::StateVectorCache backend::PNM.LinearSolverType @@ -86,9 +90,10 @@ from the cached residual: the subnetwork partition, the set of slack-participati changes the Jacobian sparsity pattern), or the REF-bus set. Returns `true` when only values changed (the common case: per-step injection changes; PV→PQ Q-limit flips under single-REF slack, where flipped PV buses carry zero participation and so never alter the pattern).""" -function _refresh_polar_residual!(entry::PolarNRCache, time_step::Int64) +function _refresh_polar_residual!( + entry::PolarNRCache, data::ACPowerFlowData, time_step::Int64, +) residual = entry.residual - data = residual.data bus_type = view(data.bus_type, :, time_step) if bus_type == entry.bus_type_snapshot @@ -164,12 +169,13 @@ end `J.Jv` might be singular.""" function _set_Δx_nr!(stateVector::StateVectorCache, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, linSolveCache::PFLinearSolverCache, solver::ACPowerFlowSolverType, refinement_threshold::Float64, refinement_eps::Float64) use_fallback = false - _count_numeric_refactor!(J.data) + _count_numeric_refactor!(data) try numeric_refactor!(linSolveCache, J.Jv) catch e @@ -327,7 +333,7 @@ function _dogleg!(Δx_proposed::Vector{Float64}, end """Accept a trust region step: update cached residual and autoscale vector `d`. -The caller is responsible for recomputing the Jacobian via `J(time_step)` before +The caller is responsible for recomputing the Jacobian via `J(data, time_step)` before calling this, so that `Jv` reflects the new state.""" function _accept_trust_region_step!( stateVector::StateVectorCache, @@ -353,6 +359,7 @@ function _iwamoto_fallback!( stateVector::StateVectorCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, old_residual::Vector{Float64}, old_residual_norm::Float64, autoscale::Bool, @@ -365,12 +372,12 @@ function _iwamoto_fallback!( μ = _iwamoto_multiplier(2.0 * c_fb, c_bb + 2.0 * c_fa, 2.0 * c_ba, c_aa) # Revert full step, apply damped step in a single fused pass. @. stateVector.x += (μ - 1.0) * stateVector.Δx_proposed - residual(stateVector.x, time_step) + residual(data, stateVector.x, time_step) g_damped = dot(residual.Rv, residual.Rv) if g_damped < g0 @debug "Iwamoto fallback accepted: μ = $(siground(μ)), " * "g_damped/g₀ = $(siground(g_damped / g0))" - J(time_step) + J(data, time_step) _accept_trust_region_step!(stateVector, residual, J.Jv, autoscale) return true else @@ -392,6 +399,7 @@ function _trust_region_step(time_step::Int, linSolveCache::PFLinearSolverCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, delta::Float64, delta_max::Float64, eta::Float64, @@ -402,6 +410,7 @@ function _trust_region_step(time_step::Int, _set_Δx_nr!( stateVector, J, + data, linSolveCache, TrustRegionACPowerFlow(), DEFAULT_REFINEMENT_THRESHOLD, @@ -425,7 +434,7 @@ function _trust_region_step(time_step::Int, oldResidual = stateVector.Δx_nr copyto!(oldResidual, residual.Rv) old_residual_norm = sum(abs2, stateVector.r) - residual(stateVector.x, time_step) + residual(data, stateVector.x, time_step) new_residual_norm = sum(abs2, residual.Rv) # Ratio of actual to predicted reduction @@ -449,14 +458,14 @@ function _trust_region_step(time_step::Int, if rho > eta # Successful iteration @debug "Step accepted: sum of squares $(siground(dot(residual.Rv, residual.Rv))), L ∞ norm $(siground(norm(residual.Rv, Inf))), Δ = $(siground(delta)), ||Δx|| = $(siground(norm(stateVector.Δx_proposed)))" - J(time_step) + J(data, time_step) _accept_trust_region_step!(stateVector, residual, J.Jv, autoscale) step_accepted = true else # Unsuccessful step — try Iwamoto damping before reverting. if iwamoto_fallback iwamoto_accepted = _iwamoto_fallback!( - time_step, stateVector, residual, J, + time_step, stateVector, residual, J, data, oldResidual, old_residual_norm, autoscale) if iwamoto_accepted # Iwamoto accepted a damped step — shrink trust region since the @@ -634,6 +643,7 @@ function _simple_step(time_step::Int, linSolveCache::PFLinearSolverCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, refinement_threshold::Float64 = DEFAULT_REFINEMENT_THRESHOLD, refinement_eps::Float64 = DEFAULT_REFINEMENT_EPS, ) @@ -641,6 +651,7 @@ function _simple_step(time_step::Int, _set_Δx_nr!( stateVector, J, + data, linSolveCache, NewtonRaphsonACPowerFlow(), refinement_threshold, @@ -650,9 +661,9 @@ function _simple_step(time_step::Int, stateVector.x .+= stateVector.Δx_nr # update data's fields (the bus angles/voltages) to match x, and update the residual. # do this BEFORE updating the Jacobian. The Jacobian computation uses data's fields, not x. - residual(stateVector.x, time_step) + residual(data, stateVector.x, time_step) # update jacobian. - J(time_step) + J(data, time_step) return end @@ -670,6 +681,7 @@ function _iwamoto_step(time_step::Int, linSolveCache::PFLinearSolverCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, refinement_threshold::Float64 = DEFAULT_REFINEMENT_THRESHOLD, refinement_eps::Float64 = DEFAULT_REFINEMENT_EPS, )::Bool @@ -679,6 +691,7 @@ function _iwamoto_step(time_step::Int, _set_Δx_nr!( stateVector, J, + data, linSolveCache, NewtonRaphsonACPowerFlow(), refinement_threshold, @@ -687,7 +700,7 @@ function _iwamoto_step(time_step::Int, # Take full trial step: x += Δx_nr stateVector.x .+= stateVector.Δx_nr # Evaluate trial residual b = F(x + Δx) - residual(stateVector.x, time_step) + residual(data, stateVector.x, time_step) # Compute gram scalars for Iwamoto criterion g0 = dot(stateVector.r, stateVector.r) @@ -697,7 +710,7 @@ function _iwamoto_step(time_step::Int, if g2 < g0 # Full step reduced residual — accept it (μ = 1). @debug "Iwamoto: full step accepted (g₂/g₀ = $(g2/g0))" - J(time_step) + J(data, time_step) return true end @@ -708,7 +721,7 @@ function _iwamoto_step(time_step::Int, stateVector.x .-= stateVector.Δx_nr stateVector.x .+= μ .* stateVector.Δx_nr # Re-evaluate residual at damped point. - residual(stateVector.x, time_step) + residual(data, stateVector.x, time_step) # Check whether the damped step actually improved the residual. g_damped = dot(residual.Rv, residual.Rv) if g_damped >= g0 @@ -716,11 +729,11 @@ function _iwamoto_step(time_step::Int, @debug "Iwamoto: damped step did not reduce residual " * "(g_damped/g₀ = $(g_damped/g0), μ = $μ); reverting" stateVector.x .-= μ .* stateVector.Δx_nr - residual(stateVector.x, time_step) + residual(data, stateVector.x, time_step) return false end # Damped step improved — accept it. - J(time_step) + J(data, time_step) return true end @@ -775,6 +788,7 @@ function _run_power_flow_method(time_step::Int, linSolveCache::PFLinearSolverCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, ::Type{NewtonRaphsonACPowerFlow}; maxIterations::Int = DEFAULT_NR_MAX_ITER, tol::Float64 = DEFAULT_NR_TOL, @@ -789,7 +803,7 @@ function _run_power_flow_method(time_step::Int, validate_vms = validate_voltage_magnitudes i, converged = 1, false consecutive_reverts = 0 - monitor, diag_state = setup_solver_diagnostics(J, stop_at_fold) + monitor, diag_state = setup_solver_diagnostics(J, data, stop_at_fold) while i < maxIterations && !converged if iwamoto made_progress = _iwamoto_step( @@ -798,6 +812,7 @@ function _run_power_flow_method(time_step::Int, linSolveCache, residual, J, + data, refinement_threshold, refinement_eps, ) @@ -817,6 +832,7 @@ function _run_power_flow_method(time_step::Int, linSolveCache, residual, J, + data, refinement_threshold, refinement_eps, ) @@ -831,7 +847,7 @@ function _run_power_flow_method(time_step::Int, # After `_simple_step`, J.Jv and residual.Rv are at the same iterate, so # one refactor feeds both the log line and the bail-out. run_solver_diagnostics!( - diag_state, "NR iter $i", residual, J, time_step, + diag_state, "NR iter $i", residual, J, data, time_step, linSolveCache, monitor, stop_at_fold) && return false, i end @@ -860,6 +876,7 @@ function _run_power_flow_method(time_step::Int, linSolveCache::PFLinearSolverCache, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, ::Type{TrustRegionACPowerFlow}; maxIterations::Int = DEFAULT_NR_MAX_ITER, tol::Float64 = DEFAULT_NR_TOL, @@ -894,7 +911,7 @@ function _run_power_flow_method(time_step::Int, linf = norm(residual.Rv, Inf) @debug "initially: sum of squares $(siground(residualSize)), L ∞ norm $(siground(linf)), Δ $(siground(delta))" - monitor, diag_state = setup_solver_diagnostics(J, stop_at_fold) + monitor, diag_state = setup_solver_diagnostics(J, data, stop_at_fold) while i < maxIterations && !converged delta = _trust_region_step( time_step, @@ -902,6 +919,7 @@ function _run_power_flow_method(time_step::Int, linSolveCache, residual, J, + data, delta, delta_max, eta, @@ -918,7 +936,7 @@ function _run_power_flow_method(time_step::Int, # After `_trust_region_step` (incl. reject and iwamoto-fallback), J.Jv and # residual.Rv are at the same iterate, so one refactor feeds both. run_solver_diagnostics!( - diag_state, "TR iter $i", residual, J, time_step, + diag_state, "TR iter $i", residual, J, data, time_step, linSolveCache, monitor, stop_at_fold) && return false, i end @@ -1092,7 +1110,7 @@ function _nr_initialize_with_jacobian_deferred( end # Rectangular/mixed: J is structure-only (no value evaluation), cheap enough to build eagerly. -# These formulations do not call J(time_step) in their setup, so the cost is just the +# These formulations do not call J(data, time_step) in their setup, so the cost is just the # sparse-structure allocation (~1-2 MB), not the full evaluation. function _nr_initialize_with_jacobian_deferred( pf::ACRectangularPowerFlow{T}, @@ -1109,10 +1127,10 @@ function _nr_initialize_with_jacobian_deferred( else x0_computed = copy(x0) @warn "Using caller-provided x0; skipping improve_x0." - residual(x0_computed, time_step) + residual(data, x0_computed, time_step) end _log_initial_residual(residual) - J = ACRectangularCIJacobian(residual, time_step) + J = ACRectangularCIJacobian(data, residual, time_step) return residual, J, x0_computed end @@ -1131,23 +1149,30 @@ function _nr_initialize_with_jacobian_deferred( else x0_computed = copy(x0) @warn "Using caller-provided x0; skipping improve_x0." - residual(x0_computed, time_step) + residual(data, x0_computed, time_step) end _log_initial_residual(residual) - J = ACMixedCPBJacobian(residual, time_step) + J = ACMixedCPBJacobian(data, residual, time_step) return residual, J, x0_computed end # Build the Jacobian when the deferred path (polar) needs it after a failed convergence check. # Rectangular/mixed already have J from setup. function _nr_build_jacobian( - ::ACPolarPowerFlow, residual::ACPowerFlowResidual, ::Nothing, time_step::Int64, + ::ACPolarPowerFlow, data::ACPowerFlowData, residual::ACPowerFlowResidual, ::Nothing, + time_step::Int64, ) - J = ACPowerFlowJacobian(residual, time_step) - J(time_step) + J = ACPowerFlowJacobian(data, residual, time_step) + J(data, time_step) return J end -_nr_build_jacobian(::AbstractACPowerFlow, residual, J, time_step::Int64) = J +_nr_build_jacobian( + ::AbstractACPowerFlow, + ::ACPowerFlowData, + residual, + J, + time_step::Int64, +) = J """Shared fresh-build body for `_newton_workspace!`: initialize the residual (deferring the Jacobian per `_nr_initialize_with_jacobian_deferred`), return early on a 0-iteration warm start, @@ -1166,7 +1191,7 @@ function _fresh_newton_workspace( _nr_initialize_with_jacobian_deferred(pf, data, time_step; init_kwargs...) converged = norm(residual.Rv, Inf) < tol converged && return residual, J_deferred, x0_init, nothing, nothing, true - J = _nr_build_jacobian(pf, residual, J_deferred, time_step) + J = _nr_build_jacobian(pf, data, residual, J_deferred, time_step) linSolveCache = make_linear_solver_cache(backend, J.Jv) symbolic_factor!(linSolveCache, J.Jv) _count_symbolic_factor!(data) @@ -1276,7 +1301,7 @@ function _newton_workspace!( _nr_initialize_with_jacobian_deferred(pf, data, time_step; init_kwargs...) converged = norm(residual.Rv, Inf) < tol converged && return residual, J_deferred, x0_init, nothing, nothing, true - J = _nr_build_jacobian(pf, residual, J_deferred, time_step) + J = _nr_build_jacobian(pf, data, residual, J_deferred, time_step) linSolveCache, stateVector = _get_or_build_rect_mixed_cache!( data.solver_cache[], data, backend, J.Jv, x0_init, residual.Rv) return residual, J, x0_init, linSolveCache, stateVector, false @@ -1333,7 +1358,7 @@ function _polar_newton_workspace!( can_reuse = typeof(entry.backend) === typeof(backend) && !haskey(init_kwargs, :x0) && - _refresh_polar_residual!(entry, time_step) + _refresh_polar_residual!(entry, data, time_step) can_reuse || return _polar_newton_workspace!( nothing, @@ -1364,7 +1389,7 @@ function _polar_newton_workspace!( # Defer the Jacobian fill past the convergence check: a 0-iteration warm start must not # pay for it. `nothing` lets the caller rebuild only if it actually needs J. converged && return residual, nothing, x0_init, nothing, nothing, true - J(time_step) + J(data, time_step) # Reuse the linear-solver cache (symbolic factorization holds: pattern is bus-type-agnostic) # and the state-vector buffers; refresh only the per-solve values. linSolveCache = entry.linSolveCache @@ -1431,6 +1456,7 @@ function _newton_power_flow( linSolveCache, residual, J, + data, T; tol, maxIterations, @@ -1455,7 +1481,7 @@ function _newton_power_flow( # opted into loss / voltage-stability factors — those need J even at 0 iterations, or a # first solve that lands within tol would leave them at their zero-initialized values. if get_calculate_loss_factors(data) || get_calculate_voltage_stability_factors(data) - J = _nr_build_jacobian(pf, residual, J_or_nothing, time_step) + J = _nr_build_jacobian(pf, data, residual, J_or_nothing, time_step) return _finalize_power_flow( converged, i, string(T), residual, data, J.Jv, time_step) end diff --git a/src/power_flow_setup.jl b/src/power_flow_setup.jl index 1e225dc1..697b4954 100644 --- a/src/power_flow_setup.jl +++ b/src/power_flow_setup.jl @@ -9,16 +9,16 @@ function improve_x0(pf::ACPolarPowerFlow, time_step::Int64, ) x0 = calculate_x0(data, time_step) - residual(x0, time_step) + residual(data, x0, time_step) prev = findlast(@view(data.converged[1:(time_step - 1)])) if !isnothing(prev) newx0 = _previous_solution_start(x0, data, prev) - _pick_better_x0(x0, newx0, time_step, residual, "previous converged solution") + _pick_better_x0(x0, newx0, time_step, residual, data, "previous converged solution") end if norm(residual.Rv, 1) > LARGE_RESIDUAL * length(residual.Rv) && get_enhanced_flat_start(pf) newx0 = _enhanced_flat_start(x0, data, time_step) - _pick_better_x0(x0, newx0, time_step, residual, "enhanced flat start") + _pick_better_x0(x0, newx0, time_step, residual, data, "enhanced flat start") else @debug "skipping enhanced flat start" end @@ -28,7 +28,7 @@ function improve_x0(pf::ACPolarPowerFlow, else @debug "skipping running DC power flow fallback" end - residual(x0, time_step) # re-calculate residual for new x0: might have changed. + residual(data, x0, time_step) # re-calculate residual for new x0: might have changed. if sum(abs, residual.Rv) > LARGE_RESIDUAL * length(residual.Rv) # Tail rows (LCC/VSC/area) are not bus quantities: let the resolver label the index. @@ -55,21 +55,21 @@ function improve_x0(pf::ACRectangularPowerFlow, rect_initial_state!( x0, data, residual.bus_state_offset, residual.bus_block_size, time_step, ) - residual(x0, time_step) + residual(data, x0, time_step) prev = findlast(@view(data.converged[1:(time_step - 1)])) if !isnothing(prev) newx0 = copy(x0) _rect_fill_state!(newx0, data, residual.bus_state_offset, time_step, prev) - _pick_better_x0(x0, newx0, time_step, residual, "previous converged solution") + _pick_better_x0(x0, newx0, time_step, residual, data, "previous converged solution") end if norm(residual.Rv, 1) > LARGE_RESIDUAL * length(residual.Rv) && get_enhanced_flat_start(pf) newx0 = _enhanced_flat_start(x0, data, residual, time_step) - _pick_better_x0(x0, newx0, time_step, residual, "enhanced flat start") + _pick_better_x0(x0, newx0, time_step, residual, data, "enhanced flat start") else @debug "skipping enhanced flat start" end - residual(x0, time_step) # re-calculate residual for chosen x0 + residual(data, x0, time_step) # re-calculate residual for chosen x0 if sum(abs, residual.Rv) > LARGE_RESIDUAL * length(residual.Rv) lg_res, ix = findmax(abs.(residual.Rv)) lg_res_rounded = round(lg_res; sigdigits = 3) @@ -97,21 +97,21 @@ function improve_x0(pf::ACMixedPowerFlow, mixed_initial_state!( x0, data, residual.bus_state_offset, residual.bus_block_size, time_step, ) - residual(x0, time_step) + residual(data, x0, time_step) prev = findlast(@view(data.converged[1:(time_step - 1)])) if !isnothing(prev) newx0 = copy(x0) _mixed_fill_state!(newx0, data, residual.bus_state_offset, time_step, prev) - _pick_better_x0(x0, newx0, time_step, residual, "previous converged solution") + _pick_better_x0(x0, newx0, time_step, residual, data, "previous converged solution") end if norm(residual.Rv, 1) > LARGE_RESIDUAL * length(residual.Rv) && get_enhanced_flat_start(pf) newx0 = _enhanced_flat_start(x0, data, residual, time_step) - _pick_better_x0(x0, newx0, time_step, residual, "enhanced flat start") + _pick_better_x0(x0, newx0, time_step, residual, data, "enhanced flat start") else @debug "skipping enhanced flat start" end - residual(x0, time_step) # re-calculate residual for chosen x0 + residual(data, x0, time_step) # re-calculate residual for chosen x0 if sum(abs, residual.Rv) > LARGE_RESIDUAL * length(residual.Rv) lg_res, ix = findmax(abs.(residual.Rv)) lg_res_rounded = round(lg_res; sigdigits = 3) @@ -125,10 +125,11 @@ function _smaller_residual(x0::Vector{Float64}, newx0::Vector{Float64}, time_step::Int64, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, + data::ACPowerFlowData, ) - residual(x0, time_step) + residual(data, x0, time_step) residualSize = norm(residual.Rv, 1) - residual(newx0, time_step) + residual(data, newx0, time_step) newResidualSize = norm(residual.Rv, 1) return newResidualSize < residualSize end @@ -137,12 +138,13 @@ function _pick_better_x0(x0::Vector{Float64}, newx0::Vector{Float64}, time_step::Int64, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, + data::ACPowerFlowData, improvement_method::String, ) - if _smaller_residual(x0, newx0, time_step, residual) + if _smaller_residual(x0, newx0, time_step, residual, data) @info "success: $improvement_method yields smaller residual" copyto!(x0, newx0) - residual(x0, time_step) # re-calculate for new x0. + residual(data, x0, time_step) # re-calculate for new x0. else @debug "no improvement from $improvement_method" end @@ -159,7 +161,7 @@ function dc_power_flow_start!(x0::Vector{Float64}, ) _dc_power_flow_fallback!(data, time_step) newx0 = calculate_x0(data, time_step) - _pick_better_x0(x0, newx0, time_step, residual, "DC power flow fallback") + _pick_better_x0(x0, newx0, time_step, residual, data, "DC power flow fallback") return end @@ -310,7 +312,7 @@ function _initialize_residual_x0(pf::ACPolarPowerFlow, else x0_computed = copy(x0) @warn "Using caller-provided x0; skipping improve_x0." - residual(x0_computed, time_step) + residual(data, x0_computed, time_step) end _log_initial_residual(residual) @@ -335,8 +337,8 @@ function initialize_power_flow_variables(pf::ACPolarPowerFlow{T}, pf, data, time_step; x0, validate_voltage_magnitudes, vm_validation_range, ) - J = ACPowerFlowJacobian(residual, time_step) - J(time_step) + J = ACPowerFlowJacobian(data, residual, time_step) + J(data, time_step) return residual, J, x0_computed end @@ -355,10 +357,10 @@ function initialize_power_flow_variables(pf::ACRectangularPowerFlow{T}, else x0_computed = copy(x0) @warn "Using caller-provided x0; skipping improve_x0." - residual(x0_computed, time_step) + residual(data, x0_computed, time_step) end _log_initial_residual(residual) - J = ACRectangularCIJacobian(residual, time_step) + J = ACRectangularCIJacobian(data, residual, time_step) return residual, J, x0_computed end @@ -376,9 +378,9 @@ function initialize_power_flow_variables(pf::ACMixedPowerFlow{T}, else x0_computed = copy(x0) @warn "Using caller-provided x0; skipping improve_x0." - residual(x0_computed, time_step) + residual(data, x0_computed, time_step) end _log_initial_residual(residual) - J = ACMixedCPBJacobian(residual, time_step) + J = ACMixedCPBJacobian(data, residual, time_step) return residual, J, x0_computed end diff --git a/src/psse_export.jl b/src/psse_export.jl index 7ac722f2..a683acb3 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -1426,6 +1426,7 @@ function _make_gens_from_hvdc( 0.0, 0.0, 0.0, ), base_power = PSY.get_base_power(exporter.system, PSY.NU), + input_basis = PSY.CU, ) end diff --git a/src/rectangular_ci_power_flow_jacobian.jl b/src/rectangular_ci_power_flow_jacobian.jl index 392170a9..67b10975 100644 --- a/src/rectangular_ci_power_flow_jacobian.jl +++ b/src/rectangular_ci_power_flow_jacobian.jl @@ -10,7 +10,6 @@ built once at construction time, so the hot-path cost is `O(N + n_LCC)` rather than `O((N + n_LCC) · log(nnz_per_col))` of `Jv[r, c] = v` setindex. # Fields -- `data::ACPowerFlowData` - `Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}` — Jacobian values - `Y_bus_eff::SparseMatrixCSC{ComplexF64, Int}` — Y_bus with ZIP-Z folded in - `Y_diag::Vector{ComplexF64}` — cached Y_bus_eff diagonal @@ -20,8 +19,7 @@ than `O((N + n_LCC) · log(nnz_per_col))` of `Jv[r, c] = v` setindex. `slack_bus_k` / `slack_c_k` for the corresponding per-iteration data - LCC tail nzval cache `lcc_nz` (24×n_lccs; the last 2 identity diagonals stay 1.0) """ -struct ACRectangularCIJacobian{D <: ACPowerFlowData} - data::D +struct ACRectangularCIJacobian Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} Y_diag::Vector{ComplexF64} # cached Y_bus_eff diagonal; avoids O(log nnz) sparse access per iteration @@ -50,11 +48,12 @@ struct ACRectangularCIJacobian{D <: ACPowerFlowData} end function ACRectangularCIJacobian( + data::ACPowerFlowData, residual::ACRectangularCIResidual, time_step::Int64, ) Jv0 = _create_rect_ci_jacobian_structure( - residual.data, + data, residual.Y_bus_eff, residual.bus_slack_participation_factors, residual.subnetworks, @@ -68,16 +67,16 @@ function ACRectangularCIJacobian( Jv0, residual.Y_bus_eff, residual.bus_state_offset, - view(residual.data.bus_type, :, time_step), + view(data.bus_type, :, time_step), ) - n_buses = first(size(residual.data.bus_type)) + n_buses = first(size(data.bus_type)) Y_diag = Vector{ComplexF64}(undef, n_buses) @inbounds for i in 1:n_buses Y_diag[i] = residual.Y_bus_eff[i, i] end diag_base_nz, pv_extra_nz = _build_diag_nz_cache( Jv0, residual.bus_state_offset, - view(residual.data.bus_type, :, time_step), + view(data.bus_type, :, time_step), ) # REF status is fixed for the life of a solve; reuse the residual's # already-computed set instead of reallocating it here. @@ -86,17 +85,16 @@ function ACRectangularCIJacobian( Jv0, residual.bus_state_offset, residual.subnetworks, residual.bus_slack_participation_factors, residual.independent_ref, ) - n_lccs = size(residual.data.lcc.p_set, 1) + n_lccs = size(data.lcc.p_set, 1) lcc_nz = _build_lcc_nz_cache( - Jv0, residual.data, residual.bus_state_offset, + Jv0, data, residual.bus_state_offset, residual.total_bus_state, n_lccs, ) vsc_nz = _build_vsc_nz_cache( - Jv0, get_dc_network(residual.data), residual.bus_state_offset, + Jv0, get_dc_network(data), residual.bus_state_offset, residual.total_bus_state, n_lccs, ) J = ACRectangularCIJacobian( - residual.data, Jv0, residual.Y_bus_eff, Y_diag, @@ -122,12 +120,12 @@ function ACRectangularCIJacobian( lcc_nz, vsc_nz, ) - J(time_step) # populate state-dependent entries (diagonals, slack, LCC tail) + J(data, time_step) # populate state-dependent entries (diagonals, slack, LCC tail) return J end -function (J::ACRectangularCIJacobian)(time_step::Int64) - _update_rect_ci_jacobian_values!(J.Jv, J.data, J.Y_diag, +function (J::ACRectangularCIJacobian)(data::ACPowerFlowData, time_step::Int64) + _update_rect_ci_jacobian_values!(J.Jv, data, J.Y_diag, J.e_state, J.f_state, J.Q_state, J.P_eff_cache, J.Q_eff_cache, J.const_I_P, J.const_I_Q, J.bus_slack_participation_factors, J.independent_ref, @@ -139,10 +137,11 @@ function (J::ACRectangularCIJacobian)(time_step::Int64) end function (J::ACRectangularCIJacobian)( + data::ACPowerFlowData, Jv::SparseMatrixCSC{Float64, J_INDEX_TYPE}, time_step::Int64, ) - _update_rect_ci_jacobian_values!(J.Jv, J.data, J.Y_diag, + _update_rect_ci_jacobian_values!(J.Jv, data, J.Y_diag, J.e_state, J.f_state, J.Q_state, J.P_eff_cache, J.Q_eff_cache, J.const_I_P, J.const_I_Q, J.bus_slack_participation_factors, J.independent_ref, diff --git a/src/rectangular_ci_power_flow_residual.jl b/src/rectangular_ci_power_flow_residual.jl index 8d7bfb51..453c9890 100644 --- a/src/rectangular_ci_power_flow_residual.jl +++ b/src/rectangular_ci_power_flow_residual.jl @@ -7,7 +7,6 @@ state representation: PQ/REF blocks are 2 entries `(e,f)` or `(P_gen, Q_gen)`; PV blocks are 3 entries `(e, f, Q)`. # Fields -- `data::ACPowerFlowData` - `Rv::Vector{Float64}` — current residual values, length `total_bus_state + 4·n_LCC` - `Y_bus_eff::SparseMatrixCSC{ComplexF64, Int}` — Y_bus with ZIP constant-Z folded in - `P_net_const::Vector{Float64}` — constant-power net injection (no |V| dependence) @@ -26,8 +25,7 @@ PV blocks are 3 entries `(e, f, Q)`. - `validate_offsets::Vector{Int}` — precomputed `x`-offsets of PQ/PV buses for the per-iteration voltage-magnitude diagnostic """ -struct ACRectangularCIResidual{D <: ACPowerFlowData} - data::D +struct ACRectangularCIResidual Rv::Vector{Float64} Y_bus_eff::SparseMatrixCSC{ComplexF64, Int} P_net_const::Vector{Float64} @@ -102,7 +100,6 @@ function ACRectangularCIResidual(data::ACPowerFlowData, time_step::Int64) fold_zip_constant_z!(Y_bus_eff, data, time_step) return ACRectangularCIResidual( - data, Vector{Float64}(undef, total_state), Y_bus_eff, P_net_const, @@ -126,6 +123,7 @@ function ACRectangularCIResidual(data::ACPowerFlowData, time_step::Int64) end function (R::ACRectangularCIResidual)( + data::ACPowerFlowData, Rv::Vector{Float64}, x::Vector{Float64}, time_step::Int64, @@ -135,18 +133,22 @@ function (R::ACRectangularCIResidual)( R.bus_slack_participation_factors, R.subnetworks, R.independent_ref, R.bus_state_offset, R.bus_block_size, R.total_bus_state, R.e_state, R.f_state, R.Q_state, R.P_eff_cache, R.Q_eff_cache, - R.data, time_step) + data, time_step) copyto!(Rv, R.Rv) return end -function (R::ACRectangularCIResidual)(x::Vector{Float64}, time_step::Int64) +function (R::ACRectangularCIResidual)( + data::ACPowerFlowData, + x::Vector{Float64}, + time_step::Int64, +) _update_rect_ci_residual_values!(R.Rv, x, R.Y_bus_eff, R.P_net_const, R.Q_net_const, R.const_I_P, R.const_I_Q, R.P_net_set, R.bus_slack_participation_factors, R.subnetworks, R.independent_ref, R.bus_state_offset, R.bus_block_size, R.total_bus_state, R.e_state, R.f_state, R.Q_state, R.P_eff_cache, R.Q_eff_cache, - R.data, time_step) + data, time_step) return end diff --git a/src/residual_condition_diagnostics.jl b/src/residual_condition_diagnostics.jl index d4f51349..7b0978ef 100644 --- a/src/residual_condition_diagnostics.jl +++ b/src/residual_condition_diagnostics.jl @@ -385,9 +385,10 @@ the scratch only when a diagnostic or the bail-out is on so the default solve pa allocates nothing. `diag_state` is `nothing` when neither is requested.""" function setup_solver_diagnostics( J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, bail::Bool, ) - monitor = get_log_solver_diagnostics(J.data) + monitor = get_log_solver_diagnostics(data) diag_state = (monitor || bail) ? SolverDiagnosticsState(size(J.Jv, 1)) : nothing return monitor, diag_state end @@ -404,6 +405,7 @@ function run_solver_diagnostics!( label::AbstractString, residual::Union{ACPowerFlowResidual, ACRectangularCIResidual, ACMixedCPBResidual}, J::Union{ACPowerFlowJacobian, ACRectangularCIJacobian, ACMixedCPBJacobian}, + data::ACPowerFlowData, time_step::Int, cache::PFLinearSolverCache, monitor::Bool, @@ -419,7 +421,6 @@ function run_solver_diagnostics!( singular = true end - data = J.data if singular if bail @warn "$label: the Jacobian is singular; this is a fold / " * diff --git a/test/Project.toml b/test/Project.toml index a16eb09b..4c7a6fb4 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -36,10 +36,7 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" [sources] PowerFlows = {path = ".."} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} -# PSCB/parser branches carry the PSS/E import-contract fixes the round-trip tests need -# jd/pf_units_fix (PowerSystemCaseBuilder.jl#229) stores LCC transfer_setpoint per-unit on the -# legacy parser path; revert the pin to psy6 once #229 merges. -PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "jd/pf_units_fix"} +PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems diff --git a/test/test_ac_nr_allocations.jl b/test/test_ac_nr_allocations.jl index 246971b8..b2cb52db 100644 --- a/test/test_ac_nr_allocations.jl +++ b/test/test_ac_nr_allocations.jl @@ -5,16 +5,16 @@ pf = ACPowerFlow{PF.NewtonRaphsonACPowerFlow}(; correct_bustypes = true) pf_data = PF.PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(pf_data, 1) - J = PF.ACPowerFlowJacobian(residual, 1) + J = PF.ACPowerFlowJacobian(pf_data, residual, 1) x0 = PF.calculate_x0(pf_data, 1) - residual(x0, 1) # warm - J(1) # warm + residual(pf_data, x0, 1) # warm + J(pf_data, 1) # warm # --- per-call upper bounds (chosen ~2x current best-case after fixes) --- # Baseline before fixes is ~140 KB on 2000-bus; target post-fix is < 2 KB. - @test (@allocated residual(x0, 1)) < 2_000 + @test (@allocated residual(pf_data, x0, 1)) < 2_000 # Jacobian update is already lean; tight bound catches future regressions. - @test (@allocated J(1)) < 200 + @test (@allocated J(pf_data, 1)) < 200 # --- _do_refinement! mul! path: A * Δx_nr should be zero-alloc when using mul! --- cache = PF.make_linear_solver_cache(PF.PNM.KLUSolver(), J.Jv) @@ -41,14 +41,14 @@ end PF.rect_initial_state!( x0, pf_data, residual.bus_state_offset, residual.bus_block_size, 1, ) - residual(x0, 1) # warm - J = PF.ACRectangularCIJacobian(residual, 1) - J(1) # warm + residual(pf_data, x0, 1) # warm + J = PF.ACRectangularCIJacobian(pf_data, residual, 1) + J(pf_data, 1) # warm # Baselines on 2000-bus: residual ~96 B, Jacobian ~144 B. Tight bounds # catch any future change that reintroduces per-iteration allocations. - @test (@allocated residual(x0, 1)) < 500 - @test (@allocated J(1)) < 500 + @test (@allocated residual(pf_data, x0, 1)) < 500 + @test (@allocated J(pf_data, 1)) < 500 # The linear-solve step also goes through the PNM linear-solver cache for the # rectangular Jacobian; mul! against the rect Jv must be zero-alloc. diff --git a/test/test_area_interchange_enrollment.jl b/test/test_area_interchange_enrollment.jl index 0108511a..10dbe1b5 100644 --- a/test/test_area_interchange_enrollment.jl +++ b/test/test_area_interchange_enrollment.jl @@ -161,8 +161,8 @@ end secondary_bus = PSY.get_from(PSY.get_arc(PSY.get_secondary_circuit(trf))) tertiary_bus = PSY.get_from(PSY.get_arc(PSY.get_tertiary_circuit(trf))) - areaA = PSY.Area(; name = "AreaA") - areaB = PSY.Area(; name = "AreaB") + areaA = PSY.Area(; name = "AreaA", input_basis = PSY.CU) + areaB = PSY.Area(; name = "AreaB", input_basis = PSY.CU) PSY.add_component!(sys, areaA) PSY.add_component!(sys, areaB) PSY.set_area!(primary_bus, areaA) @@ -213,7 +213,7 @@ end x = 0.01, rating = 1.0, discrete_branch_type = PSY.DiscreteControlledBranchType.BREAKER, - branch_status = PSY.DiscreteControlledBranchStatus.CLOSED, + branch_status = PSY.DiscreteControlledBranchStatus.CLOSED, input_basis = PSY.CU, ) open_sw = PSY.DiscreteControlledACBranch(; name = "sw_open", @@ -225,7 +225,7 @@ end x = 0.01, rating = 1.0, discrete_branch_type = PSY.DiscreteControlledBranchType.BREAKER, - branch_status = PSY.DiscreteControlledBranchStatus.OPEN, + branch_status = PSY.DiscreteControlledBranchStatus.OPEN, input_basis = PSY.CU, ) @test PF._tie_in_service(closed_sw) == true @test PF._tie_in_service(open_sw) == false @@ -247,7 +247,7 @@ end x = 0.01, rating = 1.0, discrete_branch_type = PSY.DiscreteControlledBranchType.BREAKER, - branch_status = PSY.DiscreteControlledBranchStatus.OPEN, + branch_status = PSY.DiscreteControlledBranchStatus.OPEN, input_basis = PSY.CU, ) PSY.add_component!(sys, sw) @@ -297,8 +297,8 @@ end x = 0.10, tap = 1.0, rating = 1.0, - base_power = 100.0, - ), + base_power = 100.0, input_basis = PSY.CU, + ), input_basis = PSY.CU, ) PSY.add_component!(sys, parallel_tx) @@ -453,8 +453,8 @@ AC-disconnected system returns an empty `ac_ties` and a `dc_ties` that touches A entirely by a real `PowerFlowData`/`LCCParameters`/reduced-network build.""" function _dc_tie_only_fixture() sys = System(100.0) - area1 = PSY.Area(; name = "Area1") - area3 = PSY.Area(; name = "Area3") + area1 = PSY.Area(; name = "Area1", input_basis = PSY.CU) + area3 = PSY.Area(; name = "Area3", input_basis = PSY.CU) PSY.add_component!(sys, area1) PSY.add_component!(sys, area3) diff --git a/test/test_area_interchange_solve.jl b/test/test_area_interchange_solve.jl index 57472ddb..a0408314 100644 --- a/test/test_area_interchange_solve.jl +++ b/test/test_area_interchange_solve.jl @@ -188,7 +188,7 @@ end residual = PF.ACPowerFlowResidual(data, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) + residual(data, x0, 1) F = residual.Rv dcn = PF.get_dc_network(data) area_off = PF.area_tail_offset(data, dcn) @@ -227,7 +227,7 @@ end # tie contributes +P_m to one tracked area and -P_m to the other), so it must hold at # ANY state, not only at a converged solution. x1 = x0 .+ 0.05 .* sin.(1:length(x0)) - residual(x1, 1) + residual(data, x1, 1) F = residual.Rv area_off = PF.area_tail_offset(data, PF.get_dc_network(data)) @@ -244,13 +244,13 @@ end area = first(data.area_interchange.areas) slack_ix = area.slack_bus_ix - residual(x0, 1) + residual(data, x0, 1) F_base = copy(residual.Rv) ΔP = 0.037 x1 = copy(x0) x1[area_off + area.tail_ix] = ΔP - residual(x1, 1) + residual(data, x1, 1) F_pert = residual.Rv # ΔP_a is added to P_net[slack_bus_ix] at the same seam as the distributed-slack @@ -271,7 +271,7 @@ end data = _two_controlled_area_data() residual = PF.ACPowerFlowResidual(data, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) # warm: populate data.bus_magnitude/bus_angles, JIT compile + residual(data, x0, 1) # warm: populate data.bus_magnitude/bus_angles, JIT compile dcn = PF.get_dc_network(data) area_off = PF.area_tail_offset(data, dcn) F = copy(residual.Rv) @@ -310,18 +310,18 @@ end @testset "area interchange Jacobian structure cache reuse" begin data1 = _two_controlled_area_data() residual1a = PF.ACPowerFlowResidual(data1, 1) - PF.ACPowerFlowJacobian(residual1a, 1) + PF.ACPowerFlowJacobian(data1, residual1a, 1) cache1 = data1.ac_jacobian_structure_cache[] @test !isnothing(cache1) @test cache1.area_data === data1.area_interchange residual1b = PF.ACPowerFlowResidual(data1, 1) - PF.ACPowerFlowJacobian(residual1b, 1) + PF.ACPowerFlowJacobian(data1, residual1b, 1) @test data1.ac_jacobian_structure_cache[] === cache1 data2 = _two_controlled_area_data() residual2 = PF.ACPowerFlowResidual(data2, 1) - PF.ACPowerFlowJacobian(residual2, 1) + PF.ACPowerFlowJacobian(data2, residual2, 1) cache2 = data2.ac_jacobian_structure_cache[] @test cache2.area_data === data2.area_interchange @test cache2.area_data !== cache1.area_data @@ -343,7 +343,7 @@ end x0 = PF.calculate_x0(data, 1) Random.seed!(7) x0 .+= 0.02 .* randn(length(x0)) - residual(x0, 1) # updates data.bus_magnitude/bus_angles in place + residual(data, x0, 1) # updates data.bus_magnitude/bus_angles in place expected = _oracle_tie_metered_power(sys, data, tie, 1) actual = PF._tie_metered_active_power( @@ -803,9 +803,9 @@ end function _weak_tie_three_area_fixture(; x_weak::Float64 = 2.0, pdes2::Float64 = 0.1, pdes3::Float64 = 2.0) sys = System(100.0) - area1 = PSY.Area(; name = "Area1") - area2 = PSY.Area(; name = "Area2") - area3 = PSY.Area(; name = "Area3") + area1 = PSY.Area(; name = "Area1", input_basis = PSY.CU) + area2 = PSY.Area(; name = "Area2", input_basis = PSY.CU) + area3 = PSY.Area(; name = "Area3", input_basis = PSY.CU) PSY.add_component!(sys, area1) PSY.add_component!(sys, area2) PSY.add_component!(sys, area3) @@ -1033,10 +1033,10 @@ end ) data = PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) - jac(1) + residual(data, x0, 1) + jac(data, 1) n_state = size(jac.Jv, 1) dcn = PF.get_dc_network(data) @@ -1849,7 +1849,7 @@ end residual = PF.ACPowerFlowResidual(data, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) + residual(data, x0, 1) F = residual.Rv dcn = PF.get_dc_network(data) area_off = PF.area_tail_offset(data, dcn) @@ -1907,15 +1907,15 @@ end ACPolarPowerFlow{NewtonRaphsonACPowerFlow}(; area_interchange_control = true), sys) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x0 = PF.calculate_x0(data, 1) # Perturbed, non-solution state so bus Vm/θ AND the DC-tail columns (LCC tap/α, VSC # P_c) all carry nonzero sensitivity through the area-interchange NI rows. x = x0 .+ 0.02 .* sin.(1:length(x0)) - residual(x, 1) - jac(1) + residual(data, x, 1) + jac(data, 1) verify_jacobian_asymptotic( - residual, jac.Jv, x, 1; + residual, data, jac.Jv, x, 1; label = "area interchange DC ($lcc_metered_end-metered)") end end @@ -2186,8 +2186,8 @@ genuinely merges them (`fix == tix`) -- the self-tie merge guard case, distinct "interior DC link" test (same-tail on two DIFFERENT buses, not a merge).""" function _lcc_self_merge_fixture() sys = System(100.0) - area_a = PSY.Area(; name = "AreaA") - area_b = PSY.Area(; name = "AreaB") + area_a = PSY.Area(; name = "AreaA", input_basis = PSY.CU) + area_b = PSY.Area(; name = "AreaB", input_basis = PSY.CU) PSY.add_component!(sys, area_a) PSY.add_component!(sys, area_b) diff --git a/test/test_dc_phase_shifters.jl b/test/test_dc_phase_shifters.jl index 75ccdca8..2eea167f 100644 --- a/test/test_dc_phase_shifters.jl +++ b/test/test_dc_phase_shifters.jl @@ -26,7 +26,7 @@ function _dc_line_pst_parallel_sys(; load_p::Float64 = 0.0, pst_r::Float64 = 0.0 x = 0.1, b = (from = 0.0, to = 0.0), rating = 2.0, - angle_limits = (min = -pi / 2, max = pi / 2), + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU, ) add_component!(sys, line) pst = PSY.TwoWindingTransformer(; @@ -40,8 +40,8 @@ function _dc_line_pst_parallel_sys(; load_p::Float64 = 0.0, pst_r::Float64 = 0.0 α = 0.15, rating = 2.0, base_power = 100.0, - control_limits = (min = -0.7, max = 0.7), - ), + control_limits = (min = -0.7, max = 0.7), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, pst) return sys @@ -85,7 +85,7 @@ function _dc_pst_loop_sys() x = 0.1, b = (from = 0.0, to = 0.0), rating = 2.0, - angle_limits = (min = -pi / 2, max = pi / 2), + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU, ) add_component!(sys, line12) line23 = PSY.Line(; @@ -98,7 +98,7 @@ function _dc_pst_loop_sys() x = 0.1, b = (from = 0.0, to = 0.0), rating = 2.0, - angle_limits = (min = -pi / 2, max = pi / 2), + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU, ) add_component!(sys, line23) pst = PSY.TwoWindingTransformer(; @@ -112,8 +112,8 @@ function _dc_pst_loop_sys() α = 0.15, rating = 2.0, base_power = 100.0, - control_limits = (min = -0.7, max = 0.7), - ), + control_limits = (min = -0.7, max = 0.7), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, pst) return sys @@ -225,7 +225,7 @@ function _dc_pst_loop_with_series_pst_sys() x = 0.1, b = (from = 0.0, to = 0.0), rating = 2.0, - angle_limits = (min = -pi / 2, max = pi / 2), + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU, ) add_component!(sys, line15) pst53 = PSY.TwoWindingTransformer(; @@ -239,8 +239,8 @@ function _dc_pst_loop_with_series_pst_sys() α = 0.1, rating = 2.0, base_power = 100.0, - control_limits = (min = -0.7, max = 0.7), - ), + control_limits = (min = -0.7, max = 0.7), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, pst53) return sys diff --git a/test/test_dc_power_flow.jl b/test/test_dc_power_flow.jl index 46ba872e..49a8a07f 100644 --- a/test/test_dc_power_flow.jl +++ b/test/test_dc_power_flow.jl @@ -185,6 +185,7 @@ end max_impedance_reactive_power = PSY.get_max_reactive_power(load, PSY.NU), max_current_active_power = PSY.get_max_active_power(load, PSY.NU), max_current_reactive_power = PSY.get_max_reactive_power(load, PSY.NU), + input_basis = PSY.CU, ) add_component!(sys, new_load) set_zip_load_in_mva!(sys, (0.0, P, 0.0)) diff --git a/test/test_discrete_control.jl b/test/test_discrete_control.jl index a8b3c62c..e6739fcf 100644 --- a/test/test_discrete_control.jl +++ b/test/test_discrete_control.jl @@ -49,7 +49,7 @@ function build_ieee14_facts_system(; control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = vset, shunt_control_type = shunt_control_type, - regulated_bus_number = regulated_bus_number, + regulated_bus_number = regulated_bus_number, input_basis = PSY.CU, ) # `max_shunt_current`/`max_reactive_power` are stored in device base; the constructor # kwargs take a raw CU value, so set them through the units-aware setters to honor the @@ -1014,7 +1014,7 @@ end Line(; name = tap_name, available = true, active_power_flow = 0.0, reactive_power_flow = 0.0, arc = Arc(; from = b2, to = b3), r = 0.1, x = 0.1, b = (from = 0.0, to = 0.0), rating = 1.0, - angle_limits = (min = -pi / 2, max = pi / 2)), + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU), ) pf = ACPolarPowerFlow(; control_discrete_devices = true) @test solve_and_store_power_flow!(pf, sys) @@ -1231,13 +1231,15 @@ end sys, PowerLoad(; name = "l$k", available = true, bus = bl, active_power = 0.5, reactive_power = 0.25, base_power = 100.0, - max_active_power = 100.0, max_reactive_power = 100.0), + max_active_power = 100.0, max_reactive_power = 100.0, + input_basis = PSY.CU), ) add_component!( sys, PowerLoad(; name = "s$k", available = true, bus = bs, active_power = 0.05, reactive_power = 0.025, base_power = 100.0, - max_active_power = 100.0, max_reactive_power = 100.0), + max_active_power = 100.0, max_reactive_power = 100.0, + input_basis = PSY.CU), ) _add_simple_line!(sys, ref, bs, 1e-2, 1e-2, 0.0) add_component!( @@ -1246,7 +1248,8 @@ end circuit = TransformerCircuit(; available = true, arc = Arc(; from = ref, to = bl), r = 0.01, x = 0.10, tap = 1.0, rating = 1.0, base_power = 100.0, - control_objective = PSY.TransformerControlObjective.VOLTAGE)), + control_objective = PSY.TransformerControlObjective.VOLTAGE, + input_basis = PSY.CU), input_basis = PSY.CU), ) add_component!( sys, diff --git a/test/test_distributed_slack.jl b/test/test_distributed_slack.jl index e7f675d8..ea1f4d46 100644 --- a/test/test_distributed_slack.jl +++ b/test/test_distributed_slack.jl @@ -131,7 +131,7 @@ end fuel = ThermalFuels.OTHER, services = Device[], dynamic_injector = nothing, - ext = Dict{String, Any}(), + ext = Dict{String, Any}(), input_basis = PSY.CU, ) add_component!(sys, g2) @@ -372,7 +372,7 @@ end fuel = ThermalFuels.OTHER, services = Device[], dynamic_injector = nothing, - ext = Dict{String, Any}(), + ext = Dict{String, Any}(), input_basis = PSY.CU, ) add_component!(sys, g2) diff --git a/test/test_fast_decoupled.jl b/test/test_fast_decoupled.jl index 641da2d6..23e2dea0 100644 --- a/test/test_fast_decoupled.jl +++ b/test/test_fast_decoupled.jl @@ -279,10 +279,11 @@ function _tapped_magnetizing_shunt_system() tap = 1.05, α = 0.0, rating = 2.0, - base_power = 100.0, + base_power = 100.0, input_basis = PSY.CU, ), magnetizing_shunt = 0.0 + 0.04im, shunt_location = PSY.TwoWindingTransformerShuntLocation.PRIMARY, + input_basis = PSY.CU, ) add_component!(sys, tx) # A FixedAdmittance at b3 so a true bus shunt is distinguished from a mis-split branch shunt. @@ -354,8 +355,8 @@ function _zero_impedance_transformer_system() tap = 1.0, α = 0.0, rating = 2.0, - base_power = 100.0, - ), + base_power = 100.0, input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, tx) return sys @@ -421,8 +422,8 @@ function _pst_line_parallel_degree_two_system() α = 0.15, rating = 2.0, base_power = 100.0, - control_limits = (min = -0.7, max = 0.7), - ), + control_limits = (min = -0.7, max = 0.7), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, pst12) _add_simple_line!(sys, b2, b3, 0.01, 0.10, 0.0) @@ -579,7 +580,7 @@ end # Flat-start residual (fresh residual on freshly-initialized data). residual_flat = PF.ACPowerFlowResidual(data, 1) x0_flat = _calc_x(data, 1) - residual_flat(x0_flat, 1) + residual_flat(data, x0_flat, 1) flat_ss = sum(abs2, residual_flat.Rv) converged = _drive_fd_directly(pf, data; @@ -595,7 +596,7 @@ end # diverged state in `data`). residual_final = PF.ACPowerFlowResidual(data, 1) x_final = _calc_x(data, 1) - residual_final(x_final, 1) + residual_final(data, x_final, 1) final_ss = sum(abs2, residual_final.Rv) @test isfinite(final_ss) @test final_ss <= flat_ss + 1e-8 @@ -700,8 +701,8 @@ function _phase_shifter_system() rating = 2.0, base_power = 100.0, # Phase-angle bounds (rad) live in the circuit's control band. - control_limits = (min = -0.7, max = 0.7), - ), + control_limits = (min = -0.7, max = 0.7), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, pst) # A fixed-admittance shunt at b3 so the per-bus shunt-residual path is exercised too. @@ -810,7 +811,7 @@ _fd_decoupled_pf(; scheme::PF.FDScheme = PF.FDSchemeXB(), kwargs...) = lc_nr = PF.make_linear_solver_cache(backend, J_nr.Jv) PF.symbolic_factor!(lc_nr, J_nr.Jv) conv_nr, it_nr = PF._run_power_flow_method( - 1, sv_nr, lc_nr, r_nr, J_nr, NewtonRaphsonACPowerFlow; + 1, sv_nr, lc_nr, r_nr, J_nr, data_nr2, NewtonRaphsonACPowerFlow; tol = 1e-9, maxIterations = 50) @test conv_nr @@ -1559,7 +1560,7 @@ end Vm = view(data.bus_magnitude, :, 1) residual = PF.ACPowerFlowResidual(data, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) # warm residual + sync data + residual(data, x0, 1) # warm residual + sync data rp = cache.rp rq = pqdata.rq @@ -1612,7 +1613,7 @@ end a_lazy = @allocated PF._initialize_residual_x0(pf, data, 1; kw...) a_full = @allocated PF.initialize_power_flow_variables(pf, data, 1; kw...) jac_bytes = Base.summarysize( - PF.ACPowerFlowJacobian(PF.ACPowerFlowResidual(data, 1), 1).Jv) + PF.ACPowerFlowJacobian(data, PF.ACPowerFlowResidual(data, 1), 1).Jv) @test a_lazy < a_full @test (a_full - a_lazy) > jac_bytes ÷ 2 @@ -1631,8 +1632,8 @@ end @test cache.bp_factor_count == 1 # lazy-J change did not disturb factor-once jac_alloc = @allocated ( - let J = PF.ACPowerFlowJacobian(PF.ACPowerFlowResidual(data_mp, 1), 1) - J(1) + let J = PF.ACPowerFlowJacobian(data_mp, PF.ACPowerFlowResidual(data_mp, 1), 1) + J(data_mp, 1) end ) a_solve = @allocated solve_power_flow!(data_mp) @@ -1702,7 +1703,7 @@ end @test all(isfinite, data.bus_magnitude[:, 1]) @test all(data.bus_magnitude[:, 1] .> 0.0) residual_final = PF.ACPowerFlowResidual(data, 1) - residual_final(_calc_x(data, 1), 1) + residual_final(data, _calc_x(data, 1), 1) @test isfinite(sum(abs2, residual_final.Rv)) end diff --git a/test/test_gradient_descent_ac_power_flow.jl b/test/test_gradient_descent_ac_power_flow.jl index a4f5039e..f93d9a38 100644 --- a/test/test_gradient_descent_ac_power_flow.jl +++ b/test/test_gradient_descent_ac_power_flow.jl @@ -56,9 +56,9 @@ time_step = 1 residual = PF.ACPowerFlowResidual(data, time_step) x0 = PF.calculate_x0(data, time_step) - residual(x0, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) - J(time_step) + residual(data, x0, time_step) + J = PF.ACPowerFlowJacobian(data, residual, time_step) + J(data, time_step) state = PF.AdamState(length(x0)) # Warm up diff --git a/test/test_homotopy_hessian.jl b/test/test_homotopy_hessian.jl index 3d06c34b..c38190c2 100644 --- a/test/test_homotopy_hessian.jl +++ b/test/test_homotopy_hessian.jl @@ -8,7 +8,7 @@ t_k = 1.0 residual = PF.ACPowerFlowResidual(data, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) + J = PF.ACPowerFlowJacobian(data, residual, time_step) # when t_k is 1, homotopy hessian H(x) is Jacobian matrix of G(x) := J(x)^T*F(x) # check that as Δx -> 0, [G(x) - G(x+Δx)] - H(x)*Δx -> 0 at O(norm(Δx)^2) @@ -16,7 +16,7 @@ n = size(x0, 1) u = rand(Float64, n) .- 0.5 u /= LinearAlgebra.norm(u) - hess(x0, t_k, time_step) + hess(data, x0, t_k, time_step) errors = [] Δx_mags = collect(10.0^k for k in -3:-1:-6) for Δx_mag in Δx_mags @@ -25,8 +25,8 @@ inputValues = [x0, x1] outputValues = Vector{Vector{Float64}}() for inputVal in inputValues - residual(inputVal, time_step) - J(time_step) + residual(data, inputVal, time_step) + J(data, time_step) push!(outputValues, J.Jv' * residual.Rv) end ΔFtJ = outputValues[2] - outputValues[1] @@ -46,14 +46,19 @@ column-by-column diagnostics for free.""" mutable struct _GradAsResidual pfResidual::PF.ACPowerFlowResidual J::PF.ACPowerFlowJacobian + data::PF.ACPowerFlowData Rv::Vector{Float64} end function (gr::_GradAsResidual)(x::Vector{Float64}, time_step::Int) - gr.pfResidual(x, time_step) - gr.J(time_step) + gr.pfResidual(gr.data, x, time_step) + gr.J(gr.data, time_step) gr.Rv .= gr.J.Jv' * gr.pfResidual.Rv return end +# `verify_jacobian_asymptotic` calls its residual argument as `residual(data, x, time_step)`; +# `_GradAsResidual` already carries its own `data`, so the one passed in here is ignored. +(gr::_GradAsResidual)(::PF.ACPowerFlowData, x::Vector{Float64}, time_step::Int64) = + gr(x, time_step) @testset "RH method: hessian on simple LCC system (asymptotic check)" begin time_step = 1 @@ -70,7 +75,7 @@ end solve_power_flow!(data; pf = pf) residual = PF.ACPowerFlowResidual(data, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) + J = PF.ACPowerFlowJacobian(data, residual, time_step) # Perturb every coordinate well off the NR-converged state so every # residual entry is O(1) — the four LCC ∇²F blocks are each weighted @@ -86,17 +91,18 @@ end x0[end - 1] += 0.6 # α_r x0[end] += 0.6 # α_i - residual(x0, time_step) + residual(data, x0, time_step) @test minimum(abs, residual.Rv) > 0.05 @test sin(data.lcc.rectifier.phi[1, time_step]) > 0.1 @test sin(data.lcc.inverter.phi[1, time_step]) > 0.1 hess = PF.HomotopyHessian(data, time_step) - hess(x0, 1.0, time_step) # populates hess.Hv + hess(data, x0, 1.0, time_step) # populates hess.Hv - grad_residual = _GradAsResidual(residual, J, similar(x0)) + grad_residual = _GradAsResidual(residual, J, data, similar(x0)) verify_jacobian_asymptotic( grad_residual, + data, Matrix(hess.Hv), # dense for arbitrary J·e_j slicing x0, time_step; @@ -121,7 +127,7 @@ end @test all(.!data.lcc.setpoint_at_rectifier) residual = PF.ACPowerFlowResidual(data, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) + J = PF.ACPowerFlowJacobian(data, residual, time_step) x0 = copy(PF.calculate_x0(data, time_step)) Random.seed!(2) @@ -129,17 +135,18 @@ end x0[end - 1] += 0.6 # α_r x0[end] += 0.6 # α_i - residual(x0, time_step) + residual(data, x0, time_step) @test minimum(abs, residual.Rv) > 0.05 @test sin(data.lcc.rectifier.phi[1, time_step]) > 0.1 @test sin(data.lcc.inverter.phi[1, time_step]) > 0.1 hess = PF.HomotopyHessian(data, time_step) - hess(x0, 1.0, time_step) + hess(data, x0, 1.0, time_step) - grad_residual = _GradAsResidual(residual, J, similar(x0)) + grad_residual = _GradAsResidual(residual, J, data, similar(x0)) verify_jacobian_asymptotic( grad_residual, + data, Matrix(hess.Hv), x0, time_step; @@ -163,7 +170,7 @@ end n = size(x0, 1) u = rand(Float64, n) .- 0.5 u /= LinearAlgebra.norm(u) - hess(x0, t_k, time_step) + hess(data, x0, t_k, time_step) Hv = copy(hess.Hv) errors = Float64[] Δx_mags = collect(10.0^k for k in -3:-1:-6) @@ -171,8 +178,8 @@ end x1 = x0 .+ Δx_mag .* u g0 = similar(x0) g1 = similar(x0) - PF.gradient_value!(g0, hess, t_k, x0, time_step) - PF.gradient_value!(g1, hess, t_k, x1, time_step) + PF.gradient_value!(g0, hess, data, t_k, x0, time_step) + PF.gradient_value!(g1, hess, data, t_k, x1, time_step) push!(errors, norm((g1 - g0) - Hv * (x1 - x0)) / Δx_mag) end ratios = [err / Δx_mag for (err, Δx_mag) in zip(errors, Δx_mags)] @@ -188,16 +195,16 @@ end hess = PF.HomotopyHessian(data, time_step) t_k = 0.0 x0 = PF.homotopy_x0(data, time_step) - hess(x0, t_k, time_step) + hess(data, x0, t_k, time_step) rowval, colptr = copy(hess.Hv.rowval), copy(hess.Hv.colptr) t_k = 0.5 - hess(x0, t_k, time_step) + hess(data, x0, t_k, time_step) @test hess.Hv.rowval == rowval && hess.Hv.colptr == colptr t_k = 1.0 - hess(x0, t_k, time_step) + hess(data, x0, t_k, time_step) @test hess.Hv.rowval == rowval && hess.Hv.colptr == colptr end @@ -262,7 +269,7 @@ end t_k = 0.0 x0 = PF.homotopy_x0(data, time_step) g0 = similar(x0) - PF.gradient_value!(g0, hess, t_k, x0, time_step) + PF.gradient_value!(g0, hess, data, t_k, x0, time_step) for (ind, bt) in enumerate(PF.get_bus_type(data)[:, time_step]) @test g0[2 * ind - 1] == (bt == PSY.ACBusTypes.PQ ? x0[2 * ind - 1] - 1 : 0.0) @test g0[2 * ind] == 0.0 @@ -270,7 +277,7 @@ end t_k = 0.5 g1 = similar(x0) - PF.gradient_value!(g1, hess, t_k, x0, time_step) + PF.gradient_value!(g1, hess, data, t_k, x0, time_step) n = size(x0, 1) u = rand(Float64, n) .- 0.5 @@ -283,7 +290,7 @@ end inputValues = [x0, x1] outputValues = Vector{Float64}() for inputVal in inputValues - push!(outputValues, PF.F_value(hess, t_k, inputVal, time_step)) + push!(outputValues, PF.F_value(hess, data, t_k, inputVal, time_step)) end ΔF = outputValues[2] - outputValues[1] push!(errors, norm(ΔF - dot(g1, x1 - x0)) / Δx_mag) diff --git a/test/test_hvdc.jl b/test/test_hvdc.jl index e5abaf64..900fc43c 100644 --- a/test/test_hvdc.jl +++ b/test/test_hvdc.jl @@ -168,7 +168,7 @@ function add_component_with_power!(sys::PSY.System, bus::PSY.ACBus, P::Float64) fuel = ThermalFuels.OTHER, services = Device[], dynamic_injector = nothing, - ext = Dict{String, Any}(), + ext = Dict{String, Any}(), input_basis = PSY.CU, ) add_component!(sys, gen) @assert get_active_power(gen, PSY.SU) == P @@ -181,7 +181,7 @@ function add_component_with_power!(sys::PSY.System, bus::PSY.ACBus, P::Float64) reactive_power = 0.0, # Per-unitized by device base_power base_power = 100.0, # MVA max_active_power = -P, - max_reactive_power = 0.0, + max_reactive_power = 0.0, input_basis = PSY.CU, ) add_component!(sys, load) @assert get_active_power(load, PSY.SU) == -P diff --git a/test/test_iterative_methods.jl b/test/test_iterative_methods.jl index 75684dbb..afcbe471 100644 --- a/test/test_iterative_methods.jl +++ b/test/test_iterative_methods.jl @@ -332,11 +332,11 @@ end data3 = PowerFlowData(no_dc_pf, sys3) x0 = PF.calculate_x0(data3, 1) residual = PF.ACPowerFlowResidual(data3, 1) - residual(x0, 1) + residual(data3, x0, 1) residualSize = norm(residual.Rv, 1) newx0 = deepcopy(x0) PF.dc_power_flow_start!(newx0, data, 1, residual) - residual(newx0, 1) + residual(data3, newx0, 1) newResidualSize = norm(residual.Rv, 1) @test x0 !== newx0 @test newResidualSize < residualSize diff --git a/test/test_jacobian.jl b/test/test_jacobian.jl index f4e4c8a5..7e2c8772 100644 --- a/test/test_jacobian.jl +++ b/test/test_jacobian.jl @@ -261,12 +261,12 @@ function _verify_area_jacobian(sys::PSY.System, label::String) @test PF.n_controlled_areas(data) >= 1 time_step = 1 residual = PF.ACPowerFlowResidual(data, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) + J = PF.ACPowerFlowJacobian(data, residual, time_step) x0 = PF.calculate_x0(data, time_step) Random.seed!(42) x0 .+= 0.02 .* randn(length(x0)) - residual(x0, time_step) - J(time_step) + residual(data, x0, time_step) + J(data, time_step) dcn = PF.get_dc_network(data) area_off = PF.area_tail_offset(data, dcn) @@ -277,7 +277,7 @@ function _verify_area_jacobian(sys::PSY.System, label::String) @test Jv[2 * area.slack_bus_ix - 1, area_off + area.tail_ix] == -1.0 end - verify_jacobian_asymptotic(residual, deepcopy(Jv), x0, time_step; label = label) + verify_jacobian_asymptotic(residual, data, deepcopy(Jv), x0, time_step; label = label) return end diff --git a/test/test_lcc_zero_setpoint.jl b/test/test_lcc_zero_setpoint.jl index 6831dca4..df05a8dd 100644 --- a/test/test_lcc_zero_setpoint.jl +++ b/test/test_lcc_zero_setpoint.jl @@ -23,10 +23,10 @@ end _ZERO_SP_FORMULATIONS data = PowerFlowData(PFType(), _zero_setpoint_lcc_system()) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) - jac(1) + residual(data, x0, 1) + jac(data, 1) # i_dc of the degenerate converter is exactly 0. @test iszero(data.lcc.i_dc[1, 1]) @@ -43,19 +43,19 @@ end ) in _ZERO_SP_FORMULATIONS data = PowerFlowData(PFType(), _zero_setpoint_lcc_system()) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x0 = PF.calculate_x0(data, 1) Random.seed!(1) x = x0 .+ 0.01 .* randn(length(x0)) - residual(x, 1) - jac(1) + residual(data, x, 1) + jac(data, 1) J = copy(Matrix(jac.Jv)) v = randn(length(x)) ε = 1e-6 - residual(x .+ ε .* v, 1) + residual(data, x .+ ε .* v, 1) Fp = copy(residual.Rv) - residual(x .- ε .* v, 1) + residual(data, x .- ε .* v, 1) Fm = copy(residual.Rv) fd = (Fp .- Fm) ./ (2ε) # The pinned tap rows (∂/∂tap = 1) must agree with FD just like every other row. @@ -67,14 +67,14 @@ end sys = _zero_setpoint_lcc_system() data = PowerFlowData(ACPowerFlow{NewtonRaphsonACPowerFlow}(; time_steps = 3), sys) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) - residual(PF.calculate_x0(data, 1), 1) - jac(1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) + residual(data, PF.calculate_x0(data, 1), 1) + jac(data, 1) sz = size(jac.Jv) nnz1 = SparseArrays.nnz(jac.Jv) for t in 2:3 - residual(PF.calculate_x0(data, t), t) - jac(t) + residual(data, PF.calculate_x0(data, t), t) + jac(data, t) @test size(jac.Jv) == sz @test SparseArrays.nnz(jac.Jv) == nnz1 end diff --git a/test/test_mixed_cpb_flat_start.jl b/test/test_mixed_cpb_flat_start.jl index 352d61a7..a3c62566 100644 --- a/test/test_mixed_cpb_flat_start.jl +++ b/test/test_mixed_cpb_flat_start.jl @@ -60,7 +60,7 @@ end PF.mixed_initial_state!( x0, data, residual.bus_state_offset, residual.bus_block_size, 1, ) - residual(x0, 1) + residual(data, x0, 1) # The hard fixture must actually trip the LARGE_RESIDUAL gate so the # enhanced flat start path is the one under test. @test norm(residual.Rv, 1) > PF.LARGE_RESIDUAL * length(residual.Rv) @@ -152,14 +152,14 @@ end PF.mixed_initial_state!( cold, data, residual.bus_state_offset, residual.bus_block_size, 2, ) - residual(cold, 2) + residual(data, cold, 2) cold_norm = norm(residual.Rv, 1) # Warm start: step-1 converged mixed state via the type/value split # (_mixed_fill_state! with type_ts=2, value_ts=1). warm = copy(cold) PF._mixed_fill_state!(warm, data, residual.bus_state_offset, 2, 1) - residual(warm, 2) + residual(data, warm, 2) warm_norm = norm(residual.Rv, 1) @test warm_norm < 0.1 * cold_norm @@ -185,10 +185,10 @@ end x0[off] = 0.0 x0[off + 1] = 0.0 - residual(x0, 1) + residual(data, x0, 1) @test all(isfinite, residual.Rv) - J = PF.ACMixedCPBJacobian(residual, 1) - J(1) + J = PF.ACMixedCPBJacobian(data, residual, 1) + J(data, 1) @test all(isfinite, SparseArrays.nonzeros(J.Jv)) end diff --git a/test/test_mixed_cpb_jacobian.jl b/test/test_mixed_cpb_jacobian.jl index 81ea471a..995062a8 100644 --- a/test/test_mixed_cpb_jacobian.jl +++ b/test/test_mixed_cpb_jacobian.jl @@ -7,8 +7,8 @@ x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) - J = PF.ACMixedCPBJacobian(R, 1) + R(data, x, 1) + J = PF.ACMixedCPBJacobian(data, R, 1) n_buses = first(size(data.bus_type)) n_lcc = size(data.lcc.p_set, 1) @@ -34,8 +34,8 @@ # finite-difference testset below. Random.seed!(123) x .+= 0.01 .* randn(length(x)) - R(x, 1) - J(1) + R(data, x, 1) + J(data, 1) J_second = copy(J.Jv) # Sparsity structure is fixed across iterations. @@ -73,10 +73,10 @@ end # Verify the analytic Jacobian by its asymptotic agreement with the # residual (O(Δx²) Taylor remainder), not a single fixed-tolerance # finite-difference snapshot. Mirrors test_rectangular_ci_jacobian.jl. - function _verify_mixed_jacobian(R, x, label) - R(x, 1) - J = PF.ACMixedCPBJacobian(R, 1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = label) + function _verify_mixed_jacobian(R, data, x, label) + R(data, x, 1) + J = PF.ACMixedCPBJacobian(data, R, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = label) end function _build_mixed_x(sys) @@ -87,23 +87,23 @@ end R = PF.ACMixedCPBResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - return R, x + return R, data, x end @testset "c_sys5" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys5") - R, x = _build_mixed_x(sys) + R, data, x = _build_mixed_x(sys) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - _verify_mixed_jacobian(R, x, "mixed CPB c_sys5") + _verify_mixed_jacobian(R, data, x, "mixed CPB c_sys5") end @testset "c_sys14" begin sys = PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false) - R, x = _build_mixed_x(sys) + R, data, x = _build_mixed_x(sys) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - _verify_mixed_jacobian(R, x, "mixed CPB c_sys14") + _verify_mixed_jacobian(R, data, x, "mixed CPB c_sys14") end @testset "ZIP load (P+I+Z combination)" begin @@ -122,7 +122,7 @@ end PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - _verify_mixed_jacobian(R, x, "mixed CPB ZIP") + _verify_mixed_jacobian(R, data, x, "mixed CPB ZIP") end function _build_mixed_lcc_x(sys; correct_bustypes = false) @@ -136,15 +136,15 @@ end R = PF.ACMixedCPBResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - return R, x + return R, data, x end @testset "LCC PQ terminals (simple_lcc_system)" begin sys, _ = simple_lcc_system() - R, x = _build_mixed_lcc_x(sys) + R, data, x = _build_mixed_lcc_x(sys) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - _verify_mixed_jacobian(R, x, "mixed CPB LCC PQ") + _verify_mixed_jacobian(R, data, x, "mixed CPB LCC PQ") end @testset "LCC PV terminal" begin @@ -153,10 +153,10 @@ end PSY.set_bustype!(b2, ACBusTypes.PV) PSY.set_magnitude!(b2, 1.05) _add_simple_thermal_standard!(sys, b2, 0.3, 0.0) - R, x = _build_mixed_lcc_x(sys) + R, data, x = _build_mixed_lcc_x(sys) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - _verify_mixed_jacobian(R, x, "mixed CPB LCC PV") + _verify_mixed_jacobian(R, data, x, "mixed CPB LCC PV") end @testset "LCC inverter-side setpoint" begin @@ -165,10 +165,10 @@ end sys, lcc = simple_lcc_system() PSY.set_inverter_extinction_angle!(lcc, 1.0) # interior, off ϕ clamp PSY.set_transfer_setpoint!(lcc, -0.5) # setpoint at inverter - R, x = _build_mixed_lcc_x(sys) + R, data, x = _build_mixed_lcc_x(sys) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - _verify_mixed_jacobian(R, x, "mixed CPB LCC inverter-side setpoint") + _verify_mixed_jacobian(R, data, x, "mixed CPB LCC inverter-side setpoint") end end @@ -183,9 +183,9 @@ end PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Random.seed!(2024) x .+= 1e-3 .* randn(length(x)) - R(x, 1) - J = PF.ACMixedCPBJacobian(R, 1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "mixed CPB two-swing") + R(data, x, 1) + J = PF.ACMixedCPBJacobian(data, R, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = "mixed CPB two-swing") end @testset "Mixed CPB Jacobian: zero allocation per Newton iteration" begin @@ -197,8 +197,8 @@ end x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) - return PF.ACMixedCPBJacobian(R, 1) + R(data, x, 1) + return PF.ACMixedCPBJacobian(data, R, 1), data end function _build_rect_J(sys) pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}() @@ -207,18 +207,18 @@ end x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) - return PF.ACRectangularCIJacobian(R, 1) + R(data, x, 1) + return PF.ACRectangularCIJacobian(data, R, 1), data end function _check_zero_alloc(sys, label) @testset "$label" begin - J = _build_mixed_J(sys) - J(1) # warm-up (JIT) - a_mixed = @allocated J(1) - Jr = _build_rect_J(sys) - Jr(1) # warm-up (JIT) - a_rect = @allocated Jr(1) + J, data = _build_mixed_J(sys) + J(data, 1) # warm-up (JIT) + a_mixed = @allocated J(data, 1) + Jr, data_r = _build_rect_J(sys) + Jr(data_r, 1) # warm-up (JIT) + a_rect = @allocated Jr(data_r, 1) # Measured (c_sys5 & c_sys14, neither has an LCC): # mixed @allocated J(1) == 80, rect @allocated J(1) == 80. # The inner `_update_mixed_cpb_jacobian_values!` is verified @@ -252,5 +252,5 @@ end linSolveCache = PF.make_linear_solver_cache(PF.PNM.KLUSolver(), J.Jv) PF.symbolic_factor!(linSolveCache, J.Jv) stateVector = PF.StateVectorCache(x0, R.Rv) - @test_nowarn PF._simple_step(1, stateVector, linSolveCache, R, J) + @test_nowarn PF._simple_step(1, stateVector, linSolveCache, R, J, data) end diff --git a/test/test_mixed_cpb_lcc.jl b/test/test_mixed_cpb_lcc.jl index 7a1e8beb..4639937e 100644 --- a/test/test_mixed_cpb_lcc.jl +++ b/test/test_mixed_cpb_lcc.jl @@ -25,7 +25,7 @@ function _mixed_lcc_residual_norm( x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Rv = similar(x) - R(Rv, x, 1) + R(data, Rv, x, 1) return LinearAlgebra.norm(Rv, Inf) end diff --git a/test/test_mixed_cpb_residual.jl b/test/test_mixed_cpb_residual.jl index 76aaea1f..c21acd56 100644 --- a/test/test_mixed_cpb_residual.jl +++ b/test/test_mixed_cpb_residual.jl @@ -14,7 +14,7 @@ x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Rv = similar(x) - R(Rv, x, 1) + R(data, Rv, x, 1) @test LinearAlgebra.norm(Rv, Inf) < 1e-6 end @@ -28,7 +28,7 @@ x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Rv = similar(x) - R(Rv, x, 1) + R(data, Rv, x, 1) @test LinearAlgebra.norm(Rv, Inf) < 1e-6 end @@ -51,7 +51,7 @@ x = Vector{Float64}(undef, length(R.Rv)) PF.mixed_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Rv = similar(x) - R(Rv, x, 1) + R(data, Rv, x, 1) @test LinearAlgebra.norm(Rv, Inf) < 1e-6 end end diff --git a/test/test_nested_reduction_flow_reporting.jl b/test/test_nested_reduction_flow_reporting.jl index 824904ec..f0d8fd44 100644 --- a/test/test_nested_reduction_flow_reporting.jl +++ b/test/test_nested_reduction_flow_reporting.jl @@ -52,7 +52,7 @@ function _nested_reduction_system(shape::Symbol) x = x, b = (from = 0.0, to = 0.0), rating = 4.0, - angle_limits = (min = -pi, max = pi), + angle_limits = (min = -pi, max = pi), input_basis = PSY.CU, ), ) end @@ -86,7 +86,7 @@ function _nested_reduction_system(shape::Symbol) reactive_power = 0.2, base_power = 100.0, max_active_power = 1.0, - max_reactive_power = 0.2, + max_reactive_power = 0.2, input_basis = PSY.CU, ), ) PSY.add_component!( @@ -106,7 +106,7 @@ function _nested_reduction_system(shape::Symbol) base_power = 100.0, time_limits = nothing, prime_mover_type = PSY.PrimeMovers.OT, - fuel = PSY.ThermalFuels.OTHER, + fuel = PSY.ThermalFuels.OTHER, input_basis = PSY.CU, ), ) return sys diff --git a/test/test_nr_cache_reuse.jl b/test/test_nr_cache_reuse.jl index 87e758b4..09aade12 100644 --- a/test/test_nr_cache_reuse.jl +++ b/test/test_nr_cache_reuse.jl @@ -139,18 +139,18 @@ end col = 2 * ix - 1 residual = PowerFlows.ACPowerFlowResidual(data, 1) - jac = PowerFlows.ACPowerFlowJacobian(residual, 1) + jac = PowerFlows.ACPowerFlowJacobian(data, residual, 1) x = PowerFlows.calculate_x0(data, 1) - residual(x, 1) - jac(1) + residual(data, x, 1) + jac(data, 1) @test jac.Jv[vk, col] == 0.0 # fresh PV build: structural zero data.bus_type[ix, 1] = PSY.ACBusTypes.PQ - jac(1) + jac(data, 1) @test jac.Jv[vk, col] != 0.0 # PQ: the loss-coupling derivative now enters data.bus_type[ix, 1] = PSY.ACBusTypes.PV - jac(1) # refilled IN PLACE, same Jacobian object, as a reused cache would do + jac(data, 1) # refilled IN PLACE, same Jacobian object, as a reused cache would do @test jac.Jv[vk, col] == 0.0 # must match a fresh PV build, not the stale PQ value end @@ -161,10 +161,10 @@ end pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) data = PowerFlowData(pf, sys) residual = PowerFlows.ACPowerFlowResidual(data, 1) - jac = PowerFlows.ACPowerFlowJacobian(residual, 1) + jac = PowerFlows.ACPowerFlowJacobian(data, residual, 1) x = PowerFlows.calculate_x0(data, 1) - residual(x, 1) - jac(1) + residual(data, x, 1) + jac(data, 1) M = PowerFlows._build_singular_J_fallback(jac.Jv, x) F = jac.Jv' * jac.Jv @@ -223,17 +223,19 @@ end end end -@testset "Polar residual and Jacobian hold a concretely typed data field" begin +@testset "solver objects do not store data" begin + for T in (PF.ACPowerFlowResidual, PF.ACPowerFlowJacobian, PF.ACRectangularCIResidual, + PF.ACRectangularCIJacobian, PF.ACMixedCPBResidual, PF.ACMixedCPBJacobian, + PF.HomotopyHessian) + @test !hasfield(T, :data) + end sys = PSB.build_system(PSB.PSITestSystems, "c_sys14") - pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) - data = PowerFlowData(pf, sys) + data = PowerFlowData(ACPowerFlow{NewtonRaphsonACPowerFlow}(), sys) residual = PowerFlows.ACPowerFlowResidual(data, 1) - J = PowerFlows.ACPowerFlowJacobian(residual, 1) - @test isconcretetype(fieldtype(typeof(residual), :data)) - @test isconcretetype(fieldtype(typeof(J), :data)) + J = PowerFlows.ACPowerFlowJacobian(data, residual, 1) x0 = PowerFlows.calculate_x0(data, 1) - residual(x0, 1) - J(1) - @test (@allocated residual(x0, 1)) == 0 - @test (@allocated J(1)) == 0 + residual(data, x0, 1) + J(data, 1) + @test (@allocated residual(data, x0, 1)) == 0 + @test (@allocated J(data, 1)) == 0 end diff --git a/test/test_pardiso_backend.jl b/test/test_pardiso_backend.jl index 6a5e62d5..003945fd 100644 --- a/test/test_pardiso_backend.jl +++ b/test/test_pardiso_backend.jl @@ -55,8 +55,8 @@ import Pardiso pf = ACPowerFlow{NewtonRaphsonACPowerFlow}() data = PF.PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(data, 1) - J = PF.ACPowerFlowJacobian(residual, 1) - J(1) + J = PF.ACPowerFlowJacobian(data, residual, 1) + J(data, 1) cache = PF.make_linear_solver_cache(PF.PNM.MKLPardisoSolver(), J.Jv) PF.full_factor!(cache, J.Jv) b = randn(size(J.Jv, 1)) @@ -77,8 +77,8 @@ import Pardiso pf = ACPowerFlow{NewtonRaphsonACPowerFlow}() data = PF.PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(data, 1) - J = PF.ACPowerFlowJacobian(residual, 1) - J(1) + J = PF.ACPowerFlowJacobian(data, residual, 1) + J(data, 1) # Constructing the cache must fail with a clear error (never a crash/segfault) # when MKL is unusable — the functional guard runs before any MKL ccall. @test_throws ErrorException PF.make_linear_solver_cache( diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index de5a2e13..36f4626d 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -580,7 +580,7 @@ end dc_control_from = PSY.VSCDCControlModes.DC_VOLTAGE, dc_setpoint_from = 1.0, dc_control_to = PSY.VSCDCControlModes.DC_POWER, - dc_setpoint_to = 0.2, + dc_setpoint_to = 0.2, input_basis = PSY.CU, ), ) export_location = joinpath(test_psse_export_dir, "v33", "case16_vsc_no_ext") @@ -609,7 +609,7 @@ end voltage_setpoint = 1.0, regulated_bus_number = 7, reactive_power_required = 42.0, # solved output; must NOT be written as RMPCT - ext = Dict{String, Any}("RMPCT" => 55.0), # stale ext; the exporter must ignore it + ext = Dict{String, Any}("RMPCT" => 55.0), input_basis = PSY.CU, # stale ext; the exporter must ignore it ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the MVA input. @@ -713,7 +713,8 @@ end base_power = 100.0, control_objective = PSY.TransformerControlObjective.ACTIVE_POWER_FLOW, control_limits = (min = deg2rad(-30), max = deg2rad(30)), - ), + input_basis = PSY.CU, + ), input_basis = PSY.CU, ) PSY.add_component!(sys, tx) @@ -841,7 +842,7 @@ end line = Line(; name = "L", available = true, active_power_flow = 0.0, reactive_power_flow = 0.0, arc = Arc(; from = b1, to = b2), r = 0.01, x = 0.1, b = (from = 0.0, to = 0.0), rating = 1.0, - angle_limits = (min = -pi / 2, max = pi / 2)) + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU) add_component!(sys, line) export_location = joinpath(test_psse_export_dir, "v35", "issue361_missing_rate_keys") @@ -1161,7 +1162,7 @@ end active_power_limits_to = (min = -100.0, max = 100.0), reactive_power_limits_from = (min = 0.0, max = 0.0), reactive_power_limits_to = (min = 0.0, max = 0.0), - base_power = 100.0, + base_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, hvdc) @@ -1238,7 +1239,7 @@ end x = 0.01, rating = 12.06, # 1206 MVA on a 100 MVA system base discrete_branch_type = DiscreteControlledBranchType.BREAKER, - branch_status = DiscreteControlledBranchStatus.CLOSED, + branch_status = DiscreteControlledBranchStatus.CLOSED, input_basis = PSY.CU, ) add_component!(sys, sw) @test PSY.get_rating(sw, PSY.NU) ≈ 1206.0 diff --git a/test/test_rectangular_ci_jacobian.jl b/test/test_rectangular_ci_jacobian.jl index 02d7604f..85bf61f4 100644 --- a/test/test_rectangular_ci_jacobian.jl +++ b/test/test_rectangular_ci_jacobian.jl @@ -6,16 +6,16 @@ pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}() data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) # Avoid verifying at the special converged state — see note in # verify_jacobian (test_jacobian.jl) about hidden zeros. Random.seed!(42) x .+= 0.02 .* randn(length(x)) - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "rect CI c_sys5") + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = "rect CI c_sys5") end @testset "c_sys14 at polar-converged + perturbation" begin @@ -25,14 +25,14 @@ pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}() data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Random.seed!(42) x .+= 0.02 .* randn(length(x)) - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "rect CI c_sys14") + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = "rect CI c_sys14") end @testset "ZIP constant-current load at perturbed state" begin @@ -56,14 +56,21 @@ ) data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Random.seed!(7) x .+= 0.02 .* randn(length(x)) - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "rect CI ZIP perturbed") + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic( + R, + data, + copy(J.Jv), + x, + 1; + label = "rect CI ZIP perturbed", + ) end @testset "c_sys5 at perturbed (non-converged) state" begin @@ -73,14 +80,21 @@ pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}() data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) Random.seed!(42) x .+= 0.05 .* randn(length(x)) - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "rect CI c_sys5 perturbed") + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic( + R, + data, + copy(J.Jv), + x, + 1; + label = "rect CI c_sys5 perturbed", + ) end end @@ -91,17 +105,17 @@ end pf_rect = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}() data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) - J(1) + R(data, x, 1) + J(data, 1) J_first = copy(J.Jv) Random.seed!(123) x .+= 0.01 .* randn(length(x)) - R(x, 1) - J(1) + R(data, x, 1) + J(data, 1) J_second = copy(J.Jv) # For non-REF, non-PV-Q columns at off-diagonal block positions, Y_bus entries @@ -126,14 +140,14 @@ end solution_parameters = _rect_pf_settings()) data = PF.PowerFlowData(pf_rect, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) # Avoid the special converged state (see verify_jacobian note in # test_jacobian.jl about hidden zeros). Random.seed!(42) x .+= 0.01 .* randn(length(x)) - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "rect CI two-swing") + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = "rect CI two-swing") end diff --git a/test/test_rectangular_ci_lcc.jl b/test/test_rectangular_ci_lcc.jl index 29529916..64910905 100644 --- a/test/test_rectangular_ci_lcc.jl +++ b/test/test_rectangular_ci_lcc.jl @@ -13,7 +13,7 @@ end R = PF.ACRectangularCIResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) + R(data, x, 1) @test LinearAlgebra.norm(R.Rv, Inf) < 1e-7 end @@ -22,16 +22,16 @@ function _rect_lcc_verify(sys::System; label::String, perturbation::Float64 = 0. correct_bustypes = true, solution_parameters = _rect_lcc_settings()) data = PF.PowerFlowData(pf_r, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) if perturbation > 0 Random.seed!(42) x .+= perturbation .* randn(length(x)) end - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = label) + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = label) end @testset "Rectangular CI LCC: asymptotic verification, nonzero xc (interior)" begin @@ -101,7 +101,7 @@ end solution_parameters = _rect_lcc_settings()) data = PF.PowerFlowData(pf_r, sys) R = PF.ACRectangularCIResidual(data, 1) - J = PF.ACRectangularCIJacobian(R, 1) + J = PF.ACRectangularCIJacobian(data, R, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) # Verify away from the converged state. NB: case5_2_lcc has x_t = 0 for @@ -111,9 +111,9 @@ end # exercise the α-vs-true-ϕ divergence properly. Random.seed!(42) x .+= 0.02 .* randn(length(x)) - R(x, 1) - J(1) - verify_jacobian_asymptotic(R, copy(J.Jv), x, 1; label = "rect CI LCC case5_2") + R(data, x, 1) + J(data, 1) + verify_jacobian_asymptotic(R, data, copy(J.Jv), x, 1; label = "rect CI LCC case5_2") end @testset "Rectangular CI LCC: solve parity with polar" begin diff --git a/test/test_rectangular_ci_power_flow.jl b/test/test_rectangular_ci_power_flow.jl index 299cbb5c..ba38da5a 100644 --- a/test/test_rectangular_ci_power_flow.jl +++ b/test/test_rectangular_ci_power_flow.jl @@ -336,8 +336,8 @@ end x[off] = 0.0 x[off + 1] = 0.0 - residual(x, 1) - J(1) + residual(data, x, 1) + J(data, 1) @test all(isfinite, residual.Rv) @test all(isfinite, J.Jv.nzval) end diff --git a/test/test_rectangular_ci_residual.jl b/test/test_rectangular_ci_residual.jl index 8d6932ae..1b1a1d82 100644 --- a/test/test_rectangular_ci_residual.jl +++ b/test/test_rectangular_ci_residual.jl @@ -8,7 +8,7 @@ R = PF.ACRectangularCIResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) + R(data, x, 1) @test LinearAlgebra.norm(R.Rv, Inf) < 1e-7 end @@ -21,7 +21,7 @@ R = PF.ACRectangularCIResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) + R(data, x, 1) @test LinearAlgebra.norm(R.Rv, Inf) < 1e-7 end end @@ -36,7 +36,7 @@ end R = PF.ACRectangularCIResidual(data, 1) x = Vector{Float64}(undef, length(R.Rv)) PF.rect_initial_state!(x, data, R.bus_state_offset, R.bus_block_size, 1) - R(x, 1) + R(data, x, 1) # Something to converge from @test LinearAlgebra.norm(R.Rv, Inf) > 1e-3 end diff --git a/test/test_rectangular_ci_setup.jl b/test/test_rectangular_ci_setup.jl index 418c9773..c47ca514 100644 --- a/test/test_rectangular_ci_setup.jl +++ b/test/test_rectangular_ci_setup.jl @@ -112,12 +112,12 @@ end PF.rect_initial_state!( x0, data, residual.bus_state_offset, residual.bus_block_size, 1, ) - residual(x0, 1) + residual(data, x0, 1) base_norm = norm(residual.Rv, 1) # A deliberately worse candidate must be rejected (x0 unchanged). worse = x0 .+ 5.0 - PF._pick_better_x0(x0, worse, 1, residual, "unit test worse candidate") - residual(x0, 1) + PF._pick_better_x0(x0, worse, 1, residual, data, "unit test worse candidate") + residual(data, x0, 1) @test norm(residual.Rv, 1) ≤ base_norm @test x0 != worse end diff --git a/test/test_reduced_ac_power_flow.jl b/test/test_reduced_ac_power_flow.jl index 27548a3b..a8539206 100644 --- a/test/test_reduced_ac_power_flow.jl +++ b/test/test_reduced_ac_power_flow.jl @@ -446,14 +446,14 @@ function _zir_merge_test_sys(lines, nbus) Line(; name = name, available = true, active_power_flow = 0.0, reactive_power_flow = 0.0, arc = arc, r = r, x = x, b = (from = 0.0, to = 0.0), rating = 4.0, - angle_limits = (min = -pi, max = pi)), + angle_limits = (min = -pi, max = pi), input_basis = PSY.CU), ) end add_component!( sys, PowerLoad(; name = "zload2", available = true, bus = buses[2], active_power = 1.0, reactive_power = 0.2, base_power = 100.0, - max_active_power = 1.0, max_reactive_power = 0.2), + max_active_power = 1.0, max_reactive_power = 0.2, input_basis = PSY.CU), ) add_component!( sys, @@ -464,7 +464,7 @@ function _zir_merge_test_sys(lines, nbus) reactive_power_limits = (min = -5.0, max = 5.0), ramp_limits = nothing, operation_cost = ThermalGenerationCost(nothing), base_power = 100.0, time_limits = nothing, prime_mover_type = PrimeMovers.OT, - fuel = ThermalFuels.OTHER), + fuel = ThermalFuels.OTHER, input_basis = PSY.CU), ) return sys end diff --git a/test/test_reduced_dc_power_flow.jl b/test/test_reduced_dc_power_flow.jl index 7d9fb502..e2276ebd 100644 --- a/test/test_reduced_dc_power_flow.jl +++ b/test/test_reduced_dc_power_flow.jl @@ -80,7 +80,7 @@ end x = 0.2, b = (from = 0.0, to = 0.0), rating = 0.0, - angle_limits = (min = -pi, max = pi), + angle_limits = (min = -pi, max = pi), input_basis = PSY.CU, ) line_4_6 = Line(; name = "l_4_6", @@ -92,7 +92,7 @@ end x = 0.2, b = (from = 0.0, to = 0.0), rating = 0.0, - angle_limits = (min = -pi, max = pi), + angle_limits = (min = -pi, max = pi), input_basis = PSY.CU, ) add_component!(sys, line_2_6) add_component!(sys, line_4_6) diff --git a/test/test_residual_condition_diagnostics.jl b/test/test_residual_condition_diagnostics.jl index 08c0c5a4..aabf306f 100644 --- a/test/test_residual_condition_diagnostics.jl +++ b/test/test_residual_condition_diagnostics.jl @@ -11,10 +11,10 @@ const _KLU_SETTINGS = SolutionParameters(; linear_solver = "KLU") function _schur_eig_and_truth(pf, sys; time_step = 1, backend = PNM.KLUSolver()) data = PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(data, time_step) - jac = PF.ACPowerFlowJacobian(residual, time_step) + jac = PF.ACPowerFlowJacobian(data, residual, time_step) x0 = PF.calculate_x0(data, time_step) - residual(x0, time_step) - jac(time_step) + residual(data, x0, time_step) + jac(data, time_step) cache = PF.make_linear_solver_cache(backend, jac.Jv) PF.symbolic_factor!(cache, jac.Jv) @@ -188,10 +188,10 @@ function _singular_matrix_family(; backend = PNM.KLUSolver()) pf = ACPowerFlow{NewtonRaphsonACPowerFlow}(; correct_bustypes = true) data = PF.PowerFlowData(pf, sys) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x0 = PF.calculate_x0(data, 1) - residual(x0, 1) - jac(1) + residual(data, x0, 1) + jac(data, 1) n = size(jac.Jv, 1) # An odd shift keeps every diagonal entry structurally stored (a cancelling @@ -465,7 +465,7 @@ end # Both trigger conditions must hold, or this test would pass for the wrong reason. x0 = PF.calculate_x0(data, 1) - residual(x0, 1) + residual(data, x0, 1) @test sum(abs, residual.Rv) > PF.LARGE_RESIDUAL * length(residual.Rv) @test argmax(abs.(residual.Rv)) > n_bus_eqs diff --git a/test/test_solve_power_flow.jl b/test/test_solve_power_flow.jl index b6ee668b..92848c7b 100644 --- a/test/test_solve_power_flow.jl +++ b/test/test_solve_power_flow.jl @@ -364,9 +364,9 @@ end # way `_calculate_voltage_stability_factors` does internally. residual = PF.ACPowerFlowResidual(data_newton, time_step) x_solved = PF.calculate_x0(data_newton, time_step) - residual(x_solved, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) - J(time_step) + residual(data_newton, x_solved, time_step) + J = PF.ACPowerFlowJacobian(data_newton, residual, time_step) + J(data_newton, time_step) rows, cols = PowerFlows._block_J_indices(pvpq, pq) J_block = J.Jv[rows, cols] @@ -626,8 +626,8 @@ end rating = 1.0, base_power = 100.0, base_voltage_primary = 230, - base_voltage_secondary = 110, - ), + base_voltage_secondary = 110, input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, t) diff --git a/test/test_state_indexing_helpers.jl b/test/test_state_indexing_helpers.jl index e9f1b55c..82418205 100644 --- a/test/test_state_indexing_helpers.jl +++ b/test/test_state_indexing_helpers.jl @@ -191,6 +191,6 @@ end # leaves an error of exactly those withdrawals, so this fails on unfixed code. x = PF.calculate_x0(data, time_step) residual = PF.ACPowerFlowResidual(data, time_step) - residual(x, time_step) + residual(data, x, time_step) @test norm(residual.Rv, Inf) < 1e-8 end diff --git a/test/test_utils/common.jl b/test/test_utils/common.jl index 0c6385ac..a0f37d4c 100644 --- a/test/test_utils/common.jl +++ b/test/test_utils/common.jl @@ -252,7 +252,7 @@ function _add_simple_load!( reactive_power = Float64(reactive_power), # Per-unitized by device base_power base_power = 1.0, # MVA max_active_power = 100.0, # 10 MW per-unitized by device base_power - max_reactive_power = 100.0, + max_reactive_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, load) @@ -276,7 +276,7 @@ function _add_simple_source!( active_power = Float64(active_power), reactive_power = Float64(reactive_power), R_th = 1e-5, - X_th = 1e-5, + X_th = 1e-5, input_basis = PSY.CU, ) add_component!(sys, source) return source @@ -310,7 +310,7 @@ function _add_simple_thermal_standard!( fuel = ThermalFuels.OTHER, services = Device[], dynamic_injector = nothing, - ext = Dict{String, Any}(), + ext = Dict{String, Any}(), input_basis = PSY.CU, ) add_component!(sys, gen) return gen @@ -338,7 +338,7 @@ function _add_simple_line!( x = x, b = (from = b / 2, to = b / 2), rating = 1.0, - angle_limits = (min = -pi / 2, max = pi / 2), + angle_limits = (min = -pi / 2, max = pi / 2), input_basis = PSY.CU, ) add_component!(sys, line) return line @@ -372,7 +372,7 @@ function _add_simple_transformer_3w!( r = r, x = x, rating = 1.0, - base_power = 100.0, + base_power = 100.0, input_basis = PSY.CU, ) end xfmr = ThreeWindingTransformer(; @@ -383,7 +383,7 @@ function _add_simple_transformer_3w!( primary_circuit = _star_circuit(bus_p, r_primary, x_primary, true), secondary_circuit = _star_circuit(bus_s, r_secondary, x_secondary, true), tertiary_circuit = _star_circuit(bus_t, r_tertiary, x_tertiary, available_tertiary), - star_bus = star_bus, + star_bus = star_bus, input_basis = PSY.CU, ) add_component!(sys, xfmr) return xfmr @@ -418,7 +418,7 @@ function _add_simple_zip_load!( max_impedance_active_power = 0.0, max_impedance_reactive_power = 0.0, max_current_active_power = 0.0, - max_current_reactive_power = 0.0, + max_current_reactive_power = 0.0, input_basis = PSY.CU, ) add_component!(sys, zip_load) return zip_load @@ -466,7 +466,7 @@ function _add_simple_vsc!( rating_to = 1.0, reactive_power_limits_to = (min = -1.0, max = 1.0), power_factor_weighting_fraction_to = 0.0, - voltage_limits_to = (min = 0.9, max = 1.1), + voltage_limits_to = (min = 0.9, max = 1.1), input_basis = PSY.CU, ) add_component!(sys, vsc) return vsc @@ -518,7 +518,7 @@ function _add_simple_lcc!( active_power_limits_from = (min = 0.0, max = 0.0), active_power_limits_to = (min = 0.0, max = 0.0), reactive_power_limits_from = (min = 0.0, max = 0.0), - reactive_power_limits_to = (min = 0.0, max = 0.0), + reactive_power_limits_to = (min = 0.0, max = 0.0), input_basis = PSY.CU, ) add_component!(sys, lcc) return lcc @@ -577,8 +577,8 @@ function _make_tap_shunt_system() rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, - controlled_quantity_limits = (min = 1.0, max = 1.0), - ), + controlled_quantity_limits = (min = 1.0, max = 1.0), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, tx) sa = SwitchedAdmittance(; @@ -624,7 +624,7 @@ function _make_solvable_tap_shunt_system() reactive_power = 0.25, base_power = 100.0, max_active_power = 100.0, - max_reactive_power = 100.0, + max_reactive_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, load2) load3 = PowerLoad(; @@ -635,7 +635,7 @@ function _make_solvable_tap_shunt_system() reactive_power = 0.025, base_power = 100.0, max_active_power = 100.0, - max_reactive_power = 100.0, + max_reactive_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, load3) # Bus 3 connected to REF bus; decoupled from bus 2. @@ -652,8 +652,8 @@ function _make_solvable_tap_shunt_system() rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, - controlled_quantity_limits = (min = 1.0, max = 1.0), - ), + controlled_quantity_limits = (min = 1.0, max = 1.0), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, tx) sa = SwitchedAdmittance(; @@ -694,7 +694,7 @@ function _make_svc_system(; reactive_power = reactive_load, base_power = 100.0, max_active_power = 100.0, - max_reactive_power = 100.0, + max_reactive_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, load2) svc = FACTSControlDevice(; @@ -703,7 +703,7 @@ function _make_svc_system(; bus = b2, control_mode = control_mode, voltage_setpoint = 1.0, - reactive_power_required = 100.0, + reactive_power_required = 100.0, input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the caller's MVA input. @@ -727,7 +727,7 @@ function _add_mp_load!( reactive_power = reactive_power, base_power = 100.0, max_active_power = 100.0, - max_reactive_power = 100.0, + max_reactive_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, load) return load @@ -889,7 +889,7 @@ function _make_multiperiod_facts_system() control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = 1.0, shunt_control_type = PSY.FACTSShuntControlType.SVC, - reactive_power_required = 100.0, + reactive_power_required = 100.0, input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the MVA input. @@ -931,7 +931,8 @@ function _make_multiperiod_tap_system() regulated_bus_number = 2, controlled_quantity_limits = (min = 1.0, max = 1.0), control_objective = PSY.TransformerControlObjective.VOLTAGE, - ), + input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, tx) return sys @@ -973,7 +974,7 @@ function _make_shunt_snap_system() reactive_power = 0.28, base_power = 100.0, max_active_power = 100.0, - max_reactive_power = 100.0, + max_reactive_power = 100.0, input_basis = PSY.CU, ) add_component!(sys, load2) sa = SwitchedAdmittance(; @@ -1016,7 +1017,8 @@ function _make_field_controlled_tap_system() regulated_bus_number = 3, controlled_quantity_limits = (min = 1.02, max = 1.02), control_objective = PSY.TransformerControlObjective.VOLTAGE, - ), + input_basis = PSY.CU, + ), input_basis = PSY.CU, ) add_component!(sys, tx) return sys @@ -1049,7 +1051,7 @@ function _add_facts_shunt!( bus = b, control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = voltage_setpoint, - reactive_power_required = 100.0, + reactive_power_required = 100.0, input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the caller's MVA input. @@ -1125,8 +1127,8 @@ function _make_primary_controlled_tap_system() base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, controlled_quantity_limits = (min = 1.0, max = 1.0), - regulated_bus_number = 2, # controlled bus = bus 2 (FROM) → primary - ), + regulated_bus_number = 2, input_basis = PSY.CU, # controlled bus = bus 2 (FROM) → primary + ), input_basis = PSY.CU, ) add_component!(sys, tx) return sys @@ -1286,8 +1288,8 @@ function _add_control_tap!(sys, from_bus, to_bus; name = "tap_ctrl") rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, - controlled_quantity_limits = (min = 1.0, max = 1.0), - ), + controlled_quantity_limits = (min = 1.0, max = 1.0), input_basis = PSY.CU, + ), input_basis = PSY.CU, ) PSY.add_component!(sys, tx) return tx @@ -1344,7 +1346,7 @@ function _build_vsc_pq_system(; ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, dc_setpoint_to = p_set, reactive_power_to = q_set, - vsc_kwargs..., + vsc_kwargs..., input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) return sys @@ -1396,7 +1398,7 @@ function _build_mtdc_system() base_power = 100.0, dc_control = configs[k].dc_control, ac_control = PSY.VSCACControlModes.AC_REACTIVE_POWER, - dc_setpoint = configs[k].dc_setpoint, + dc_setpoint = configs[k].dc_setpoint, input_basis = PSY.CU, ) PSY.add_component!(sys, ic) end @@ -1414,7 +1416,7 @@ function _build_mtdc_system() l = 0.0, c = 0.0, active_power_limits_from = (min = -5.0, max = 5.0), - active_power_limits_to = (min = -5.0, max = 5.0), + active_power_limits_to = (min = -5.0, max = 5.0), input_basis = PSY.CU, ) PSY.add_component!(sys, dcl) end @@ -1425,8 +1427,8 @@ end exactly three branches (Trans1 4-9, Trans2 5-6, Trans3 4-7) -- small and hand-checkable.""" function _make_two_area_system() sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys14"; add_forecasts = false)) - area1 = PSY.Area(; name = "Area1") - area2 = PSY.Area(; name = "Area2") + area1 = PSY.Area(; name = "Area1", input_basis = PSY.CU) + area2 = PSY.Area(; name = "Area2", input_basis = PSY.CU) PSY.add_component!(sys, area1) PSY.add_component!(sys, area2) area1_buses = Set(1:5) @@ -1445,7 +1447,7 @@ Area3 (bus 9 has boundary degree 4: Trans1, Line11, Line12, Line16). Full bounda Trans1, Trans2, Trans3, Line11, Line12, Line16.""" function _make_three_area_system() sys = _make_two_area_system() - area3 = PSY.Area(; name = "Area3") + area3 = PSY.Area(; name = "Area3", input_basis = PSY.CU) PSY.add_component!(sys, area3) bus9 = PSY.get_component(PSY.ACBus, sys, "Bus 9") PSY.set_area!(bus9, area3) @@ -1458,9 +1460,9 @@ it genuinely spans both -- exercises enrollment guard 4 via the real production a fabricated dict.""" function _make_two_island_spanning_area_system() sys = System(100.0) - home1 = PSY.Area(; name = "Home1") - home2 = PSY.Area(; name = "Home2") - span = PSY.Area(; name = "Span") + home1 = PSY.Area(; name = "Home1", input_basis = PSY.CU) + home2 = PSY.Area(; name = "Home2", input_basis = PSY.CU) + span = PSY.Area(; name = "Span", input_basis = PSY.CU) PSY.add_component!(sys, home1) PSY.add_component!(sys, home2) PSY.add_component!(sys, span) @@ -1496,9 +1498,9 @@ lcc_metered_end picks rectifier- vs inverter-metered DC tie. """ function _comprehensive_area_dc_fixture(; lcc_metered_end::String = "from") sys = System(100.0) - area1 = PSY.Area(; name = "Area1") - area2 = PSY.Area(; name = "Area2") - area3 = PSY.Area(; name = "Area3") + area1 = PSY.Area(; name = "Area1", input_basis = PSY.CU) + area2 = PSY.Area(; name = "Area2", input_basis = PSY.CU) + area3 = PSY.Area(; name = "Area3", input_basis = PSY.CU) PSY.add_component!(sys, area1) PSY.add_component!(sys, area2) PSY.add_component!(sys, area3) @@ -1573,7 +1575,7 @@ function _comprehensive_area_dc_fixture(; lcc_metered_end::String = "from") x = 0.05, rating = 1.0, discrete_branch_type = PSY.DiscreteControlledBranchType.BREAKER, - branch_status = PSY.DiscreteControlledBranchStatus.CLOSED, + branch_status = PSY.DiscreteControlledBranchStatus.CLOSED, input_basis = PSY.CU, ) PSY.add_component!(sys, sw) @@ -1612,7 +1614,7 @@ function _comprehensive_area_dc_fixture(; lcc_metered_end::String = "from") dc_control_to = PSY.VSCDCControlModes.DC_POWER, ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, dc_setpoint_to = 0.2, - reactive_power_to = 0.05, + reactive_power_to = 0.05, input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) diff --git a/test/test_utils/cross_file_fixtures.jl b/test/test_utils/cross_file_fixtures.jl index 1d1452e2..2dd23369 100644 --- a/test/test_utils/cross_file_fixtures.jl +++ b/test/test_utils/cross_file_fixtures.jl @@ -34,7 +34,7 @@ function _add_area_interchange!( active_power_flow = flow, from_area = PSY.get_component(PSY.Area, sys, from_name), to_area = PSY.get_component(PSY.Area, sys, to_name), - flow_limits = (from_to = 0.0, to_from = 0.0), + flow_limits = (from_to = 0.0, to_from = 0.0), input_basis = PSY.CU, ), ) return @@ -59,7 +59,7 @@ function _three_area_transfer_fixture(; slack_area3::Bool = true) reactive_power_limits = (min = -1.0, max = 1.0), ramp_limits = nothing, operation_cost = PSY.ThermalGenerationCost(nothing), - base_power = 100.0, + base_power = 100.0, input_basis = PSY.CU, ) PSY.add_component!(sys, gen9) _set_slack!(sys, "Bus 6") @@ -75,8 +75,8 @@ end # needed here. function _make_3w_boundary_fixture() sys = System(100.0) - area_a = PSY.Area(; name = "AreaA") - area_b = PSY.Area(; name = "AreaB") + area_a = PSY.Area(; name = "AreaA", input_basis = PSY.CU) + area_b = PSY.Area(; name = "AreaB", input_basis = PSY.CU) PSY.add_component!(sys, area_a) PSY.add_component!(sys, area_b) @@ -124,7 +124,7 @@ function verify_jacobian( data = PF.PowerFlowData(pf, sys) time_step = 1 residual = PF.ACPowerFlowResidual(data, time_step) - J = PF.ACPowerFlowJacobian(residual, time_step) + J = PF.ACPowerFlowJacobian(data, residual, time_step) x0 = PF.calculate_x0(data, time_step) # Verify away from the flat-start state. At flat start θ=0 for every bus, # which silently zeroes all `sin(Δθ)` cross-terms — a sign flip in the @@ -134,10 +134,10 @@ function verify_jacobian( Random.seed!(seed) x0 .+= perturbation .* randn(length(x0)) end - residual(x0, time_step) - J(time_step) + residual(data, x0, time_step) + J(data, time_step) verify_jacobian_asymptotic( - residual, deepcopy(J.Jv), x0, time_step; label = label, + residual, data, deepcopy(J.Jv), x0, time_step; label = label, ) end @@ -191,7 +191,7 @@ function build_lcc_control_system(; p_set_mw::Union{Nothing, Float64} = nothing) max_shunt_current = 1000.0, max_reactive_power = 9999.0, shunt_control_type = PSY.FACTSShuntControlType.STATCOM, - regulated_bus_number = 0, + regulated_bus_number = 0, input_basis = PSY.CU, ), ) if p_set_mw !== nothing @@ -448,7 +448,7 @@ function _build_vsc_system(; g = 50.0) dc_control_to = PSY.VSCDCControlModes.DC_POWER, ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, dc_setpoint_to = 0.5, - ac_setpoint_to = 1.0, + ac_setpoint_to = 1.0, input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) return sys @@ -490,7 +490,7 @@ function _vsc_system_pv_terminal(; g = 45.0) converter_loss_to = PSY.LossCurve( PSY.QuadraticCurve(0.01, 0.02, 0.005), PSY.NaturalUnit(), - ), + ), input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) return sys diff --git a/test/test_utils/jacobian_verification.jl b/test/test_utils/jacobian_verification.jl index 3f7b9e23..720f6dc1 100644 --- a/test/test_utils/jacobian_verification.jl +++ b/test/test_utils/jacobian_verification.jl @@ -1,5 +1,5 @@ """ - verify_jacobian_asymptotic(residual, Jv, x0, time_step; Δx_mags, rtol, label) + verify_jacobian_asymptotic(residual, data, Jv, x0, time_step; Δx_mags, rtol, label) Verify the analytic Jacobian `Jv` against `residual` by checking that the first-order Taylor remainder @@ -11,9 +11,9 @@ For a correct Jacobian, `e(Δx) / Δx²` is constant across a geometric `Δx`-sweep (the second-derivative contribution along `u_j`); this routine asserts those normalized ratios agree within `rtol`. -`residual` must be callable as `residual(x, time_step)` and expose the -current residual vector as `residual.Rv`. Both `ACPowerFlowResidual` and -`ACRectangularCIResidual` satisfy this. `Jv::AbstractMatrix` so we can +`residual` is called via `residual(data, x, time_step)` — `data` is threaded explicitly for +every residual type (`ACPowerFlowResidual`, `ACRectangularCIResidual`, `ACMixedCPBResidual`), +none of which store it. Exposes the current residual vector as `residual.Rv`. `Jv::AbstractMatrix` so we can both compute `Jv * u` (asymptotic check) and `Jv[row, j]` (failure diagnostic). Methodology mirrors `test_homotopy_hessian.jl`'s asymptotic checks for the Hessian/gradient. @@ -39,6 +39,7 @@ are skipped (cannot distinguish a correct from an incorrect entry there). """ function verify_jacobian_asymptotic( residual, + data, Jv::AbstractMatrix, x0::Vector{Float64}, time_step::Int; @@ -47,7 +48,7 @@ function verify_jacobian_asymptotic( label::String = "", ) n = length(x0) - residual(x0, time_step) + residual(data, x0, time_step) F0 = copy(residual.Rv) F0_scale = max(LinearAlgebra.norm(F0), 1.0) # Raw-remainder noise floor: below this, `F(x+Δx) - F(x) - Δx·J·u` is @@ -68,10 +69,10 @@ function verify_jacobian_asymptotic( errors = Vector{Float64}(undef, length(Δx_mags)) for (k, Δx) in enumerate(Δx_mags) x1 = x0 .+ Δx .* u - residual(x1, time_step) + residual(data, x1, time_step) errors[k] = LinearAlgebra.norm(residual.Rv .- F0 .- Δx .* Ju) / Δx end - residual(x0, time_step) # restore in case caller relies on it + residual(data, x0, time_step) # restore in case caller relies on it # Filter Δx points whose raw remainder is at FP noise. If every # point is at noise, F is locally linear in x_j within machine @@ -96,11 +97,11 @@ function verify_jacobian_asymptotic( # Worst-row diagnostic: row whose remainder is largest at a # mid-range Δx — most likely the row whose `Jv[row, j]` is wrong. Δx_probe = Δx_mags[end - 1] - residual(x0 .+ Δx_probe .* u, time_step) + residual(data, x0 .+ Δx_probe .* u, time_step) Fp = copy(residual.Rv) - residual(x0 .- Δx_probe .* u, time_step) + residual(data, x0 .- Δx_probe .* u, time_step) Fm = copy(residual.Rv) - residual(x0, time_step) + residual(data, x0, time_step) remainder_probe = Fp .- F0 .- Δx_probe .* Ju row_worst = argmax(abs.(remainder_probe)) symbolic_entry = Jv[row_worst, j] diff --git a/test/test_utils/validate_reduced_power_flow.jl b/test/test_utils/validate_reduced_power_flow.jl index 69ae0f35..5f81febf 100644 --- a/test/test_utils/validate_reduced_power_flow.jl +++ b/test/test_utils/validate_reduced_power_flow.jl @@ -4,7 +4,14 @@ function test_reduced_power_flow( nrs::Vector{PNM.NetworkReduction}, ) data = PF.PowerFlowData(pf, sys) - @test map(typeof, PF.get_network_reductions(pf)) == map(typeof, nrs) + nrd = PNM.get_network_reduction_data(data.power_network_matrix) + @test issetequal( + filter( + !=(PNM.ZeroImpedanceBranchReduction), + map(typeof, PNM.get_applied_reductions(nrd)), + ), + map(typeof, nrs), + ) if pf isa PF.ACPowerFlow PF.solve_power_flow!(data; pf = pf) else @@ -54,7 +61,14 @@ function validate_reduced_power_flow( unreduced_solved_data::PF.PowerFlowData, ) data = PF.PowerFlowData(pf, sys) - @test map(typeof, PF.get_network_reductions(pf)) == map(typeof, nrs) + nrd = PNM.get_network_reduction_data(data.power_network_matrix) + @test issetequal( + filter( + !=(PNM.ZeroImpedanceBranchReduction), + map(typeof, PNM.get_applied_reductions(nrd)), + ), + map(typeof, nrs), + ) if pf isa PF.ACPowerFlow PF.solve_power_flow!(data; pf = pf) else @@ -63,7 +77,6 @@ function validate_reduced_power_flow( @test all(data.converged) reduced_bus_results = get_bus_voltages(data) unreduced_bus_results = get_bus_voltages(unreduced_solved_data) - nrd = PNM.get_network_reduction_data(data.power_network_matrix) reduced_buses = get_reduced_buses(nrd) if all(data.converged) buses_match, branches_match = true, true diff --git a/test/test_vsc_power_flow.jl b/test/test_vsc_power_flow.jl index 4977d303..7a6f2c1a 100644 --- a/test/test_vsc_power_flow.jl +++ b/test/test_vsc_power_flow.jl @@ -66,7 +66,7 @@ end dc_setpoint_from = 1.0, dc_control_to = PSY.VSCDCControlModes.DC_VOLTAGE_DROOP, dc_voltage_droop_to = 0.05, - dc_setpoint_to = 1.0, + dc_setpoint_to = 1.0, input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) data = PowerFlowData( @@ -108,7 +108,7 @@ end # full residual (incl. DC-KCL) is ~0 at the solution residual = PF.ACPowerFlowResidual(data, 1) x = PF.calculate_x0(data, 1) - residual(x, 1) + residual(data, x, 1) @test maximum(abs, residual.Rv) < 1e-7 end @@ -123,11 +123,11 @@ end @test solve_power_flow!(data) # rebuild residual/Jacobian at the converged state and check the analytic J vs FD residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x = PF.calculate_x0(data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC polar I1") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic(residual, data, jac.Jv, x, 1; label = "VSC polar I1") end # Flexible builder: one VSC line between the first two PQ buses of c_sys14, control fields passed @@ -154,7 +154,7 @@ function _vsc_system(; g = 50.0, vsc_kwargs...) active_power_limits_from = (min = -2.0, max = 2.0), active_power_limits_to = (min = -2.0, max = 2.0), g = g, - vsc_kwargs..., + vsc_kwargs..., input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) return (sys, PSY.get_number(pq[1]), PSY.get_number(pq[2])) @@ -249,11 +249,11 @@ end ) @test solve_power_flow!(data) residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x = PF.calculate_x0(data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC polar lossy") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic(residual, data, jac.Jv, x, 1; label = "VSC polar lossy") end @testset "VSC: analytic polar Jacobian matches FD for a lossy converter on a PV bus" begin sys = _vsc_system_pv_terminal(; g = 45.0) @@ -265,11 +265,18 @@ end ) # check at the flat start to isolate the Jacobian structure from solver convergence residual = PF.ACPowerFlowResidual(data, 1) - jac = PF.ACPowerFlowJacobian(residual, 1) + jac = PF.ACPowerFlowJacobian(data, residual, 1) x = PF.calculate_x0(data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC polar lossy-on-PV") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic( + residual, + data, + jac.Jv, + x, + 1; + label = "VSC polar lossy-on-PV", + ) end # A point-to-point VSC with g = 0 is an open DC link: `_build_G_dc` yields an all-zero DC @@ -368,9 +375,9 @@ end data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC mixed") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic(residual, data, jac.Jv, x, 1; label = "VSC mixed") end @testset "VSC I3: rectangular Jacobian matches finite differences (incl. loss)" begin @@ -395,9 +402,9 @@ end data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC rect") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic(residual, data, jac.Jv, x, 1; label = "VSC rect") end # ── Coverage: control modes whose analytic Jacobian / cross-formulation parity were previously @@ -453,9 +460,9 @@ end data = PowerFlowData(pf, _vsc_droop_system()) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC droop $name") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic(residual, data, jac.Jv, x, 1; label = "VSC droop $name") end @testset "VSC: DC-voltage droop — polar, rectangular, and mixed all agree" begin @@ -490,9 +497,9 @@ end data = PowerFlowData(pf, _vsc_ac_voltage_system()) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) - residual(x, 1) - jac(1) - verify_jacobian_asymptotic(residual, jac.Jv, x, 1; label = "VSC ac-voltage $name") + residual(data, x, 1) + jac(data, 1) + verify_jacobian_asymptotic(residual, data, jac.Jv, x, 1; label = "VSC ac-voltage $name") end @testset "VSC: AC-voltage control — polar, rectangular, and mixed all agree" begin @@ -772,7 +779,7 @@ function _build_parallel_ic_system(; shared_ac::Bool = true) loss_function = PSY.LossCurve( PSY.QuadraticCurve(0.005, 0.01, 0.002), PSY.NaturalUnit(), - ), + ), input_basis = PSY.CU, ) PSY.add_component!(sys, ic) end @@ -788,7 +795,7 @@ function _build_parallel_ic_system(; shared_ac::Bool = true) l = 0.0, c = 0.0, active_power_limits_from = (min = -5.0, max = 5.0), - active_power_limits_to = (min = -5.0, max = 5.0), + active_power_limits_to = (min = -5.0, max = 5.0), input_basis = PSY.CU, ) PSY.add_component!(sys, dcl) return sys @@ -845,7 +852,7 @@ function _vsc_system_ref_terminal(; g = 45.0) converter_loss_to = PSY.LossCurve( PSY.QuadraticCurve(0.01, 0.02, 0.005), PSY.NaturalUnit(), - ), + ), input_basis = PSY.CU, ) PSY.add_component!(sys, vsc) return sys @@ -863,10 +870,10 @@ end data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) - residual(x, 1) - jac(1) + residual(data, x, 1) + jac(data, 1) verify_jacobian_asymptotic( - residual, jac.Jv, x, 1; + residual, data, jac.Jv, x, 1; label = "VSC REF terminal $(label)", ) dcn = PF.get_dc_network(data) @@ -897,10 +904,10 @@ end data = PowerFlowData(pf, sys) @test solve_power_flow!(data) residual, jac, x = PF.initialize_power_flow_variables(pf, data, 1) - residual(x, 1) - jac(1) + residual(data, x, 1) + jac(data, 1) verify_jacobian_asymptotic( - residual, jac.Jv, x, 1; + residual, data, jac.Jv, x, 1; label = "VSC PV terminal $(label)", ) dcn = PF.get_dc_network(data) @@ -948,7 +955,7 @@ end dc_control_to = PSY.VSCDCControlModes.DC_POWER, ac_control_to = PSY.VSCACControlModes.AC_VOLTAGE, dc_setpoint_to = 0.3, - ac_setpoint_to = 1.0, + ac_setpoint_to = 1.0, input_basis = PSY.CU, ) PSY.add_component!(sys_pv, vsc) @test_throws ErrorException PowerFlowData( @@ -988,7 +995,7 @@ end ac_setpoint_from = 1.0, dc_control_to = PSY.VSCDCControlModes.DC_POWER, ac_control_to = PSY.VSCACControlModes.AC_REACTIVE_POWER, - dc_setpoint_to = 0.2, + dc_setpoint_to = 0.2, input_basis = PSY.CU, ) PSY.add_component!(sys_dup, vsc_k) end diff --git a/test/test_zip_state_roundtrip.jl b/test/test_zip_state_roundtrip.jl index 24a2dd25..cff2869c 100644 --- a/test/test_zip_state_roundtrip.jl +++ b/test/test_zip_state_roundtrip.jl @@ -38,7 +38,7 @@ end residual = PF.ACPowerFlowResidual(data, 1) x = zeros(Float64, length(residual.Rv)) PF.update_state!(x, data, 1) - residual(x, 1) + residual(data, x, 1) @test maximum(abs, residual.Rv) < ZIP_ROUNDTRIP_ATOL # `update_data!` inverts `update_state!`, but only for actual state variables: @@ -79,7 +79,7 @@ end residual = residual_type(data, 1) x = Vector{Float64}(undef, length(residual.Rv)) fill_state!(x, data, residual.bus_state_offset, residual.bus_block_size, 1) - residual(x, 1) + residual(data, x, 1) @test maximum(abs, residual.Rv) < ZIP_ROUNDTRIP_ATOL end end From dc8e32f78cc21221e908ddb46bf85765716244d1 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 24 Sep 2026 02:17:38 -0700 Subject: [PATCH 09/14] Export and consume remote and shared voltage control The PSS/E writer carries IREG, VS and RMPCT on generators, the signed CONT with CR/CX on transformer windings (0 for a three-winding circuit regulating its star bus), ICR/ICI and IFR/ITR/IDR on two-terminal DC lines, REMOT/RMPCT on VSC converters, FCREG/REMOT on FACTS devices and SWREG/SWREM on switched shunts, reading the new PowerSystems fields and ReactivePowerSharing groups; RMPCT is each member's share of its group in percent, 100 for a device that regulates alone. Discrete control, FACTS injections and the FACTS write-back read the regulated bus and reactive power through the units-aware accessors. Two round-trip testsets export PowerSystems-built and parser-built cases on both raw versions, re-import them and compare the control fields against the original records. The rest of the changes bring the suite up to date with the psy6 line: input_basis on every unit-bearing test constructor and on the exporter's synthetic HVDC generators, system_from_openapi in place of the retired make_system, regulated buses on the helper transformers, switching device ratings written in MVA, the VSC RDC rebuilt on the rated DC voltage and RCOMP in ohm, a capped finite-difference probe step, and the LCC parity tolerance the corrected line resistances need. --- Project.toml | 16 +- docs/Project.toml | 6 +- src/common.jl | 2 +- src/discrete_control/control_continuation.jl | 5 +- src/discrete_control/control_metadata.jl | 27 +- src/psse_export.jl | 190 ++++++++++---- src/solve_ac_power_flow.jl | 5 +- test/Project.toml | 47 ++-- test/includes.jl | 1 - test/test_discrete_control.jl | 59 +++-- test/test_fast_decoupled.jl | 52 +++- test/test_hvdc.jl | 5 +- test/test_lcc_discrete_control.jl | 14 +- test/test_lcc_zero_setpoint.jl | 5 +- test/test_lossy_dc_power_flow.jl | 5 +- test/test_mixed_cpb_lcc.jl | 5 +- test/test_mixed_cpb_polar_parity.jl | 10 +- test/test_psse_export.jl | 37 +-- test/test_psse_export_remote_control.jl | 259 +++++++++++++++++++ test/test_psse_remote_control_fixtures.jl | 201 ++++++++++++++ test/test_rectangular_ci_lcc.jl | 20 +- test/test_residual_condition_diagnostics.jl | 4 +- test/test_robust_power_flow.jl | 5 +- test/test_solve_power_flow.jl | 2 +- test/test_utils/common.jl | 26 +- test/test_utils/cross_file_fixtures.jl | 7 +- 26 files changed, 827 insertions(+), 188 deletions(-) create mode 100644 test/test_psse_export_remote_control.jl create mode 100644 test/test_psse_remote_control_fixtures.jl diff --git a/Project.toml b/Project.toml index b9bb2d54..2a52b2f0 100644 --- a/Project.toml +++ b/Project.toml @@ -37,18 +37,18 @@ PowerFlowsPardisoExt = "Pardiso" [sources] # Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} -PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} +PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "mb/remote-control"} # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems # 5.11, which this line (5.10.0) does not satisfy. PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "jd/psy6_correctness"} -InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} -PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "InfrastructureCoreOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerInvestmentsOpenAPIModels.jl"} +PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerOperationsOpenAPIModels.jl"} [compat] InfrastructureCoreOpenAPIModels = "0.1" diff --git a/docs/Project.toml b/docs/Project.toml index b73593ab..5f6d0e68 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -26,12 +26,12 @@ InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/ # Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems # 5.11, which this line (5.10.0) does not satisfy. PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "jd/psy6_correctness"} -PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} -PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl.git"} +PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "mb/remote-control"} +PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "mb/remote-control"} # PSCB's own [sources] pin for this is ignored once PSCB is a dependency rather than the root # project; the docs env needs its own pin so Pkg can resolve PSCB's unregistered parser dep. PowerTableDataParser = {url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git", rev = "psy6"} -PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} +PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "mb/remote-control"} [compat] Documenter = "^1.0" diff --git a/src/common.jl b/src/common.jl index bc165861..4b2311b3 100644 --- a/src/common.jl +++ b/src/common.jl @@ -31,7 +31,7 @@ function _get_injections!( # yet to implement control mode etc. for FACTS devices. if source isa PSY.FACTSControlDevice bus_reactive_power_injections[bus_ix] += - PSY.get_reactive_power_required(source) + PSY.get_reactive_power_required(source, PSY.SU) else bus_reactive_power_injections[bus_ix] += PSY.get_reactive_power(source, PSY.SU) diff --git a/src/discrete_control/control_continuation.jl b/src/discrete_control/control_continuation.jl index 567501a7..2e55a0e6 100644 --- a/src/discrete_control/control_continuation.jl +++ b/src/discrete_control/control_continuation.jl @@ -141,7 +141,10 @@ function _plant_sign( y0 = measured_value(d, data, ts) _capture_state!(snap, data, ts) lo, hi = parameter_limits(d) - δ = 1e-3 * (hi - lo) + # A thousandth of the parameter range, capped relative to the operating point: a device + # with a very wide range (a FACTS device sized never to bind) would otherwise be probed + # with a step large enough for the |V|^2 nonlinearity to bias the slope. + δ = min(1e-3 * (hi - lo), 1e-2 * max(abs(p0), 1.0)) if δ <= 0.0 δ = 1e-6 end diff --git a/src/discrete_control/control_metadata.jl b/src/discrete_control/control_metadata.jl index ef8b6226..3df6b07d 100644 --- a/src/discrete_control/control_metadata.jl +++ b/src/discrete_control/control_metadata.jl @@ -115,15 +115,11 @@ bound WINDV1 while `PSY.get_tap` stores the ratio WINDV1/WINDV2; `TransformerCir WINDV2-equivalent field, so this band is wrong by a factor of WINDV2 whenever WINDV2 != 1 for the parsed transformer (the correct band would be `control_limits ./ WINDV2`). Fixing this needs a data-model change upstream (PFFP/PSY), not here. -`get_regulated_bus_number` is 0 for local (to-bus) control.""" +A circuit that names no regulated bus controls its to-bus.""" function _tap_metadata(circuit::PSY.TransformerCircuit, to_bus::Int) lims = PSY.get_control_limits(circuit) - reg = PSY.get_regulated_bus_number(circuit) - cbus = to_bus - if !iszero(reg) - # The sign marks the regulation side (PSS/E CONT<0); the bus number itself is |reg|. - cbus = abs(reg) - end + reg = PSY.get_regulated_bus(circuit) + cbus = isnothing(reg) ? to_bus : PSY.get_number(reg) # The tap is held anywhere inside the VMA/VMI band and regulates toward its midpoint on # an excursion — the same posture as a switched shunt's VSWLO/VSWHI. vlims = PSY.get_controlled_quantity_limits(circuit) @@ -280,12 +276,8 @@ function build_controlled_device_set( network; leaving the shunt locked." continue end - # `regulated_bus_number` is 0 for local control (PSS/E SWREM/NREG map to it in the parser). - reg = PSY.get_regulated_bus_number(sa) - cbus = bus - if !iszero(reg) - cbus = reg - end + # The resolved regulated bus: the remote bus when one is named, else the shunt's own. + cbus = PSY.get_number(PSY.get_regulated_bus(sa)) cix = _resolve_bus_ix(bus_lookup, reverse_bus_search_map, cbus) if isnothing(cix) @warn "ControlledSwitchedShunt \"$name\": controlled bus $cbus is not in \ @@ -333,7 +325,7 @@ end # Continuous shunt FACTS (SVC/STATCOM) voltage control. `rating` (SHMX) bounds the SVC # susceptance-at-unity or the STATCOM current; `q_cap` is an independent MVA ceiling. Both -# combine into the |V|-dependent limit `_facts_b_limit`. FCREG (`regulated_bus_number`) +# combine into the |V|-dependent limit `_facts_b_limit`. FCREG (`remote_regulated_bus`) # selects local vs. remote-bus regulation. function _enroll_facts!( facts::Vector{ControlledFACTS}, @@ -358,11 +350,8 @@ function _enroll_facts!( device not enrolled." continue end - reg = PSY.get_regulated_bus_number(fd) - cix = bix - if !iszero(reg) - cix = _resolve_bus_ix(bus_lookup, reverse_bus_search_map, reg) - end + reg = PSY.get_number(PSY.get_regulated_bus(fd)) + cix = reg == bus ? bix : _resolve_bus_ix(bus_lookup, reverse_bus_search_map, reg) if isnothing(cix) @warn "ControlledFACTS \"$name\": regulated bus $reg is not in the \ (reduced) network; device not enrolled." diff --git a/src/psse_export.jl b/src/psse_export.jl index a683acb3..917106fd 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -260,6 +260,79 @@ supports_multi_period(::PSSEExporter) = false _value_or_default(val, default) = isnothing(val) ? default : val +"""PSS/E number of `bus` in the export, or `default` when there is no bus.""" +_psse_bus_or_default(md, ::Nothing, default) = default +_psse_bus_or_default(md, bus::PSY.ACBus, default) = + md["bus_number_mapping"][PSY.get_number(bus)] + +""" +PSS/E RMPCT of `device`: its share of its voltage control group's reactive power in +percent, or `default` when it regulates alone. `terminal` picks the converter of a +two-terminal VSC line. +""" +function _psse_rmpct(device::PSY.Component, default; terminal = nothing) + for group in PSY.get_supplemental_attributes(PSY.VoltageControlGroup, device) + isnothing(terminal) || PSY.get_terminal(group, device) == terminal || continue + return 100.0 * PSY.get_weight(group, device) / sum(values(PSY.get_weights(group))) + end + return default +end + +""" +Signed PSS/E CONT of `circuit`: the number of the bus it regulates, negative when that bus +lies on the controlling winding's side, or 0 when it regulates no bus. A three-winding +circuit regulating its star bus also writes 0: the star bus has no PSS/E number, and 0 is how +PSS/E names the winding's own far end. +""" +function _psse_transformer_cont(md, circuit::PSY.TransformerCircuit) + bus = PSY.get_regulated_bus(circuit) + isnothing(bus) && return 0 + number = get(md["bus_number_mapping"], PSY.get_number(bus), nothing) + isnothing(number) && return 0 + side = PSY.get_regulated_bus_side(circuit) + return side == PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING ? -number : number +end + +""" +PSS/E IF, IT and ID of a converter's tap transformer: its bus numbers and circuit id in the +export, or blanks when the converter has none. +""" +_psse_tap_transformer_fields(::PSSEExporter, ::Nothing) = + (PSSE_DEFAULT, PSSE_DEFAULT, PSSE_DEFAULT) +function _psse_tap_transformer_fields( + exporter::PSSEExporter, + transformer::PSY.TwoWindingTransformer, +) + md = exporter.md_dict + (_, _, transformer_ckt_mapping, _) = _load_transformer_components_and_mappings(exporter) + from_n, to_n = branch_to_bus_numbers(transformer) + ckt = transformer_ckt_mapping[((from_n, to_n), PSY.get_name(transformer))] + if startswith(ckt, "_") + ckt = ckt[2:end] + end + return ( + md["bus_number_mapping"][from_n], + md["bus_number_mapping"][to_n], + _psse_quote_string(ckt), + ) +end + +# VS is the voltage a unit holds at the bus it regulates; a unit type with no setpoint holds +# its own bus at its current magnitude and names no remote bus. +const _PSSE_SETPOINT_GENERATORS = Union{ + PSY.VoltageControlGenerator, + PSY.EnergyReservoirStorage, + PSY.SynchronousCondenser, + PSY.Source, +} +_psse_generator_vs(generator::_PSSE_SETPOINT_GENERATORS) = + PSY.get_voltage_setpoint(generator) +_psse_generator_vs(generator::PSY.StaticInjection) = + PSY.get_magnitude(PSY.get_bus(generator)) +_psse_remote_regulated_bus(generator::_PSSE_SETPOINT_GENERATORS) = + PSY.get_remote_regulated_bus(generator) +_psse_remote_regulated_bus(::PSY.StaticInjection) = nothing + """ Write v35 header comments for a given section if applicable. """ @@ -935,12 +1008,11 @@ function _write_2w_transformer_record3_winding1!( VMI1 = controlled_quantity_limits.min NTP1 = PSY.get_number_of_tap_positions(circuit) NOD1 = PSSE_DEFAULT - CONT1 = PSY.get_regulated_bus_number(circuit) + CONT1 = _psse_transformer_cont(exporter.md_dict, circuit) supp_attr = PSY.get_supplemental_attributes(PSY.ImpedanceCorrectionData, transformer) TAB1 = !isempty(supp_attr) ? PSY.get_table_number(supp_attr[1]) : 0 - CR1 = PSSE_DEFAULT - CX1 = PSSE_DEFAULT + CR1, CX1 = reim(PSY.get_load_drop_compensation(circuit, PSY.SU)) CNXA1 = PSSE_DEFAULT if exporter.psse_version == :v35 @@ -1069,7 +1141,7 @@ function _collect_3w_winding_data( PSY.get_control_objective(circuit), PSY.TransformerControlObjective.UNDEFINED, ) - CONT = PSY.get_regulated_bus_number(circuit) + CONT = _psse_transformer_cont(exporter.md_dict, circuit) NOD = PSSE_DEFAULT control_limits = _circuit_control_limits_degrees(circuit) RMA = control_limits.max @@ -1086,8 +1158,7 @@ function _collect_3w_winding_data( TAB = PSY.get_table_number(icd_tr) end end - CR = PSSE_DEFAULT - CX = PSSE_DEFAULT + CR, CX = reim(PSY.get_load_drop_compensation(circuit, PSY.SU)) CNXA = PSSE_DEFAULT if exporter.psse_version == :v35 @@ -1641,7 +1712,8 @@ function write_to_buffers!( ) # Get common fields - VS = PSY.get_magnitude(PSY.get_bus(generator)) + VS = _psse_generator_vs(generator) + remote_bus = _psse_remote_regulated_bus(generator) MBASE = PSY.get_base_power(generator, PSY.NU) STAT = 0 if PSY.get_available(generator) @@ -1651,22 +1723,24 @@ function write_to_buffers!( # Generator machine data PSY does not model. v35 forbids blank fields, so it writes # the spec defaults the reader would have substituted; v33 leaves them blank. if exporter.psse_version == :v35 + IREG = _psse_bus_or_default(md, remote_bus, PSSE_GEN_DEFAULT_IREG) + RMPCT = _psse_rmpct(generator, PSSE_GEN_DEFAULT_RMPCT) _write_generator_v35_record!( io, I, ID, PG, QG, QT, QB, VS, - PSSE_GEN_DEFAULT_IREG, PSSE_GEN_DEFAULT_NREG, MBASE, + IREG, PSSE_GEN_DEFAULT_NREG, MBASE, PSSE_GEN_DEFAULT_ZR, PSSE_GEN_DEFAULT_ZX, PSSE_GEN_DEFAULT_RT, PSSE_GEN_DEFAULT_XT, PSSE_GEN_DEFAULT_GTAP, - STAT, PSSE_GEN_DEFAULT_RMPCT, PT, PB, PSSE_GEN_DEFAULT_BASLOD, + STAT, RMPCT, PT, PB, PSSE_GEN_DEFAULT_BASLOD, PSSE_GEN_DEFAULT_WMOD, PSSE_GEN_DEFAULT_WPF, ) else - IREG = PSSE_DEFAULT + IREG = _psse_bus_or_default(md, remote_bus, PSSE_DEFAULT) ZR = PSSE_DEFAULT ZX = PSSE_DEFAULT RT = PSSE_DEFAULT XT = PSSE_DEFAULT GTAP = PSSE_DEFAULT - RMPCT = PSSE_DEFAULT + RMPCT = _psse_rmpct(generator, 100.0) WMOD = PSSE_DEFAULT WPF = PSSE_DEFAULT _write_generator_v33_record!( @@ -1804,16 +1878,11 @@ function _write_discrete_branch_record!( GJ = 0.0 BJ = 0.0 + # The switching device record's RATE1 is in MVA, like every other PSS/E rating. RATEA = _value_or_default(PSY.get_rating(branch, PSY.NU), PSSE_DEFAULT) RATEB = 0.0 RATEC = 0.0 - # PFFP's switch/breaker importer stores RATE unscaled, so export divides by SBASE to - # round-trip. - if RATEA >= INFINITE_BOUND - RATEA = 0.0 - else - RATEA = RATEA / PSY.get_base_power(exporter.system, PSY.NU) - end + RATEA = RATEA >= INFINITE_BOUND ? 0.0 : RATEA @fastprintdelim_unroll(io, false, I, J, CKT, R, X, B, RATEA, RATEB, RATEC, GI, BI, @@ -1946,13 +2015,9 @@ function write_to_buffers!( CKT = _psse_quote_string(CKT) X = PSY.get_x(branch, PSY.SU) + # RATE1 is in MVA, like every other PSS/E rating. RATE1 = _value_or_default(PSY.get_rating(branch, PSY.NU), PSSE_DEFAULT) - # See `_write_discrete_branch_record!`. - if RATE1 >= INFINITE_BOUND - RATE1 = 0.0 - else - RATE1 = RATE1 / PSY.get_base_power(exporter.system, PSY.NU) - end + RATE1 = RATE1 >= INFINITE_BOUND ? 0.0 : RATE1 rates = [RATE1] # Using 0.0 as default for rating exporter, since PSSEv35 does not allow blank values @@ -2267,10 +2332,16 @@ function _compute_dcline_common_fields( VSCHD = PSY.get_scheduled_dc_voltage(dcline) # RDC is a DC-circuit resistance: PSY per-unitizes it against the DC base (VSCHD^2 / # baseMVA), not the rectifier AC commutating base, so the inverse conversion must use - # the same base or the raw round trip scales `r` by (VSCHD/EBASR)^2. - RDC = PSY.get_r(dcline) * VSCHD^2 / PSY.get_base_power(exporter.system, PSY.NU) + # the same base or the raw round trip scales `r` by (VSCHD/EBASR)^2. A line scheduled at + # 0 kV (out of service) is per-unitized on the rectifier's AC base instead, by both the + # parser and PSY, so the inverse follows that fallback. + dc_base_voltage = iszero(VSCHD) ? PSY.get_rectifier_base_voltage(dcline) : VSCHD + RDC = + PSY.get_r(dcline) * dc_base_voltage^2 / PSY.get_base_power(exporter.system, PSY.NU) VCMOD = PSY.get_switch_mode_voltage(dcline) - RCOMP = PSY.get_compounding_resistance(dcline) + RCOMP = + PSY.get_compounding_resistance(dcline) * dc_base_voltage^2 / + PSY.get_base_power(exporter.system, PSY.NU) DELTI = PSSE_DEFAULT METER = PSSE_DEFAULT DCVMIN = PSY.get_min_compounding_voltage(dcline) @@ -2305,11 +2376,14 @@ function _compute_dcline_rectifier_fields( TMXR = PSY.get_rectifier_tap_limits(dcline).max TMNR = PSY.get_rectifier_tap_limits(dcline).min STPR = PSY.get_rectifier_tap_step(dcline) - ICR = PSSE_DEFAULT + ICR = _psse_bus_or_default( + exporter.md_dict, + PSY.get_rectifier_commutating_bus(dcline), + PSSE_DEFAULT, + ) NDR = PSSE_DEFAULT - IFR = PSSE_DEFAULT - ITR = PSSE_DEFAULT - IDR = PSSE_DEFAULT + IFR, ITR, IDR = + _psse_tap_transformer_fields(exporter, PSY.get_rectifier_tap_transformer(dcline)) XCAPR = PSY.get_rectifier_capacitor_reactance(dcline) * PSY.get_rectifier_base_voltage(dcline)^2 / @@ -2343,11 +2417,14 @@ function _compute_dcline_inverter_fields( TMXI = PSY.get_inverter_tap_limits(dcline).max TMNI = PSY.get_inverter_tap_limits(dcline).min STPI = PSY.get_inverter_tap_step(dcline) - ICI = PSSE_DEFAULT + ICI = _psse_bus_or_default( + exporter.md_dict, + PSY.get_inverter_commutating_bus(dcline), + PSSE_DEFAULT, + ) NDI = PSSE_DEFAULT - IFI = PSSE_DEFAULT - ITI = PSSE_DEFAULT - IDI = PSSE_DEFAULT + IFI, ITI, IDI = + _psse_tap_transformer_fields(exporter, PSY.get_inverter_tap_transformer(dcline)) XCAPI = PSY.get_inverter_capacitor_reactance(dcline) * PSY.get_inverter_base_voltage(dcline)^2 / @@ -2507,8 +2584,12 @@ function _compute_vsc_converter_fields( PWF = PSY.get_power_factor_weighting_fraction_from(vscline) q_limits = PSY.get_reactive_power_limits_from(vscline, PSY.SU) # PSY spells local (terminal-bus) regulation as `nothing`; PSS/E as REMOT = 0. - REMOT = _value_or_default(PSY.get_remote_bus_control_from(vscline), 0) - RMPCT = PSY.get_rmpct_from(vscline) + REMOT = _psse_bus_or_default( + exporter.md_dict, + PSY.get_remote_regulated_bus_from(vscline), + 0, + ) + RMPCT = _psse_rmpct(vscline, 100.0; terminal = PSY.VoltageControlTerminal.FROM) else MODE = PSY.get_ac_control_to(vscline) == PSY.VSCACControlModes.AC_VOLTAGE ? 1 : 2 @@ -2519,8 +2600,12 @@ function _compute_vsc_converter_fields( get_imax = PSY.get_max_dc_current_to PWF = PSY.get_power_factor_weighting_fraction_to(vscline) q_limits = PSY.get_reactive_power_limits_to(vscline, PSY.SU) - REMOT = _value_or_default(PSY.get_remote_bus_control_to(vscline), 0) - RMPCT = PSY.get_rmpct_to(vscline) + REMOT = _psse_bus_or_default( + exporter.md_dict, + PSY.get_remote_regulated_bus_to(vscline), + 0, + ) + RMPCT = _psse_rmpct(vscline, 100.0; terminal = PSY.VoltageControlTerminal.TO) end # Invert the parser's loss normalization: ALOSS/MINLOSS are kW normalized by @@ -2597,21 +2682,16 @@ function write_to_buffers!( MDC = PSY.get_available(vscline) ? 1 : 0 from_dc_control = PSY.get_dc_control_from(vscline) to_dc_control = PSY.get_dc_control_to(vscline) - # Base (DC) voltage comes from a terminal carrying a DC-voltage reference (strict DC_VOLTAGE - # or droop); a pure MW (DC_POWER) terminal's setpoint is a power, not a voltage. The DC-side - # kV is the per-unit setpoint times `rated_dc_voltage` (kV); `rated_dc_voltage == 0` treats - # the setpoint as already in kV. RDC is reconstructed from `g` and Zbase (round-trips `g`). + # The DC-side kV of a setpoint is the per-unit setpoint times `rated_dc_voltage` (kV); + # `rated_dc_voltage == 0` treats the setpoint as already in kV. vdc_scale = if iszero(PSY.get_rated_dc_voltage(vscline)) 1.0 else PSY.get_rated_dc_voltage(vscline) end - if _has_dc_voltage_reference(from_dc_control) - base_voltage = PSY.get_dc_setpoint_from(vscline) * vdc_scale - else - base_voltage = PSY.get_dc_setpoint_to(vscline) * vdc_scale - end - Zbase = base_voltage^2 / PSY.get_base_power(exporter.system, PSY.NU) + # PSY holds `g` per unit on rated_dc_voltage^2 / base power (kV, MVA), the base its + # importer converts siemens against, so RDC in ohm is 1 / g times that base. + Zbase = vdc_scale^2 / PSY.get_base_power(exporter.system, PSY.NU) RDC = if iszero(PSY.get_g(vscline)) 0.0 else @@ -2838,14 +2918,14 @@ function write_to_buffers!( VSMX = PSSE_DEFAULT IMX = PSSE_DEFAULT LINX = PSSE_DEFAULT - RMPCT = PSSE_DEFAULT + RMPCT = _psse_rmpct(facts, 100.0) OWNER = PSSE_DEFAULT SET1 = PSSE_DEFAULT SET2 = PSSE_DEFAULT VSREF = PSSE_DEFAULT - FCREG = PSY.get_regulated_bus_number(facts) + FCREG = _psse_bus_or_default(md, PSY.get_remote_regulated_bus(facts), 0) NREG = PSSE_DEFAULT - REMOT = PSY.get_regulated_bus_number(facts) + REMOT = FCREG MNAME = _psse_quote_string("") if exporter.psse_version == :v35 @@ -2971,13 +3051,13 @@ function write_to_buffers!( VSWLO = admittance_limits.min if exporter.psse_version == :v35 - SWREG = PSY.get_regulated_bus_number(shunt) + SWREG = _psse_bus_or_default(md, PSY.get_remote_regulated_bus(shunt), 0) NREG = PSSE_DEFAULT else - SWREM = PSY.get_regulated_bus_number(shunt) + SWREM = _psse_bus_or_default(md, PSY.get_remote_regulated_bus(shunt), 0) end - RMPCT = PSSE_DEFAULT + RMPCT = _psse_rmpct(shunt, 100.0) RMIDNT = _psse_quote_string("") BINIT = _switched_shunt_binit( diff --git a/src/solve_ac_power_flow.jl b/src/solve_ac_power_flow.jl index 1aec9385..b16a20e1 100644 --- a/src/solve_ac_power_flow.jl +++ b/src/solve_ac_power_flow.jl @@ -157,7 +157,10 @@ function write_device_settings!(system::PSY.System, data) end # Delivered reactive power Q = b·|V_local|² (MVA) at the device's own bus. PSY.set_reactive_power_required!( - fd, delivered_q_mvar(d, data.bus_magnitude[d.bus_ix, 1])) + fd, + delivered_q_mvar(d, data.bus_magnitude[d.bus_ix, 1]) / + PSY.get_base_power(system, PSY.NU) * PSY.SU, + ) end return end diff --git a/test/Project.toml b/test/Project.toml index 4c7a6fb4..9b086f14 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,11 +1,4 @@ [deps] -InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" -InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" -PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" -PowerDynamicsOpenAPIModels = "044a0b22-31f8-4ef6-8282-c9a61b3013f6" -PowerInvestmentsOpenAPIModels = "33cb4396-f4d9-4f59-8585-787ebb56cb1b" -PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" -PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" @@ -13,7 +6,9 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DistributedArrays = "aaf54ef3-cdf8-58ed-94cc-d582ad619b94" Infiltrator = "5903a43b-9cc3-4c30-8d17-598619ec4e9b" +InfrastructureCoreOpenAPIModels = "1f5e1c8d-e0cc-4dbf-8c6d-f2a3d2ae70a8" InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1" +InfrastructureTimeSeriesOpenAPIModels = "37a216c8-a490-47cf-89d7-db2cb9618199" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" @@ -21,9 +16,14 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Pardiso = "46dd5b70-b6fb-5a00-ae2d-e8fea33afaf2" +PowerCoreOpenAPIModels = "b7b40286-e793-417d-a9a0-b1583e4da1cb" +PowerDynamicsOpenAPIModels = "044a0b22-31f8-4ef6-8282-c9a61b3013f6" PowerFlowFileParser = "bed98974-b02e-5e2f-9ee0-a103f5c450dd" PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" +PowerInvestmentsOpenAPIModels = "33cb4396-f4d9-4f59-8585-787ebb56cb1b" PowerNetworkMatrices = "bed98974-b02a-5e2f-9fe0-a103f5c450dd" +PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" +PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" PowerSystemCaseBuilder = "f00506e0-b84f-492a-93c2-c0a9afc4364e" PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" PowerTableDataParser = "2b750c0e-0bff-11f1-9200-1befd75df6be" @@ -34,27 +34,20 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" [sources] -PowerFlows = {path = ".."} +InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "InfrastructureCoreOpenAPIModels.jl"} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git"} -PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "psy6"} -PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "psy6"} -PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "psy6"} -# Pinned to the branch rather than the registry: registry PNM v0.24 requires PowerSystems -# 5.11, which this line (5.10.0) does not satisfy. -PowerNetworkMatrices = {url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git", rev = "jd/psy6_correctness"} -# PSY's own [sources] pins for these are ignored once PSY is a dependency rather than the -# root project; the test env needs its own pins so Pkg can resolve PSY's unregistered OpenAPI deps. -# PSCB's own [sources] pin for this is ignored once PSCB is a dependency rather than the root -# project; the test env needs its own pin so Pkg can resolve PSCB's unregistered parser dep. -PowerTableDataParser = {url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git", rev = "psy6"} - -InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} -PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerFlowFileParser = {url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl.git", rev = "mb/remote-control"} +PowerFlows = {path = "/Users/mbossart/sienna/remote-control/PowerFlows.jl"} +PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerInvestmentsOpenAPIModels.jl"} +PowerNetworkMatrices = {rev = "jd/psy6_correctness", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl.git"} +PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "mb/remote-control", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerSystemCaseBuilder = {url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl.git", rev = "mb/remote-control"} +PowerSystems = {url = "https://github.com/Sienna-Platform/PowerSystems.jl.git", rev = "mb/remote-control"} +PowerTableDataParser = {rev = "psy6", url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl.git"} [compat] julia = "^1.10" diff --git a/test/includes.jl b/test/includes.jl index 7719e2b8..5ff475e2 100644 --- a/test/includes.jl +++ b/test/includes.jl @@ -9,7 +9,6 @@ using LinearAlgebra using PowerFlows using PowerSystems using PowerSystemCaseBuilder -import PowerSystemCaseBuilder: make_system const PFP = PowerSystemCaseBuilder.PowerFlowFileParser using PowerNetworkMatrices using InfrastructureSystems diff --git a/test/test_discrete_control.jl b/test/test_discrete_control.jl index e6739fcf..a9579fb2 100644 --- a/test/test_discrete_control.jl +++ b/test/test_discrete_control.jl @@ -6,7 +6,7 @@ const IEEE14_FACTS_RAW = joinpath(TEST_DATA_DIR, "14_bus.raw") `stress` scales every `StandardLoad`'s ZIP P/Q components (the PSS/E parser's load type); `shunt9_off` disables the bus-9 fixed shunt so the FACTS device carries more of the reactive burden. `svc`/`shunt_control_type` selects the SVC vs STATCOM reactive-limit law; -`regulated_bus_number` sets FCREG (0 ⇒ local/sending-bus regulation).""" +`regulated_bus_number` names the remote regulated bus (0 ⇒ local/sending-bus regulation).""" function build_ieee14_facts_system(; regulated_bus_number::Int = 0, shmx_mva::Float64 = 25.0, @@ -16,7 +16,10 @@ function build_ieee14_facts_system(; stress::Float64 = 1.0, shunt9_off::Bool = false, ) - sys = make_system(PFP.PowerModelsData(IEEE14_FACTS_RAW); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(IEEE14_FACTS_RAW); + runchecks = false, + ) if !isone(stress) for load in get_components(StandardLoad, sys) set_constant_active_power!( @@ -42,6 +45,8 @@ function build_ieee14_facts_system(; if svc shunt_control_type = PSY.FACTSShuntControlType.SVC end + remote_regulated_bus = + iszero(regulated_bus_number) ? nothing : get_bus(sys, regulated_bus_number) facts = FACTSControlDevice(; name = "facts_14", available = true, @@ -49,7 +54,8 @@ function build_ieee14_facts_system(; control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = vset, shunt_control_type = shunt_control_type, - regulated_bus_number = regulated_bus_number, input_basis = PSY.CU, + remote_regulated_bus = remote_regulated_bus, + input_basis = PSY.CU, ) # `max_shunt_current`/`max_reactive_power` are stored in device base; the constructor # kwargs take a raw CU value, so set them through the units-aware setters to honor the @@ -184,7 +190,20 @@ end # de-enroll the device with a warning, not abort PowerFlowData construction. sys = _make_tap_shunt_system() tx = first(PSY.get_components(PSY.TwoWindingTransformer, sys)) - PSY.set_regulated_bus_number!(PSY.get_circuit(tx), 99) # no bus 99 + # A bus the system does not hold: the builder resolves it by number and finds nothing. + PSY.set_regulated_bus!( + PSY.get_circuit(tx), + ACBus(; + number = 99, + name = "bus_99", + available = true, + bustype = ACBusTypes.PQ, + angle = 0.0, + magnitude = 1.0, + voltage_limits = (min = 0.9, max = 1.1), + base_voltage = 230.0, + ), + ) data = PowerFlowData(ACPolarPowerFlow(), sys) set = @test_logs (:warn, r"controlled bus 99") match_mode = :any ( PowerFlows.build_controlled_device_set( @@ -195,11 +214,11 @@ end end @testset "discrete control: tap reads first-class control fields" begin - # The builder reads regulated_bus_number (controlled bus), control_limits (ratio band), + # The builder reads regulated_bus (controlled bus), control_limits (ratio band), # number_of_tap_positions, and controlled_quantity_limits (VMI/VMA) off the PSY circuit. sys = _make_tap_shunt_system() tx = first(PSY.get_components(PSY.TwoWindingTransformer, sys)) - PSY.set_regulated_bus_number!(PSY.get_circuit(tx), 3) + PSY.set_regulated_bus!(PSY.get_circuit(tx), PSY.get_bus(sys, 3)) PSY.set_control_limits!(PSY.get_circuit(tx), (min = 0.88, max = 1.12)) PSY.set_number_of_tap_positions!(PSY.get_circuit(tx), 25) # A degenerate band pins the regulation target to a single voltage. @@ -219,13 +238,16 @@ end @test t.vset_hi ≈ 1.03 end -@testset "discrete control: negative regulated_bus_number resolves via abs (PSS/E CONT<0)" begin - # PSS/E CONT1<0 marks which side of the transformer regulates; the bus number itself is - # |CONT1|. A raw negative value must not be used as a bus number directly (it resolves no - # bus, so the tap would de-enroll with "controlled bus -3 is not in the network"). +@testset "discrete control: the regulated bus side does not move the controlled bus" begin + # PSS/E CONT1<0 marks which side of the transformer regulates; PSY keeps that as + # `regulated_bus_side`, and the controlled bus is the regulated bus itself. sys = _make_tap_shunt_system() tx = first(PSY.get_components(PSY.TwoWindingTransformer, sys)) - PSY.set_regulated_bus_number!(PSY.get_circuit(tx), -3) + PSY.set_regulated_bus!(PSY.get_circuit(tx), PSY.get_bus(sys, 3)) + PSY.set_regulated_bus_side!( + PSY.get_circuit(tx), + PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING, + ) data = PowerFlowData(ACPolarPowerFlow(), sys) bl = PF.get_bus_lookup(data) set = PowerFlows.build_controlled_device_set( @@ -266,12 +288,12 @@ end @test s.current ≈ 0.1 # baseline at BINIT, inside the reachable range end -@testset "discrete control: shunt controlled bus (regulated_bus_number)" begin +@testset "discrete control: shunt controlled bus (remote_regulated_bus)" begin function _shunt(regulated_bus_number = nothing) sys = _make_tap_shunt_system() sa = first(PSY.get_components(PSY.SwitchedAdmittance, sys)) isnothing(regulated_bus_number) || - PSY.set_regulated_bus_number!(sa, regulated_bus_number) + PSY.set_remote_regulated_bus!(sa, PSY.get_bus(sys, regulated_bus_number)) data = PowerFlowData(ACPolarPowerFlow(), sys) bl = PF.get_bus_lookup(data) set = PowerFlows.build_controlled_device_set( @@ -279,11 +301,11 @@ end return set.shunts[1], bl end - # regulated_bus_number set ⇒ remote controlled bus. + # remote_regulated_bus set ⇒ remote controlled bus. s_remote, bl = _shunt(2) @test s_remote.controlled_ix == bl[2] - # regulated_bus_number 0 (default) ⇒ falls back to the shunt's local bus (3). + # remote_regulated_bus nothing (default) ⇒ falls back to the shunt's local bus (3). s_local, bl2 = _shunt() @test s_local.controlled_ix == bl2[3] end @@ -615,7 +637,7 @@ end solve_power_flow!(data) @test all(data.converged) t = data.controlled_devices.taps[1] - # The controlled bus is the tap's FROM bus (set via regulated_bus_number = 2). + # The controlled bus is the tap's FROM bus (set via regulated_bus = b2). @test t.controlled_ix == t.from_ix @test t.current in t.levels @test abs(data.bus_magnitude[t.controlled_ix, 1] - t.vset) <= @@ -780,7 +802,7 @@ end PowerFlows.write_device_settings!(sys, data) fd = only(get_components(PSY.FACTSControlDevice, sys)) # Solver-populated delivered Q = b·|V|²·base_mva at the device bus (capacitive ⇒ > 0). - @test PSY.get_reactive_power_required(fd) > 0.0 + @test PSY.get_reactive_power_required(fd, PSY.SU) > 0.0 end @testset "discrete control: tap reads first-class PSY fields (#1684)" begin @@ -794,7 +816,7 @@ end @test t.p_max ≈ 1.15 @test length(t.levels) == 17 @test t.vset ≈ 1.02 - # regulated_bus_number = 3 ⇒ remote controlled bus, not the to-bus. + # regulated_bus = b3 ⇒ remote controlled bus, not the to-bus. @test t.controlled_ix == PNM.get_bus_lookup(data.power_network_matrix)[3] end @@ -1249,6 +1271,7 @@ end arc = Arc(; from = ref, to = bl), r = 0.01, x = 0.10, tap = 1.0, rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, + regulated_bus = bl, input_basis = PSY.CU), input_basis = PSY.CU), ) add_component!( diff --git a/test/test_fast_decoupled.jl b/test/test_fast_decoupled.jl index 23e2dea0..8336f98c 100644 --- a/test/test_fast_decoupled.jl +++ b/test/test_fast_decoupled.jl @@ -192,7 +192,7 @@ end TEST_DATA_DIR, "WECC240_v04_DPV_RE20_v33_6302_xfmr_DPbuscode_PFadjusted_V32_noRemoteVctrl.raw", ) - system = make_system( + system = PowerSystemCaseBuilder.system_from_openapi( PFP.PowerModelsData( file; bus_name_formatter = x -> @@ -1031,7 +1031,10 @@ end let data_probe = PF.PowerFlowData( ACPowerFlow{NewtonRaphsonACPowerFlow}(), - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test PF.get_lcc_count(data_probe) > 0 end @@ -1041,7 +1044,10 @@ end solution_parameters = SolutionParameters(; validate_voltage_magnitudes = false)) data_nr = PF.PowerFlowData( pf_nr, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test solve_power_flow!(data_nr) @@ -1050,7 +1056,10 @@ end validate_voltage_magnitudes = false)) data_fd = PF.PowerFlowData( pf_fd, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test PF.get_lcc_count(data_fd) > 0 @test solve_power_flow!(data_fd) @@ -1065,7 +1074,10 @@ end solution_parameters = SolutionParameters(; validate_voltage_magnitudes = false)) data_nr = PF.PowerFlowData( pf_nr, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test solve_power_flow!(data_nr) @@ -1074,7 +1086,10 @@ end validate_voltage_magnitudes = false)) data_fd = PF.PowerFlowData( pf_fd, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test PF.get_lcc_count(data_fd) > 0 @test solve_power_flow!(data_fd) @@ -1088,14 +1103,20 @@ end pf_nr = ACPowerFlow{NewtonRaphsonACPowerFlow}() data_nr = PF.PowerFlowData( pf_nr, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test solve_power_flow!(data_nr) pf_fd = ACPowerFlow{_fd_solver(:fixed_jacobian)}() data_fd = PF.PowerFlowData( pf_fd, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test PF.get_lcc_count(data_fd) > 0 @test solve_power_flow!(data_fd) @@ -1195,13 +1216,19 @@ end data_nr = PF.PowerFlowData( ACPowerFlow{NewtonRaphsonACPowerFlow}(), - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test solve_power_flow!(data_nr) data_fj = PF.PowerFlowData( ACPowerFlow{_fd_solver(:fixed_jacobian)}(), - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test solve_power_flow!(data_fj) @@ -1215,7 +1242,10 @@ end )) data_fd = PF.PowerFlowData( pf_fd, - make_system(PFP.PowerModelsData(lcc_raw); runchecks = false), + PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(lcc_raw); + runchecks = false, + ), ) @test PF.get_lcc_count(data_fd) > 0 @test solve_power_flow!(data_fd) diff --git a/test/test_hvdc.jl b/test/test_hvdc.jl index 900fc43c..172f254c 100644 --- a/test/test_hvdc.jl +++ b/test/test_hvdc.jl @@ -298,7 +298,10 @@ end # where [time_step, i] was incorrectly used instead of [i, time_step] # The bug only manifested when there were multiple LCC lines. raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) # Verify we have multiple LCC lines lcc_components = collect(get_components(TwoTerminalLCCLine, sys)) diff --git a/test/test_lcc_discrete_control.jl b/test/test_lcc_discrete_control.jl index 118ce459..db16915d 100644 --- a/test/test_lcc_discrete_control.jl +++ b/test/test_lcc_discrete_control.jl @@ -40,7 +40,10 @@ end @testset "continuation checkpoint restores LCC solver state" begin raw = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw); + runchecks = false, + ) pf = ACPolarPowerFlow(; check_reactive_power_limits = false) data = PowerFlowData(pf, sys) ts = 1 @@ -92,7 +95,10 @@ end @testset "LCC + control_discrete_devices constructs at any time_steps" begin raw = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw); + runchecks = false, + ) for nts in (1, 3) pf = ACPolarPowerFlow(; control_discrete_devices = true, time_steps = nts) data = PowerFlowData(pf, sys) # must not throw @@ -138,7 +144,9 @@ function build_locked_twin(results, ts::Int) sa = get_component(SwitchedAdmittance, sys, "ctrl_shunt_101") set_solved_admittance!(sa, shunt_final) set_reactive_power_required!( - get_component(FACTSControlDevice, sys, "ctrl_facts_101"), facts_q) + get_component(FACTSControlDevice, sys, "ctrl_facts_101"), + facts_q / get_base_power(sys) * PSY.SU, + ) add_component!( sys, FixedAdmittance(; diff --git a/test/test_lcc_zero_setpoint.jl b/test/test_lcc_zero_setpoint.jl index df05a8dd..794cc68f 100644 --- a/test/test_lcc_zero_setpoint.jl +++ b/test/test_lcc_zero_setpoint.jl @@ -14,7 +14,10 @@ const _ZERO_SP_FORMULATIONS = ( # Build case5_2_lcc with the first LCC forced to a 0-MW transfer setpoint. function _zero_setpoint_lcc_system() raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) set_transfer_setpoint!(first(get_components(TwoTerminalLCCLine, sys)), 0.0) return sys end diff --git a/test/test_lossy_dc_power_flow.jl b/test/test_lossy_dc_power_flow.jl index 4f8f577c..5dd9cce2 100644 --- a/test/test_lossy_dc_power_flow.jl +++ b/test/test_lossy_dc_power_flow.jl @@ -151,7 +151,10 @@ end raw_path = joinpath(export_dir, "export_1_1.raw") @test isfile(raw_path) - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) data = PowerFlowData( DCPowerFlow(; correct_bustypes = true, lossy_flows = true), sys, diff --git a/test/test_mixed_cpb_lcc.jl b/test/test_mixed_cpb_lcc.jl index 4639937e..75a793fb 100644 --- a/test/test_mixed_cpb_lcc.jl +++ b/test/test_mixed_cpb_lcc.jl @@ -35,7 +35,10 @@ end # 4 LCC tail residual rows are satisfied at the polar-converged state. @testset "case5_2_lcc (PQ terminals)" begin raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) nrm = _mixed_lcc_residual_norm(sys) @test nrm < 1e-6 end diff --git a/test/test_mixed_cpb_polar_parity.jl b/test/test_mixed_cpb_polar_parity.jl index 09263f63..0eac8cb6 100644 --- a/test/test_mixed_cpb_polar_parity.jl +++ b/test/test_mixed_cpb_polar_parity.jl @@ -73,13 +73,19 @@ const MIXED_PARITY_SOLVERS = @testset "LCC HVDC (case5_2_lcc, PQ terminals)" begin raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) sys_p = deepcopy(sys) sys_h = deepcopy(sys) sys_r = use_rect ? deepcopy(sys) : nothing + # The converter terminals' reactive power comes out of the LCC sub-iteration, whose + # stopping tolerance sets a round-off floor near 1e-6 between formulations; 1e-6 + # still pins parity, as for the multi-swing fixture below. _mixed_polar_parity( sys_p, sys_h; - sys_r = sys_r, solver = solver, + sys_r = sys_r, solver = solver, atol = 1e-6, ) end diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index 36f4626d..ddf0da12 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -592,24 +592,28 @@ end @test length(collect(PSY.get_components(PSY.TwoTerminalVSCLine, sys2))) == n0 + 1 end -@testset "PSSE Exporter FACTS: RMPCT blank, FCREG/REMOT from regulated_bus_number (v33/v35)" begin - # `reactive_power_required` is a solver OUTPUT, not the PSS/E RMPCT input, and PSY models no - # RMPCT, so the field is written blank — the exporter must not fall back to `ext`. FCREG/REMOT - # come from the first-class `regulated_bus_number` field. +@testset "PSSE Exporter FACTS: RMPCT from the sharing share, FCREG/REMOT from the remote bus (v33/v35)" begin + # `reactive_power_required` is a solver OUTPUT, not the PSS/E RMPCT input; a device outside + # any ReactivePowerSharing group holds the whole share, so RMPCT is 100 and never falls back + # to `ext`. FCREG/REMOT come from the first-class `remote_regulated_bus` field. sys = System(100.0) b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230, 1.0, 0.0) b7 = _add_simple_bus!(sys, 7, ACBusTypes.PQ, 230, 1.0, 0.0) _add_simple_source!(sys, b1, 0.0, 0.0) - _add_simple_line!(sys, b1, b7, 0.01, 0.10, 0.0) + _add_simple_line!(sys, b1, b2, 0.01, 0.10, 0.0) + _add_simple_line!(sys, b2, b7, 0.01, 0.10, 0.0) + # The device sits on a load bus: a device on the reference bus cannot regulate another bus. facts = PSY.FACTSControlDevice(; name = "facts_1", available = true, - bus = b1, + bus = b2, control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = 1.0, - regulated_bus_number = 7, + remote_regulated_bus = b7, reactive_power_required = 42.0, # solved output; must NOT be written as RMPCT - ext = Dict{String, Any}("RMPCT" => 55.0), input_basis = PSY.CU, # stale ext; the exporter must ignore it + ext = Dict{String, Any}("RMPCT" => 55.0), # stale ext; the exporter must ignore it + input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the MVA input. @@ -629,34 +633,37 @@ end fields = strip.(split(facts_line, ",")) # v33 record: NAME, I, J, MODE, PDES, QDES, VSET, SHMX, TRMX, VTMN, VTMX, VSMX, IMX, LINX, # RMPCT, OWNER, SET1, SET2, VSREF, REMOT - @test isempty(fields[15]) + @test fields[15] == "100.0" @test fields[20] == "7" @test !occursin("42.0", facts_line) @test !occursin("55.0", facts_line) sys2 = read_system_with_metadata(raw_path, metadata_path) facts2 = only(collect(PSY.get_components(PSY.FACTSControlDevice, sys2))) - @test PSY.get_regulated_bus_number(facts2) == 7 + @test PSY.get_number(PSY.get_remote_regulated_bus(facts2)) == 7 end -@testset "PSSE Exporter: switched shunt control_mode/regulated_bus_number round-trip (v33)" begin +@testset "PSSE Exporter: switched shunt control_mode/remote_regulated_bus round-trip (v33)" begin # MODSW/SWREM must survive export + reimport so parsed switched shunts keep # regulating instead of silently defaulting to FIXED (control_mode = 0). sys = System(100.0) b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230, 1.0, 0.0) b7 = _add_simple_bus!(sys, 7, ACBusTypes.PQ, 230, 1.0, 0.0) _add_simple_source!(sys, b1, 0.0, 0.0) - _add_simple_line!(sys, b1, b7, 0.01, 0.10, 0.0) + _add_simple_line!(sys, b1, b2, 0.01, 0.10, 0.0) + _add_simple_line!(sys, b2, b7, 0.01, 0.10, 0.0) + # The shunt sits on a load bus: a device on the reference bus cannot regulate another bus. shunt = PSY.SwitchedAdmittance(; name = "shunt_1", available = true, - bus = b1, + bus = b2, number_engaged = [1], number_of_steps = [4], Y_increase = [0.0 + 0.05im], admittance_limits = (min = 0.9, max = 1.1), control_mode = PSY.SwitchedAdmittanceControlMode.DISCRETE_VOLTAGE, - regulated_bus_number = 7, + remote_regulated_bus = b7, ) PSY.add_component!(sys, shunt) @@ -668,7 +675,7 @@ end sys2 = read_system_with_metadata(raw_path, metadata_path) shunt2 = only(collect(PSY.get_components(PSY.SwitchedAdmittance, sys2))) @test PSY.get_control_mode(shunt2) == PSY.SwitchedAdmittanceControlMode.DISCRETE_VOLTAGE - @test PSY.get_regulated_bus_number(shunt2) == 7 + @test PSY.get_number(PSY.get_remote_regulated_bus(shunt2)) == 7 end @testset "PSSE Exporter: v35 switched shunt Si is a 0/1 block status" begin diff --git a/test/test_psse_export_remote_control.jl b/test/test_psse_export_remote_control.jl new file mode 100644 index 00000000..1b084b94 --- /dev/null +++ b/test/test_psse_export_remote_control.jl @@ -0,0 +1,259 @@ +# The PSS/E writer carries every remote and shared voltage control field: IREG/VS/RMPCT on +# generators, the signed CONT and CR/CX on transformer windings, ICR and IFR/ITR/IDR on the +# two-terminal DC line, REMOT/RMPCT on VSC converters, FCREG/REMOT on FACTS and SWREG/SWREM on +# switched shunts. Each case exports on both versions and re-imports through the parser, so the +# assertions are on the PowerSystems objects a user gets back. + +const _REMOTE_CONTROL_EXPORT_DIR = joinpath(BASE_DIR, "test", "test_exports") + +"""Re-import an export with its metadata, the way PowerSystemCaseBuilder's reimport does.""" +function _reimport_export(raw_path, metadata_path) + return System(raw_path, Dict(JSON3.read(metadata_path, Dict))) +end + +"""A voltage-controlling two-winding transformer between `from_bus` and `to_bus`.""" +function _add_control_transformer!( + sys::System, + name::AbstractString, + from_bus::ACBus, + to_bus::ACBus; + control_objective = PSY.TransformerControlObjective.VOLTAGE, + regulated_bus = nothing, + regulated_bus_side = nothing, + load_drop_compensation = 0.0 + 0.0im, +) + tx = TwoWindingTransformer(; + name = name, + circuit = TransformerCircuit(; + available = true, + arc = Arc(; from = from_bus, to = to_bus), + r = 0.001, + x = 0.05, + tap = 1.0, + rating = 2.0, + base_power = 100.0, + control_objective = control_objective, + regulated_bus = regulated_bus, + regulated_bus_side = regulated_bus_side, + load_drop_compensation = load_drop_compensation, + control_limits = (min = 0.9, max = 1.1), + controlled_quantity_limits = (min = 0.95, max = 1.05), + input_basis = CU, + ), + input_basis = CU, + ) + add_component!(sys, tx) + return tx +end + +"""The network of PowerFlowFileParser's synthetic remote-control fixtures, built directly.""" +function _remote_control_system() + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 138, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PV, 138, 1.01, 0.0) + b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 138, 1.02, 0.0) + b4 = _add_simple_bus!(sys, 4, ACBusTypes.PV, 138, 1.0, 0.0) + b5 = _add_simple_bus!(sys, 5, ACBusTypes.PQ, 138, 1.0, 0.0) + b6 = _add_simple_bus!(sys, 6, ACBusTypes.PQ, 138, 1.0, 0.0) + b7 = _add_simple_bus!(sys, 7, ACBusTypes.PQ, 138, 1.0, 0.0) + _add_simple_thermal_standard!(sys, b1, 0.8, 0.0) + _add_simple_load!(sys, b3, 0.5, 0.1) + _add_simple_load!(sys, b5, 0.3, 0.05) + _add_simple_load!(sys, b7, 0.2, 0.05) + _add_simple_line!(sys, b1, b2, 0.01, 0.1, 0.02) + _add_simple_line!(sys, b2, b3, 0.01, 0.1, 0.02) + _add_simple_line!(sys, b3, b4, 0.01, 0.1, 0.02) + _add_simple_line!(sys, b4, b5, 0.01, 0.1, 0.02) + _add_simple_line!(sys, b5, b7, 0.01, 0.1, 0.02) + + g2 = _add_simple_thermal_standard!(sys, b2, 0.3, 0.0) + set_remote_regulated_bus!(g2, b3) + set_voltage_setpoint!(g2, 1.03) + g4 = _add_simple_thermal_standard!(sys, b4, 0.3, 0.0) + set_remote_regulated_bus!(g4, b3) + set_voltage_setpoint!(g4, 1.03) + + _add_control_transformer!( + sys, "xfmr_3_6", b3, b6; regulated_bus = b3, + load_drop_compensation = 0.01 + 0.02im, + ) + _add_control_transformer!( + sys, "xfmr_5_6", b5, b6; regulated_bus = b7, + regulated_bus_side = PSY.TransformerRegulatedBusSide.OPPOSITE_WINDING, + ) + dc_tap = _add_control_transformer!( + sys, "xfmr_6_7_dc", b6, b7; + control_objective = PSY.TransformerControlObjective.CONTROL_OF_DC_LINE, + ) + _add_control_transformer!( + sys, "xfmr_6_7_v", b6, b7; + control_objective = PSY.TransformerControlObjective.VOLTAGE_DISABLED, + regulated_bus = b4, + regulated_bus_side = PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING, + ) + lcc = _add_simple_lcc!(sys, b5, b7, 5.0, 0.1, 0.1) + set_rectifier_commutating_bus!(lcc, b4) + set_rectifier_tap_transformer!(lcc, dc_tap) + + vsc = _add_simple_vsc!(sys, b4, b6) + set_dc_control_from!(vsc, PSY.VSCDCControlModes.DC_VOLTAGE) + set_dc_setpoint_from!(vsc, 1.0) + set_rated_dc_voltage!(vsc, 150.0) + set_ac_control_from!(vsc, PSY.VSCACControlModes.AC_VOLTAGE) + set_ac_setpoint_from!(vsc, 1.03) + set_remote_regulated_bus_from!(vsc, b3) + + facts = FACTSControlDevice(; + name = "facts_7", available = true, bus = b7, + control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = 1.0, + input_basis = CU, + ) + set_max_shunt_current!(facts, 200.0 * u"MVA") + add_component!(sys, facts) + shunt = SwitchedAdmittance(; + name = "shunt_6", available = true, bus = b6, number_engaged = [1], + number_of_steps = [2], Y_increase = [0.0 + 0.05im], + admittance_limits = (min = 0.95, max = 1.05), + control_mode = PSY.SwitchedAdmittanceControlMode.DISCRETE_VOLTAGE, + remote_regulated_bus = b7, + ) + add_component!(sys, shunt) + + sharing3 = ReactivePowerSharing(; name = "share3") + add_supplemental_attribute!(sys, g2, sharing3; weight = 0.6) + add_supplemental_attribute!(sys, g4, sharing3; weight = 0.4) + add_supplemental_attribute!( + sys, vsc, sharing3; weight = 0.5, terminal = PSY.VoltageControlTerminal.FROM, + ) + sharing7 = ReactivePowerSharing(; name = "share7") + add_supplemental_attribute!(sys, shunt, sharing7; weight = 2.0) + add_supplemental_attribute!(sys, facts, sharing7; weight = 1.0) + return sys +end + +_number_of(::Nothing) = nothing +_number_of(bus::ACBus) = get_number(bus) + +_circuit_named(sys, name) = get_circuit(get_component(TwoWindingTransformer, sys, name)) + +@testset "PSSE Exporter: remote and shared voltage control round trip ($version)" for version in + ( + :v33, + :v35, +) + begin + sys = _remote_control_system() + export_location = + joinpath(_REMOTE_CONTROL_EXPORT_DIR, string(version), "remote_voltage_control") + exporter = PSSEExporter(sys, version, export_location; write_comments = true) + write_export(exporter, "basic"; overwrite = true) + raw_path, metadata_path = + get_psse_export_paths(joinpath(export_location, "basic")) + sys2 = _reimport_export(raw_path, metadata_path) + + # IREG, VS and RMPCT on the generators + g2 = get_component(ThermalStandard, sys2, "thermal_standard_2") + g4 = get_component(ThermalStandard, sys2, "thermal_standard_4") + g1 = get_component(ThermalStandard, sys2, "thermal_standard_1") + @test _number_of(get_remote_regulated_bus(g2)) == 3 + @test _number_of(get_remote_regulated_bus(g4)) == 3 + @test isnothing(get_remote_regulated_bus(g1)) + @test get_voltage_setpoint(g2) == 1.03 + @test get_voltage_setpoint(g1) == 1.0 + group3 = only(get_supplemental_attributes(ReactivePowerSharing, g2)) + vsc = only(get_components(TwoTerminalVSCLine, sys2)) + @test Set(get_associated_components(sys2, group3)) == Set([g2, g4, vsc]) + # RMPCT is the share in percent, so the weights come back normalized to one, at the + # precision of the record + @test get_weight(group3, g2) ≈ 0.6 / 1.5 atol = 1e-6 + @test get_weight(group3, g4) ≈ 0.4 / 1.5 atol = 1e-6 + @test get_weight(group3, vsc) ≈ 0.5 / 1.5 atol = 1e-6 + @test get_terminal(group3, vsc) == PSY.VoltageControlTerminal.FROM + @test _number_of(get_remote_regulated_bus_from(vsc)) == 3 + @test isnothing(get_remote_regulated_bus_to(vsc)) + + # CONT with its sign, CR + jCX + local_circuit = _circuit_named(sys2, "xfmr_3_6") + @test _number_of(get_regulated_bus(local_circuit)) == 3 + @test get_regulated_bus_side(local_circuit) == + PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING + @test get_load_drop_compensation(local_circuit, PSY.SU) ≈ 0.01 + 0.02im + remote_circuit = _circuit_named(sys2, "xfmr_5_6") + @test _number_of(get_regulated_bus(remote_circuit)) == 7 + @test get_regulated_bus_side(remote_circuit) == + PSY.TransformerRegulatedBusSide.OPPOSITE_WINDING + disabled_circuit = _circuit_named(sys2, "xfmr_6_7_v") + @test _number_of(get_regulated_bus(disabled_circuit)) == 4 + @test get_regulated_bus_side(disabled_circuit) == + PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING + dc_tap = get_component(TwoWindingTransformer, sys2, "xfmr_6_7_dc") + @test isnothing(get_regulated_bus(get_circuit(dc_tap))) + + # ICR and IFR/ITR/IDR + lcc = only(get_components(TwoTerminalLCCLine, sys2)) + @test _number_of(get_rectifier_commutating_bus(lcc)) == 4 + @test isnothing(get_inverter_commutating_bus(lcc)) + @test get_rectifier_tap_transformer(lcc) === dc_tap + @test isnothing(get_inverter_tap_transformer(lcc)) + + # FCREG/REMOT, SWREG/SWREM and their shares + facts = only(get_components(FACTSControlDevice, sys2)) + shunt = only(get_components(SwitchedAdmittance, sys2)) + @test isnothing(get_remote_regulated_bus(facts)) + @test _number_of(get_remote_regulated_bus(shunt)) == 7 + group7 = only(get_supplemental_attributes(ReactivePowerSharing, facts)) + @test Set(get_associated_components(sys2, group7)) == Set([facts, shunt]) + @test get_weight(group7, shunt) ≈ 2.0 / 3.0 atol = 1e-6 + @test get_weight(group7, facts) ≈ 1.0 / 3.0 atol = 1e-6 + end +end + +@testset "PSSE Exporter: a three-winding circuit regulating its star bus writes CONT 0 ($version)" for version in + ( + :v33, + :v35, +) + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230, 1.0, 0.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 138, 1.0, 0.0) + b3 = _add_simple_bus!(sys, 3, ACBusTypes.PQ, 69, 1.0, 0.0) + _add_simple_thermal_standard!(sys, b1, 0.5, 0.0) + _add_simple_load!(sys, b2, 0.2, 0.05) + _add_simple_load!(sys, b3, 0.1, 0.02) + tx = _add_simple_transformer_3w!(sys, b1, b2, b3, 1001; available_tertiary = true) + # The PSS/E record carries the pairwise impedances, which the helper leaves unset. + for (setter, value) in ( + (set_r_12!, 0.02), (set_x_12!, 0.14), (set_r_23!, 0.02), (set_x_23!, 0.11), + (set_r_31!, 0.02), (set_x_31!, 0.13), + ) + setter(tx, value * CU) + end + set_base_power_12!(tx, 100.0) + set_base_power_23!(tx, 100.0) + set_base_power_31!(tx, 100.0) + circuit = get_primary_circuit(tx) + # The star bus has no PSS/E number; PSS/E spells "the winding's far end" as CONT 0. + set_control_objective!(circuit, PSY.TransformerControlObjective.VOLTAGE) + set_regulated_bus!(circuit, get_star_bus(tx)) + + export_location = + joinpath( + _REMOTE_CONTROL_EXPORT_DIR, + string(version), + "three_winding_star_regulation", + ) + exporter = PSSEExporter(sys, version, export_location; write_comments = true) + write_export(exporter, "basic"; overwrite = true) + raw_path, metadata_path = get_psse_export_paths(joinpath(export_location, "basic")) + winding_records = filter( + line -> startswith(line, "1.0"), + readlines(raw_path), + ) + @test !isempty(winding_records) + cont_index = version == :v35 ? 17 : 8 + @test all(strip.(split(record, ","))[cont_index] == "0" for record in winding_records) + + sys2 = _reimport_export(raw_path, metadata_path) + tx2 = only(get_components(ThreeWindingTransformer, sys2)) + @test get_regulated_bus(get_primary_circuit(tx2)) === get_star_bus(tx2) +end diff --git a/test/test_psse_remote_control_fixtures.jl b/test/test_psse_remote_control_fixtures.jl new file mode 100644 index 00000000..bd9cc4a5 --- /dev/null +++ b/test/test_psse_remote_control_fixtures.jl @@ -0,0 +1,201 @@ +# End-to-end round trip of PowerFlowFileParser's synthetic remote voltage control cases: the +# RAW file is imported the way a user does it (parser -> OpenAPI document -> PowerSystems), +# the PowerSystems objects are checked, the system is written back with the PSS/E exporter, +# re-imported and checked again, and the control fields of the exported RAW are compared with +# the original record by record. + +import PowerFlowFileParser + +const _REMOTE_CONTROL_EXPORT_DIR = joinpath(BASE_DIR, "test", "test_exports") + +"""Re-import an export with its metadata, the way PowerSystemCaseBuilder's reimport does.""" +function _reimport_export(raw_path, metadata_path) + return System(raw_path, Dict(JSON3.read(metadata_path, Dict))) +end + +const _REMOTE_CONTROL_FIXTURE_DIR = + joinpath(pkgdir(PowerFlowFileParser), "test", "fixtures") + +_remote_control_fixture(version) = + joinpath(_REMOTE_CONTROL_FIXTURE_DIR, "synthetic_$(version)_remote_control.raw") + +_at_bus(components, number) = + only(c for c in components if get_number(get_bus(c)) == number) + +_circuit_between(sys, from, to, objective) = only( + get_circuit(t) for t in get_components(TwoWindingTransformer, sys) if + get_number(get_from(get_arc(t))) == from && get_number(get_to(get_arc(t))) == to && + get_control_objective(get_circuit(t)) == objective +) + +"""Every voltage control field the fixture carries, on the PowerSystems objects.""" +function _check_remote_control_fixture(sys::System) + thermal = collect(get_components(ThermalStandard, sys)) + bus2_gens = filter(g -> get_number(get_bus(g)) == 2, thermal) + g21 = only(filter(g -> !isnothing(get_remote_regulated_bus(g)), bus2_gens)) + g22 = only(filter(g -> isnothing(get_remote_regulated_bus(g)), bus2_gens)) + g41 = _at_bus(thermal, 4) + g11 = _at_bus(thermal, 1) + + # IREG names the remote bus; IREG equal to the own bus and IREG = 0 both mean own-bus + # control. VS is the setpoint at the regulated bus. + @test get_number(get_remote_regulated_bus(g21)) == 3 + @test get_number(get_remote_regulated_bus(g41)) == 3 + @test isnothing(get_remote_regulated_bus(g22)) + @test isnothing(get_remote_regulated_bus(g11)) + @test get_number(get_regulated_bus(g22)) == 2 + @test get_voltage_setpoint(g21) == 1.02 + @test get_voltage_setpoint(g22) == 1.01 + + # Two generators and a VSC converter hold bus 3 with unequal RMPCT. + vsc = only(get_components(TwoTerminalVSCLine, sys)) + @test get_number(get_remote_regulated_bus_from(vsc)) == 3 + @test isnothing(get_remote_regulated_bus_to(vsc)) + group3 = only(get_supplemental_attributes(ReactivePowerSharing, g21)) + @test Set(get_associated_components(sys, group3)) == Set([g21, g41, vsc]) + # Ratios survive at the precision of the RMPCT record. + @test get_weight(group3, g21) / get_weight(group3, g41) ≈ 60 / 40 rtol = 1e-6 + @test get_weight(group3, vsc) / get_weight(group3, g41) ≈ 50 / 40 rtol = 1e-6 + @test get_terminal(group3, vsc) == PSY.VoltageControlTerminal.FROM + @test isempty(get_supplemental_attributes(ReactivePowerSharing, g22)) + @test isempty(get_supplemental_attributes(ReactivePowerSharing, g11)) + + # A switched shunt with a remote target shares bus 7 with a FACTS device on that bus. + shunts = collect(get_components(SwitchedAdmittance, sys)) + shunt6 = _at_bus(shunts, 6) + shunt5 = _at_bus(shunts, 5) + facts = only(get_components(FACTSControlDevice, sys)) + @test get_number(get_remote_regulated_bus(shunt6)) == 7 + @test get_number(get_remote_regulated_bus(shunt5)) == 2 + @test isnothing(get_remote_regulated_bus(facts)) + group7 = only(get_supplemental_attributes(ReactivePowerSharing, facts)) + @test Set(get_associated_components(sys, group7)) == Set([shunt6, facts]) + @test get_weight(group7, shunt6) == get_weight(group7, facts) + + # CONT of both signs, CR + jCX, and a winding whose objective regulates no voltage. + local_circuit = + _circuit_between(sys, 3, 6, PSY.TransformerControlObjective.VOLTAGE) + @test get_number(get_regulated_bus(local_circuit)) == 3 + @test get_regulated_bus_side(local_circuit) == + PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING + @test get_load_drop_compensation(local_circuit, PSY.SU) ≈ 0.01 + 0.02im + remote_circuit = + _circuit_between(sys, 5, 6, PSY.TransformerControlObjective.VOLTAGE) + @test get_number(get_regulated_bus(remote_circuit)) == 7 + @test get_regulated_bus_side(remote_circuit) == + PSY.TransformerRegulatedBusSide.OPPOSITE_WINDING + @test iszero(get_load_drop_compensation(remote_circuit, PSY.SU)) + disabled_circuit = + _circuit_between(sys, 6, 7, PSY.TransformerControlObjective.VOLTAGE_DISABLED) + @test get_number(get_regulated_bus(disabled_circuit)) == 4 + @test get_regulated_bus_side(disabled_circuit) == + PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING + dc_circuit = + _circuit_between(sys, 6, 7, PSY.TransformerControlObjective.CONTROL_OF_DC_LINE) + @test isnothing(get_regulated_bus(dc_circuit)) + + # ICR names the rectifier's commutating bus and IFR/ITR/IDR its tap transformer. + lcc = only(get_components(TwoTerminalLCCLine, sys)) + @test get_number(get_rectifier_commutating_bus(lcc)) == 4 + @test isnothing(get_inverter_commutating_bus(lcc)) + @test get_circuit(get_rectifier_tap_transformer(lcc)) === dc_circuit + @test isnothing(get_inverter_tap_transformer(lcc)) + return +end + +""" +The voltage control fields of a parsed RAW file in a form that is invariant to what the +exporter may legitimately change: bus numbers are kept, circuit identifiers are replaced +by the transformer's control tuple, own-bus control is spelled as the own bus whether the +record says 0 or the bus itself, and RMPCT is normalized to each regulated bus's total, since +PSS/E reads the percentages relative to one another. +""" +function _raw_control_fields(path::AbstractString) + pm = PowerFlowFileParser.parse_file(path) + own_or_remote(remote, own) = iszero(remote) ? Int(own) : Int(remote) + + transformers = Dict{Tuple{Int, Int, String}, Tuple{Int, Int, Int, Float64, Float64}}() + for d in values(pm["branch"]) + d["transformer"] || continue + ckt = strip(String(d["source_id"][5])) + transformers[(d["f_bus"], d["t_bus"], ckt)] = + (d["f_bus"], d["t_bus"], Int(d["CONT1"]), d["CR1"], d["CX1"]) + end + + shares = Dict{Tuple, Tuple{Int, Float64}}() + for d in values(pm["gen"]) + key = ("gen", Int(d["gen_bus"]), strip(String(d["source_id"][3]))) + shares[key] = (own_or_remote(d["regulated_bus_number"], d["gen_bus"]), d["rmpct"]) + end + for d in values(pm["switched_shunt"]) + key = ("shunt", Int(d["shunt_bus"])) + shares[key] = (own_or_remote(d["regulated_bus_number"], d["shunt_bus"]), d["rmpct"]) + end + for d in values(pm["facts"]) + key = ("facts", Int(d["source_id"][2])) + shares[key] = + (own_or_remote(d["regulated_bus_number"], d["source_id"][2]), d["rmpct"]) + end + for d in values(pm["vscline"]) + shares[("vsc", Int(d["f_bus"]), :from)] = + (own_or_remote(d["remote_bus_number_from"], d["f_bus"]), d["rmpct_from"]) + shares[("vsc", Int(d["t_bus"]), :to)] = + (own_or_remote(d["remote_bus_number_to"], d["t_bus"]), d["rmpct_to"]) + end + totals = Dict{Int, Float64}() + for (bus, rmpct) in values(shares) + totals[bus] = get(totals, bus, 0.0) + rmpct + end + normalized = Dict( + key => (bus, round(rmpct / totals[bus]; digits = 6)) for + (key, (bus, rmpct)) in shares + ) + + tap_transformer(tuple) = + if iszero(tuple[1]) + nothing + else + transformers[(Int(tuple[1]), Int(tuple[2]), strip(String(tuple[3])))] + end + lcc = only(values(pm["dcline"])) + lcc_fields = ( + rectifier_bus = Int(lcc["rectifier_commutating_bus_number"]), + inverter_bus = Int(lcc["inverter_commutating_bus_number"]), + rectifier_tap = tap_transformer(lcc["rectifier_tap_transformer"]), + inverter_tap = tap_transformer(lcc["inverter_tap_transformer"]), + ) + return (; + transformers = Set(values(transformers)), + shares = normalized, + lcc = lcc_fields, + ) +end + +@testset "PSSE Exporter: remote voltage control fixture round trip ($version)" for version in + ( + :v33, + :v35, +) + fixture = _remote_control_fixture(version) + sys = PowerSystemCaseBuilder.system_from_openapi( + PowerFlowFileParser.PowerModelsData(fixture), + ) + _check_remote_control_fixture(sys) + + export_location = + joinpath( + _REMOTE_CONTROL_EXPORT_DIR, + string(version), + "remote_voltage_control_fixture", + ) + exporter = PSSEExporter(sys, version, export_location; write_comments = true) + write_export(exporter, "fixture"; overwrite = true) + raw_path, metadata_path = get_psse_export_paths(joinpath(export_location, "fixture")) + _check_remote_control_fixture(_reimport_export(raw_path, metadata_path)) + + original = _raw_control_fields(fixture) + exported = _raw_control_fields(raw_path) + @test exported.transformers == original.transformers + @test exported.shares == original.shares + @test exported.lcc == original.lcc +end diff --git a/test/test_rectangular_ci_lcc.jl b/test/test_rectangular_ci_lcc.jl index 64910905..ccf089a5 100644 --- a/test/test_rectangular_ci_lcc.jl +++ b/test/test_rectangular_ci_lcc.jl @@ -4,7 +4,10 @@ end @testset "Rectangular CI LCC: residual zero at polar-converged state" begin raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() @test PF.solve_and_store_power_flow!(pf_p, sys) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; @@ -94,7 +97,10 @@ end @testset "Rectangular CI LCC: asymptotic Jacobian verification on case5_2_lcc" begin raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() PF.solve_and_store_power_flow!(pf_p, sys) pf_r = ACRectangularPowerFlow{NewtonRaphsonACPowerFlow}(; @@ -118,7 +124,10 @@ end @testset "Rectangular CI LCC: solve parity with polar" begin raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) sys_p = deepcopy(sys) sys_r = deepcopy(sys) pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() @@ -134,7 +143,10 @@ end @testset "Rectangular CI LCC: step strategy variants" begin raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) pf_p = ACPowerFlow{NewtonRaphsonACPowerFlow}() res_p = solve_power_flow(pf_p, deepcopy(sys)) for (label, solver, extra_settings) in [ diff --git a/test/test_residual_condition_diagnostics.jl b/test/test_residual_condition_diagnostics.jl index aabf306f..c74f39a3 100644 --- a/test/test_residual_condition_diagnostics.jl +++ b/test/test_residual_condition_diagnostics.jl @@ -63,7 +63,7 @@ end @testset "Schur min-eigenvalue matches dense ground truth (LCC)" begin # On an LCC system the Schur complement projects out the converter states; # the matvec must still match the dense inv(inv(J)[1:nb, 1:nb]) eigenvalue. - sys = make_system( + sys = PowerSystemCaseBuilder.system_from_openapi( PFP.PowerModelsData(joinpath(TEST_DATA_DIR, "case5_2_lcc.raw")); runchecks = false, ) @@ -130,7 +130,7 @@ end end @testset "log_solver_diagnostics works on LCC systems" begin - sys = make_system( + sys = PowerSystemCaseBuilder.system_from_openapi( PFP.PowerModelsData(joinpath(TEST_DATA_DIR, "case5_2_lcc.raw")); runchecks = false, ) diff --git a/test/test_robust_power_flow.jl b/test/test_robust_power_flow.jl index 22fd5e6d..722e076e 100644 --- a/test/test_robust_power_flow.jl +++ b/test/test_robust_power_flow.jl @@ -20,7 +20,10 @@ end # side-aware branch of both the LCC Jacobian and the homotopy Hessian. function _case5_lcc_system(; setpoint_at_inverter::Bool = false) raw_path = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw_path); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw_path); + runchecks = false, + ) if setpoint_at_inverter for lcc in get_components(PSY.TwoTerminalLCCLine, sys) set_transfer_setpoint!(lcc, -abs(get_transfer_setpoint(lcc))) diff --git a/test/test_solve_power_flow.jl b/test/test_solve_power_flow.jl index 92848c7b..deae901f 100644 --- a/test/test_solve_power_flow.jl +++ b/test/test_solve_power_flow.jl @@ -158,7 +158,7 @@ end TEST_DATA_DIR, "WECC240_v04_DPV_RE20_v33_6302_xfmr_DPbuscode_PFadjusted_V32_noRemoteVctrl.raw", ) - system = make_system( + system = PowerSystemCaseBuilder.system_from_openapi( PFP.PowerModelsData( file; bus_name_formatter = x -> diff --git a/test/test_utils/common.jl b/test/test_utils/common.jl index a0f37d4c..d3071828 100644 --- a/test/test_utils/common.jl +++ b/test/test_utils/common.jl @@ -577,6 +577,7 @@ function _make_tap_shunt_system() rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, + regulated_bus = b2, controlled_quantity_limits = (min = 1.0, max = 1.0), input_basis = PSY.CU, ), input_basis = PSY.CU, ) @@ -652,6 +653,7 @@ function _make_solvable_tap_shunt_system() rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, + regulated_bus = b2, controlled_quantity_limits = (min = 1.0, max = 1.0), input_basis = PSY.CU, ), input_basis = PSY.CU, ) @@ -703,7 +705,7 @@ function _make_svc_system(; bus = b2, control_mode = control_mode, voltage_setpoint = 1.0, - reactive_power_required = 100.0, input_basis = PSY.CU, + input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the caller's MVA input. @@ -889,7 +891,7 @@ function _make_multiperiod_facts_system() control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = 1.0, shunt_control_type = PSY.FACTSShuntControlType.SVC, - reactive_power_required = 100.0, input_basis = PSY.CU, + input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the MVA input. @@ -906,7 +908,7 @@ end the PQ bus, for multiperiod discrete-control tests (reset-to-baseline tap design). Mirrors `_make_solvable_tap_shunt_system`'s impedance (r=0.01, x=0.10) and base load (0.5+j0.25) so the tap has full authority over bus 2; the explicit control fields (`tap_limits`, -`number_of_tap_positions`, `regulated_bus_number`, `voltage_setpoint`) pin a fine tap grid +`number_of_tap_positions`, `regulated_bus`, `voltage_setpoint`) pin a fine tap grid (31 positions over [0.85, 1.15]) so `_set_multiperiod_tap_loads!`'s per-step reactive-load scaling drives the required tap to a DIFFERENT discrete position at each time step.""" function _make_multiperiod_tap_system() @@ -928,7 +930,7 @@ function _make_multiperiod_tap_system() base_power = 100.0, control_limits = (min = 0.85, max = 1.15), number_of_tap_positions = 31, - regulated_bus_number = 2, + regulated_bus = b2, controlled_quantity_limits = (min = 1.0, max = 1.0), control_objective = PSY.TransformerControlObjective.VOLTAGE, input_basis = PSY.CU, @@ -992,7 +994,7 @@ function _make_shunt_snap_system() end """Build a 3-bus system with one voltage-controlling `TwoWindingTransformer` whose controllability is set -through the FIRST-CLASS PSY fields (`tap_limits`, `number_of_tap_positions`, `regulated_bus_number`, +through the FIRST-CLASS PSY fields (`tap_limits`, `number_of_tap_positions`, `regulated_bus`, `voltage_setpoint`) — no `ext` scrape — to exercise the post-#1684 builder path. The tap (b1→b2) remotely regulates b3.""" function _make_field_controlled_tap_system() @@ -1014,7 +1016,10 @@ function _make_field_controlled_tap_system() base_power = 100.0, control_limits = (min = 0.85, max = 1.15), number_of_tap_positions = 17, - regulated_bus_number = 3, + # Bus 3 is neither end of the b1 -> b2 arc, so the side names the winding it + # lies beyond. + regulated_bus = b3, + regulated_bus_side = PSY.TransformerRegulatedBusSide.OPPOSITE_WINDING, controlled_quantity_limits = (min = 1.02, max = 1.02), control_objective = PSY.TransformerControlObjective.VOLTAGE, input_basis = PSY.CU, @@ -1051,7 +1056,7 @@ function _add_facts_shunt!( bus = b, control_mode = PSY.FACTSOperationModes.NML, voltage_setpoint = voltage_setpoint, - reactive_power_required = 100.0, input_basis = PSY.CU, + input_basis = PSY.CU, ) # `max_shunt_current` is stored in device base; the constructor kwarg takes a raw CU # value, so set it through the units-aware setter to honor the caller's MVA input. @@ -1098,7 +1103,7 @@ exercising the from-side control orientation (the plant-sign probe must measure opposite dV/dp sign to the usual to-side wiring). Topology: REF(1) ─line─ PQ(2) ─tap─ PQ(3); REF(1) ─line─ PQ(4). -Bus 2 is both the FROM bus of the tap and the controlled bus (set via `regulated_bus_number`). +Bus 2 is both the FROM bus of the tap and the controlled bus (set via `regulated_bus`). The tap has real authority over bus 2 voltage through the impedance seen by bus 2.""" function _make_primary_controlled_tap_system() sys = System(100.0) @@ -1126,8 +1131,10 @@ function _make_primary_controlled_tap_system() rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, + regulated_bus = b3, controlled_quantity_limits = (min = 1.0, max = 1.0), - regulated_bus_number = 2, input_basis = PSY.CU, # controlled bus = bus 2 (FROM) → primary + regulated_bus = b2, # controlled bus = bus 2 (FROM) → primary + input_basis = PSY.CU, ), input_basis = PSY.CU, ) add_component!(sys, tx) @@ -1288,6 +1295,7 @@ function _add_control_tap!(sys, from_bus, to_bus; name = "tap_ctrl") rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, + regulated_bus = to_bus, controlled_quantity_limits = (min = 1.0, max = 1.0), input_basis = PSY.CU, ), input_basis = PSY.CU, ) diff --git a/test/test_utils/cross_file_fixtures.jl b/test/test_utils/cross_file_fixtures.jl index 2dd23369..06cf815b 100644 --- a/test/test_utils/cross_file_fixtures.jl +++ b/test/test_utils/cross_file_fixtures.jl @@ -170,7 +170,10 @@ so the devices clear `CONTROL_GAIN_FLOOR` and enroll instead of being frozen as and their setpoints sit above the reachable voltage so the continuation keeps driving them.""" function build_lcc_control_system(; p_set_mw::Union{Nothing, Float64} = nothing) raw = joinpath(TEST_DATA_DIR, "case5_2_lcc.raw") - sys = make_system(PFP.PowerModelsData(raw); runchecks = false) + sys = PowerSystemCaseBuilder.system_from_openapi( + PFP.PowerModelsData(raw); + runchecks = false, + ) bus101 = get_bus(sys, 101) add_component!( sys, @@ -191,7 +194,7 @@ function build_lcc_control_system(; p_set_mw::Union{Nothing, Float64} = nothing) max_shunt_current = 1000.0, max_reactive_power = 9999.0, shunt_control_type = PSY.FACTSShuntControlType.STATCOM, - regulated_bus_number = 0, input_basis = PSY.CU, + input_basis = PSY.CU, ), ) if p_set_mw !== nothing From 11c91f9e7d1f41e059dbd9105404a004f21f2003 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 24 Sep 2026 10:06:06 -0700 Subject: [PATCH 10/14] Keep supplemental attributes when updating the exporter's system update_exporter! copied the system with the fast deep copy's default, which skips supplemental attributes, so a second export after an update lost every sharing group and impedance correction table. The copy now keeps them, and a testset re-imports an export made after update_exporter! and checks the shares survive. The droop VSC testset gives its DC-voltage-controlling converter a real DC voltage reference; a 0 kV schedule is not a record the parser accepts. --- src/psse_export.jl | 3 ++- test/test_psse_export.jl | 2 ++ test/test_psse_export_remote_control.jl | 20 ++++++++++++++++++++ test/test_utils/common.jl | 1 - 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/psse_export.jl b/src/psse_export.jl index 917106fd..f8894c7e 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -451,7 +451,8 @@ function update_exporter!(exporter::PSSEExporter, data::PSY.System) "System passed to update_exporter must be the same system as the one with which the exporter was constructed, just with different values", ), ) - exporter.system = PSY.fast_deepcopy_system(data) + exporter.system = + PSY.fast_deepcopy_system(data; skip_supplemental_attributes = false) reset_caches(exporter) return end diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index ddf0da12..8d6d0dba 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -1212,6 +1212,8 @@ end # One converter must keep a real DC-voltage reference or the record has no TYPE-1 # terminal and re-parsing rejects it; only the `to` side is droop, the case under test. PSY.set_dc_control_from!(vsc, PSY.VSCDCControlModes.DC_VOLTAGE) + PSY.set_rated_dc_voltage!(vsc, 400.0) + PSY.set_dc_setpoint_from!(vsc, 1.0) PSY.set_dc_control_to!(vsc, PSY.VSCDCControlModes.DC_VOLTAGE_DROOP) export_location = joinpath(test_psse_export_dir, "v35", "vsc_droop_dcset") diff --git a/test/test_psse_export_remote_control.jl b/test/test_psse_export_remote_control.jl index 1b084b94..bd5fff27 100644 --- a/test/test_psse_export_remote_control.jl +++ b/test/test_psse_export_remote_control.jl @@ -257,3 +257,23 @@ end tx2 = only(get_components(ThreeWindingTransformer, sys2)) @test get_regulated_bus(get_primary_circuit(tx2)) === get_star_bus(tx2) end + +@testset "PSSE Exporter: update_exporter! keeps the sharing groups" begin + sys = _remote_control_system() + export_location = + joinpath(_REMOTE_CONTROL_EXPORT_DIR, "v33", "remote_voltage_control_update") + exporter = PSSEExporter(sys, :v33, export_location) + write_export(exporter, "basic"; overwrite = true) + update_exporter!(exporter, sys) + write_export(exporter, "updated"; overwrite = true) + sys2 = _reimport_export(get_psse_export_paths(joinpath(export_location, "updated"))...) + # Without the groups every RMPCT is written as 100, which re-imports as equal shares. + g2 = get_component(ThermalStandard, sys2, "thermal_standard_2") + g4 = get_component(ThermalStandard, sys2, "thermal_standard_4") + group3 = only(get_supplemental_attributes(ReactivePowerSharing, g2)) + @test get_weight(group3, g2) ≈ 0.6 / 1.5 atol = 1e-6 + @test get_weight(group3, g4) ≈ 0.4 / 1.5 atol = 1e-6 + shunt = only(get_components(SwitchedAdmittance, sys2)) + group7 = only(get_supplemental_attributes(ReactivePowerSharing, shunt)) + @test get_weight(group7, shunt) ≈ 2.0 / 3.0 atol = 1e-6 +end diff --git a/test/test_utils/common.jl b/test/test_utils/common.jl index d3071828..b18750de 100644 --- a/test/test_utils/common.jl +++ b/test/test_utils/common.jl @@ -1131,7 +1131,6 @@ function _make_primary_controlled_tap_system() rating = 1.0, base_power = 100.0, control_objective = PSY.TransformerControlObjective.VOLTAGE, - regulated_bus = b3, controlled_quantity_limits = (min = 1.0, max = 1.0), regulated_bus = b2, # controlled bus = bus 2 (FROM) → primary input_basis = PSY.CU, From d5b3ff5d23e576dcbe5abac1e30a584e8f59ff5d Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 24 Sep 2026 13:23:04 -0700 Subject: [PATCH 11/14] Export load drop compensation from its resistance and reactance fields --- src/psse_export.jl | 6 ++++-- test/test_psse_export_remote_control.jl | 11 +++++++---- test/test_psse_remote_control_fixtures.jl | 6 ++++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/psse_export.jl b/src/psse_export.jl index f8894c7e..8b7e1a6b 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -1013,7 +1013,8 @@ function _write_2w_transformer_record3_winding1!( supp_attr = PSY.get_supplemental_attributes(PSY.ImpedanceCorrectionData, transformer) TAB1 = !isempty(supp_attr) ? PSY.get_table_number(supp_attr[1]) : 0 - CR1, CX1 = reim(PSY.get_load_drop_compensation(circuit, PSY.SU)) + CR1 = PSY.get_load_drop_compensation_r(circuit, PSY.SU) + CX1 = PSY.get_load_drop_compensation_x(circuit, PSY.SU) CNXA1 = PSSE_DEFAULT if exporter.psse_version == :v35 @@ -1159,7 +1160,8 @@ function _collect_3w_winding_data( TAB = PSY.get_table_number(icd_tr) end end - CR, CX = reim(PSY.get_load_drop_compensation(circuit, PSY.SU)) + CR = PSY.get_load_drop_compensation_r(circuit, PSY.SU) + CX = PSY.get_load_drop_compensation_x(circuit, PSY.SU) CNXA = PSSE_DEFAULT if exporter.psse_version == :v35 diff --git a/test/test_psse_export_remote_control.jl b/test/test_psse_export_remote_control.jl index bd5fff27..34436fce 100644 --- a/test/test_psse_export_remote_control.jl +++ b/test/test_psse_export_remote_control.jl @@ -20,7 +20,8 @@ function _add_control_transformer!( control_objective = PSY.TransformerControlObjective.VOLTAGE, regulated_bus = nothing, regulated_bus_side = nothing, - load_drop_compensation = 0.0 + 0.0im, + load_drop_compensation_r = 0.0, + load_drop_compensation_x = 0.0, ) tx = TwoWindingTransformer(; name = name, @@ -35,7 +36,8 @@ function _add_control_transformer!( control_objective = control_objective, regulated_bus = regulated_bus, regulated_bus_side = regulated_bus_side, - load_drop_compensation = load_drop_compensation, + load_drop_compensation_r = load_drop_compensation_r, + load_drop_compensation_x = load_drop_compensation_x, control_limits = (min = 0.9, max = 1.1), controlled_quantity_limits = (min = 0.95, max = 1.05), input_basis = CU, @@ -75,7 +77,7 @@ function _remote_control_system() _add_control_transformer!( sys, "xfmr_3_6", b3, b6; regulated_bus = b3, - load_drop_compensation = 0.01 + 0.02im, + load_drop_compensation_r = 0.01, load_drop_compensation_x = 0.02, ) _add_control_transformer!( sys, "xfmr_5_6", b5, b6; regulated_bus = b7, @@ -177,7 +179,8 @@ _circuit_named(sys, name) = get_circuit(get_component(TwoWindingTransformer, sys @test _number_of(get_regulated_bus(local_circuit)) == 3 @test get_regulated_bus_side(local_circuit) == PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING - @test get_load_drop_compensation(local_circuit, PSY.SU) ≈ 0.01 + 0.02im + @test get_load_drop_compensation_r(local_circuit, PSY.SU) ≈ 0.01 + @test get_load_drop_compensation_x(local_circuit, PSY.SU) ≈ 0.02 remote_circuit = _circuit_named(sys2, "xfmr_5_6") @test _number_of(get_regulated_bus(remote_circuit)) == 7 @test get_regulated_bus_side(remote_circuit) == diff --git a/test/test_psse_remote_control_fixtures.jl b/test/test_psse_remote_control_fixtures.jl index bd9cc4a5..d4057905 100644 --- a/test/test_psse_remote_control_fixtures.jl +++ b/test/test_psse_remote_control_fixtures.jl @@ -78,13 +78,15 @@ function _check_remote_control_fixture(sys::System) @test get_number(get_regulated_bus(local_circuit)) == 3 @test get_regulated_bus_side(local_circuit) == PSY.TransformerRegulatedBusSide.CONTROLLING_WINDING - @test get_load_drop_compensation(local_circuit, PSY.SU) ≈ 0.01 + 0.02im + @test get_load_drop_compensation_r(local_circuit, PSY.SU) ≈ 0.01 + @test get_load_drop_compensation_x(local_circuit, PSY.SU) ≈ 0.02 remote_circuit = _circuit_between(sys, 5, 6, PSY.TransformerControlObjective.VOLTAGE) @test get_number(get_regulated_bus(remote_circuit)) == 7 @test get_regulated_bus_side(remote_circuit) == PSY.TransformerRegulatedBusSide.OPPOSITE_WINDING - @test iszero(get_load_drop_compensation(remote_circuit, PSY.SU)) + @test iszero(get_load_drop_compensation_r(remote_circuit, PSY.SU)) + @test iszero(get_load_drop_compensation_x(remote_circuit, PSY.SU)) disabled_circuit = _circuit_between(sys, 6, 7, PSY.TransformerControlObjective.VOLTAGE_DISABLED) @test get_number(get_regulated_bus(disabled_circuit)) == 4 From c5de672d829447bba75f9dff5cf73b6ee02c799b Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 24 Sep 2026 14:00:49 -0700 Subject: [PATCH 12/14] Name the switching device RATE1 testset for what it checks RATE1 is in MVA like every other PSS/E rating; the parser copies it verbatim as MVA and PowerSystems divides by the base on import. --- test/test_psse_export.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index 8d6d0dba..7a30ad0f 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -1228,9 +1228,9 @@ end @test PSY.get_dc_setpoint_to(vsc2) > 0.0 end -@testset "PSSE Exporter: switching-device RATE1 round-trips through SBASE" begin - # PFFP's switch/breaker importer stores RATE1 unscaled, so a 12.06 CU rating must - # export as 12.06, not 1206.0, to round-trip. +@testset "PSSE Exporter: switching-device RATE1 round-trips in MVA" begin + # RATE1 is in MVA like every other PSS/E rating: the parser copies it verbatim as MVA + # and PowerSystems divides by the base on import, so a 12.06 CU rating exports as 1206.0. sys = System(100.0) b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) From 28b971f1c8e2e74e1ce30bb6e1d41b4167624b34 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 24 Sep 2026 14:07:01 -0700 Subject: [PATCH 13/14] Warn when a VSC line exports with no DC voltage reference A DC-voltage-controlling converter whose rated_dc_voltage and setpoint are both zero writes DCSET as 0 kV, a record PSS/E cannot solve and the parser rejects; the exporter now says so, naming the line and converter. --- src/psse_export.jl | 10 ++++++++-- test/test_psse_export.jl | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/psse_export.jl b/src/psse_export.jl index 8b7e1a6b..8d43680e 100644 --- a/src/psse_export.jl +++ b/src/psse_export.jl @@ -2555,8 +2555,14 @@ function _vsc_export_dcset( end if dc_control == PSY.VSCDCControlModes.DC_VOLTAGE vdc_base = PSY.get_rated_dc_voltage(vscline) - iszero(vdc_base) && return dc_setpoint - return dc_setpoint * vdc_base + dcset = iszero(vdc_base) ? dc_setpoint : dc_setpoint * vdc_base + if iszero(dcset) + @warn "VSC line $(PSY.get_name(vscline)): the $(side) converter controls DC " * + "voltage with no DC voltage reference (rated_dc_voltage and its setpoint " * + "are both 0), so DCSET is written as 0 kV; PSS/E cannot solve the line " * + "and the parser rejects it on import" + end + return dcset end return dc_setpoint * base_power end diff --git a/test/test_psse_export.jl b/test/test_psse_export.jl index 7a30ad0f..15e96b45 100644 --- a/test/test_psse_export.jl +++ b/test/test_psse_export.jl @@ -1228,6 +1228,34 @@ end @test PSY.get_dc_setpoint_to(vsc2) > 0.0 end +@testset "PSSE Exporter: a VSC line with no DC voltage reference warns" begin + # A TYPE 1 converter's DCSET is the DC voltage base of the whole record; writing 0 kV + # produces a RAW file PSS/E cannot solve and the parser rejects, so the exporter says so. + sys = System(100.0) + b1 = _add_simple_bus!(sys, 1, ACBusTypes.REF, 230.0) + b2 = _add_simple_bus!(sys, 2, ACBusTypes.PQ, 230.0) + _add_simple_source!(sys, b1, 0.0, 0.0) + _add_simple_load!(sys, b2, 0.0, 0.0) + vsc = _add_simple_vsc!(sys, b1, b2; active_power_flow = 0.2) + PSY.set_dc_control_from!(vsc, PSY.VSCDCControlModes.DC_VOLTAGE) + @test iszero(PSY.get_rated_dc_voltage(vsc)) && iszero(PSY.get_dc_setpoint_from(vsc)) + + export_location = joinpath(test_psse_export_dir, "v35", "vsc_no_dc_reference") + exporter = PSSEExporter(sys, :v35, export_location; overwrite = true) + @test_logs (:warn, r"VSC_1_2.*DC voltage") match_mode = :any write_export( + exporter, "vsc_no_dc_reference"; overwrite = true, + ) + + # A real reference exports without that warning. + PSY.set_rated_dc_voltage!(vsc, 400.0) + PSY.set_dc_setpoint_from!(vsc, 1.0) + update_exporter!(exporter, sys) + logs, _ = Test.collect_test_logs() do + write_export(exporter, "vsc_dc_reference"; overwrite = true) + end + @test !any(occursin(r"DC voltage", record.message) for record in logs) +end + @testset "PSSE Exporter: switching-device RATE1 round-trips in MVA" begin # RATE1 is in MVA like every other PSS/E rating: the parser copies it verbatim as MVA # and PowerSystems divides by the base on import, so a 12.06 CU rating exports as 1206.0. From b1ca98d2ae93f5d299d77c87dc44e34ee85e1ccb Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 24 Sep 2026 14:41:40 -0700 Subject: [PATCH 14/14] Use the UNDEFINED regulated bus side in the remote control tests --- test/test_psse_export_remote_control.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_psse_export_remote_control.jl b/test/test_psse_export_remote_control.jl index 34436fce..650bd4a3 100644 --- a/test/test_psse_export_remote_control.jl +++ b/test/test_psse_export_remote_control.jl @@ -19,7 +19,7 @@ function _add_control_transformer!( to_bus::ACBus; control_objective = PSY.TransformerControlObjective.VOLTAGE, regulated_bus = nothing, - regulated_bus_side = nothing, + regulated_bus_side = PSY.TransformerRegulatedBusSide.UNDEFINED, load_drop_compensation_r = 0.0, load_drop_compensation_x = 0.0, )