Skip to content

Commit 843ecad

Browse files
authored
Merge pull request #296 from Sienna-Platform/claude/klu-sparse-rhs-wrapper-jLAmX
Add KLUWrapper
2 parents cdb2f67 + c19b994 commit 843ecad

45 files changed

Lines changed: 2150 additions & 473 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/Sienna.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ Key rules:
7272
- Globals: `UPPER_CASE` for constants
7373
- Exports: all exports in main module file
7474
- Comments: complete sentences, describe why not how
75+
- Nothing checks: use `isnothing(x)` / `!isnothing(x)`. Do **not** use the older `x === nothing` / `x !== nothing` patterns.
76+
- Type checks: prefer multiple dispatch over `isa` (the only acceptable use of `isa` is filtering inside a `catch` block, where dispatch is unavailable).
77+
- Conditionals: prefer `if/else` over the ternary `? :` operator for readability, especially in multi-line expressions.
78+
- Cache lookups: `get!(dict, key) do ... end` (the closure form is **lazy** — only evaluates on a miss). Never use the 3-arg `get!(dict, key, default)` when `default` is expensive: Julia evaluates function arguments eagerly, so `default` runs on every call (including cache hits) and silently defeats the cache.
7579

7680
## Documentation Practices and Requirements
7781

.github/workflows/main-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
continue-on-error: ${{ matrix.julia-version == 'nightly' }}
3232
env:
3333
PYTHON: ""
34+
JULIA_NUM_THREADS: "2"
3435
- uses: julia-actions/julia-processcoverage@v1
3536
if: matrix.julia-version != 'nightly'
3637
with:

.github/workflows/pr_testing.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
- uses: julia-actions/julia-runtest@latest
2727
env:
2828
PYTHON: ""
29+
JULIA_NUM_THREADS: "2"
2930
- uses: julia-actions/julia-processcoverage@v1
3031
with:
3132
directories: src,ext

Project.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
name = "PowerNetworkMatrices"
22
uuid = "bed98974-b02a-5e2f-9fe0-a103f5c450dd"
3-
version = "0.20.0"
3+
version = "0.21.0"
44
authors = ["SIENNA TEAM"]
55

66
[deps]
77
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
88
DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
99
HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f"
1010
InfrastructureSystems = "2cd47ed4-ca9b-11e9-27f2-ab636a7671f1"
11-
KLU = "ef3ab10e-7fda-4108-b977-705223b18434"
1211
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
1312
PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd"
1413
Preferences = "21216c6a-2e73-6563-6e65-726566657250"
1514
SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
15+
SuiteSparse_jll = "bea87d4a-7f5b-5778-9afe-8cc45184846c"
1616

1717
[weakdeps]
1818
AppleAccelerate = "13e28ba4-7ad8-5781-acae-3021b1ed3924"
@@ -28,10 +28,10 @@ DataStructures = "^0.19"
2828
DocStringExtensions = "~0.8, ~0.9"
2929
HDF5 = "0.17"
3030
InfrastructureSystems = "3"
31-
KLU = "^0.6"
3231
LinearAlgebra = "1"
3332
Pardiso = "1"
34-
PowerSystems = "^5.7"
33+
PowerSystems = "^5.10"
3534
Preferences = "^1.5"
3635
SparseArrays = "1"
36+
SuiteSparse_jll = "5, 6, 7"
3737
julia = "^1.10"

docs/make.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pages = OrderedDict(
3636
"Reference" => Any[
3737
"Matrix Overview" => "reference/network_matrices_overview.md",
3838
"Public API" => "reference/public.md",
39+
"Internals" => "reference/internals.md",
3940
],
4041
)
4142

docs/src/reference/internals.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Internals
2+
3+
The symbols documented on this page are **internal** to `PowerNetworkMatrices` and are
4+
not part of the public API. They are documented here so that the published manual covers
5+
every docstring shipped with the package, but they may change at any time without notice
6+
and should not be relied on by downstream packages.
7+
8+
## `KLUWrapper`
9+
10+
`PowerNetworkMatrices.KLUWrapper` is a thin, allocation-aware wrapper over `libklu`
11+
(provided by `SuiteSparse_jll`) used internally for sparse linear solves. None of these
12+
symbols are exported from `PowerNetworkMatrices`.
13+
14+
```@autodocs
15+
Modules = [PowerNetworkMatrices.KLUWrapper]
16+
```

src/BA_ABA_matrices.jl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ power flow analysis, sensitivity calculations, and linear power system studies.
159159
Mapping from reference bus numbers to their corresponding subnetwork axes
160160
- `ref_bus_position::Vector{Int}`:
161161
Vector containing the original indices of reference buses before matrix reduction
162-
- `K::F <: Union{Nothing, KLU.KLUFactorization{Float64, Int}}`:
162+
- `K::F <: Union{Nothing, KLULinSolveCache{Float64}}`:
163163
Optional KLU factorization object for efficient linear system solving. Nothing if unfactorized
164164
- `network_reduction_data::NetworkReductionData`:
165165
Container for network reduction information applied during matrix construction
@@ -179,7 +179,7 @@ power flow analysis, sensitivity calculations, and linear power system studies.
179179
struct ABA_Matrix{
180180
Ax <: NTuple{2, Vector},
181181
L <: NTuple{2, Dict},
182-
F <: Union{Nothing, KLU.KLUFactorization{Float64, Int}},
182+
F <: Union{Nothing, KLULinSolveCache{Float64}},
183183
} <: PowerNetworkMatrix{Float64}
184184
data::SparseArrays.SparseMatrixCSC{Float64, Int}
185185
axes::Ax
@@ -295,7 +295,7 @@ function ABA_Matrix(ybus::Ybus; factorize::Bool = false)
295295
bus_ax_ref = make_ax_ref(axes[1])
296296
lookup = (bus_ax_ref, bus_ax_ref)
297297
if factorize
298-
K = klu(ABA)
298+
K = klu_factorize(ABA)
299299
else
300300
K = nothing
301301
end
@@ -339,7 +339,7 @@ function factorize(ABA::ABA_Matrix{Ax, L, Nothing}) where {Ax, L <: NTuple{2, Di
339339
deepcopy(ABA.lookup),
340340
deepcopy(ABA.subnetwork_axes),
341341
deepcopy(ABA.ref_bus_position),
342-
klu(ABA.data),
342+
klu_factorize(ABA.data),
343343
deepcopy(ABA.network_reduction_data),
344344
)
345345
return ABA_lu
@@ -358,7 +358,7 @@ Check if an ABA_Matrix has been factorized (i.e., contains LU factorization matr
358358
"""
359359
is_factorized(ABA::ABA_Matrix{Ax, L, Nothing}) where {Ax, L <: NTuple{2, Dict}} = false
360360
is_factorized(
361-
ABA::ABA_Matrix{Ax, L, KLU.KLUFactorization{Float64, Int}},
361+
ABA::ABA_Matrix{Ax, L, <:KLULinSolveCache{Float64}},
362362
) where {Ax, L <: NTuple{2, Dict}} = true
363363

364364
# get_index functions: BA_Matrix stores the transposed matrix, thus get index

src/BranchesParallel.jl

Lines changed: 106 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,45 @@
1-
mutable struct BranchesParallel{T <: PSY.ACTransmission} <: PSY.ACTransmission
1+
abstract type AbstractBranchesParallel <: PSY.ACTransmission end
2+
3+
mutable struct BranchesParallel{T <: PSY.ACTransmission} <: AbstractBranchesParallel
24
branches::Vector{T}
35
equivalent_ybus::Union{Matrix{YBUS_ELTYPE}, Nothing}
6+
7+
function BranchesParallel{T}(
8+
branches::Vector{T},
9+
equivalent_ybus::Union{Matrix{YBUS_ELTYPE}, Nothing},
10+
) where {T <: PSY.ACTransmission}
11+
if !isconcretetype(T)
12+
error(
13+
"BranchesParallel{T} requires a concrete branch type T. " *
14+
"Use MixedBranchesParallel for groups with mixed branch types. Got T=$T.",
15+
)
16+
end
17+
return new{T}(branches, equivalent_ybus)
18+
end
419
end
520

621
function BranchesParallel(branches::Vector{T}) where {T <: PSY.ACTransmission}
7-
BranchesParallel(branches, nothing)
22+
return BranchesParallel{T}(branches, nothing)
823
end
9-
# Constructor for the mixed types
10-
function BranchesParallel(branches::Vector{PSY.ACTransmission})
11-
return BranchesParallel{PSY.ACTransmission}(branches, nothing)
24+
25+
mutable struct MixedBranchesParallel <: AbstractBranchesParallel
26+
branches::Vector{PSY.ACTransmission}
27+
equivalent_ybus::Union{Matrix{YBUS_ELTYPE}, Nothing}
28+
end
29+
30+
function MixedBranchesParallel(branches::Vector{<:PSY.ACTransmission})
31+
return MixedBranchesParallel(Vector{PSY.ACTransmission}(branches), nothing)
1232
end
1333

1434
function add_branch!(bp::BranchesParallel{T}, branch::T) where {T <: PSY.ACTransmission}
1535
push!(bp.branches, branch)
1636
end
1737

18-
get_branch_type(::BranchesParallel{T}) where {T <: PSY.ACTransmission} = T
38+
function add_branch!(mbp::MixedBranchesParallel, branch::PSY.ACTransmission)
39+
push!(mbp.branches, branch)
40+
end
1941

20-
function get_name(bp::BranchesParallel{T}) where {T <: PSY.ACTransmission}
42+
function get_name(bp::AbstractBranchesParallel)
2143
base_string = _longest_starting_substring(PSY.get_name.(bp.branches)...)
2244
if isempty(base_string)
2345
base_string = join(PSY.get_name.(bp.branches), "_") * "_"
@@ -44,7 +66,7 @@ function _longest_starting_substring(branch_names...)
4466
end
4567

4668
function compute_parallel_multiplier(
47-
parallel_branch_set::BranchesParallel,
69+
parallel_branch_set::AbstractBranchesParallel,
4870
branch_name::String,
4971
)
5072
b_total = 0.0
@@ -58,45 +80,82 @@ function compute_parallel_multiplier(
5880
return b_branch / b_total
5981
end
6082

61-
function get_series_susceptance(segment::BranchesParallel)
83+
function get_series_susceptance(segment::AbstractBranchesParallel)
6284
return sum(get_series_susceptance(branch) for branch in segment.branches)
6385
end
6486

65-
function get_equivalent_physical_branch_parameters(bp::BranchesParallel)
87+
function get_equivalent_physical_branch_parameters(bp::AbstractBranchesParallel)
6688
if isnothing(bp.equivalent_ybus)
6789
populate_equivalent_ybus!(bp)
6890
end
6991
equivalent_ybus = bp.equivalent_ybus
7092
return _get_equivalent_physical_branch_parameters(equivalent_ybus)
7193
end
7294

73-
function populate_equivalent_ybus!(bp::BranchesParallel)
95+
function populate_equivalent_ybus!(bp::AbstractBranchesParallel)
7496
Y11, Y12, Y21, Y22 = ybus_branch_entries(bp)
7597
bp.equivalent_ybus = YBUS_ELTYPE[Y11 Y12; Y21 Y22]
7698
return
7799
end
78100

79101
"""
80-
get_equivalent_rating(bp::BranchesParallel)
102+
get_sum_of_max_rating(bp::AbstractBranchesParallel)
81103
82-
Calculate the total rating for branches in parallel.
83-
For parallel circuits, the rating is the sum of individual ratings divided by the number of circuits.
84-
This provides a conservative estimate that accounts for potential overestimation of total capacity.
104+
Sum of the individual branch ratings, treating each circuit as independently loadable
105+
up to its own thermal limit. This is the least conservative aggregate and assumes
106+
unconstrained flow steering across the parallel group.
107+
"""
108+
function get_sum_of_max_rating(bp::AbstractBranchesParallel)
109+
return sum(get_equivalent_rating(branch) for branch in bp.branches)
110+
end
111+
112+
"""
113+
get_single_element_contingency_rating(bp::AbstractBranchesParallel)
114+
115+
N-1 rating for the parallel group: the surviving capacity after the largest-rated
116+
circuit trips, ``\\sum_i S_i - \\max_i S_i``. For a group of one branch this is zero.
85117
"""
86-
function get_equivalent_rating(bp::BranchesParallel)
87-
# Sum of ratings divided by number of circuits
88-
return sum(get_equivalent_rating(branch) for branch in bp.branches) /
89-
length(bp.branches)
118+
function get_single_element_contingency_rating(bp::AbstractBranchesParallel)
119+
ratings = get_equivalent_rating.(bp.branches)
120+
return sum(ratings) - maximum(ratings)
90121
end
91122

92123
"""
93-
get_equivalent_emergency_rating(bp::BranchesParallel)
124+
get_impedance_averaged_rating(bp::AbstractBranchesParallel)
125+
126+
Susceptance-weighted average of individual branch ratings,
127+
``\\sum_i f_i \\cdot S_i`` with ``f_i = b_i / \\sum_k b_k``. Reflects how DC flow
128+
physically splits across a parallel group. Throws `ArgumentError` if the total
129+
series susceptance is zero or non-finite.
130+
"""
131+
function get_impedance_averaged_rating(bp::AbstractBranchesParallel)
132+
b_total = sum(PSY.get_series_susceptance, bp.branches)
133+
if !isfinite(b_total) || iszero(b_total)
134+
throw(
135+
ArgumentError(
136+
"Cannot compute impedance-averaged rating: total series susceptance across the parallel group must be finite and non-zero.",
137+
),
138+
)
139+
end
140+
return sum(
141+
PSY.get_series_susceptance(br) / b_total * get_equivalent_rating(br)
142+
for br in bp.branches
143+
)
144+
end
145+
146+
# Series-chain rating contribution for a parallel block: dispatch arm for
147+
# `get_equivalent_rating(::BranchesSeries)` defined in BranchesSeries.jl.
148+
_series_member_rating(bp::AbstractBranchesParallel) =
149+
get_single_element_contingency_rating(bp)
150+
151+
"""
152+
get_equivalent_emergency_rating(bp::AbstractBranchesParallel)
94153
95154
Calculate the total emergency rating for branches in parallel.
96155
For parallel circuits, the emergency rating is the sum of individual emergency ratings divided by the number of circuits.
97156
This provides a conservative estimate that accounts for potential overestimation of total capacity.
98157
"""
99-
function get_equivalent_emergency_rating(bp::BranchesParallel)
158+
function get_equivalent_emergency_rating(bp::AbstractBranchesParallel)
100159
equivalent_rating = 0.0
101160
for branch in bp.branches
102161
rating_b = get_equivalent_emergency_rating(branch)
@@ -106,36 +165,38 @@ function get_equivalent_emergency_rating(bp::BranchesParallel)
106165
end
107166

108167
"""
109-
get_equivalent_available(bp::BranchesParallel)
168+
get_equivalent_available(bp::AbstractBranchesParallel)
110169
111170
Get the availability status for parallel branches.
112171
All branches in parallel must be available for the parallel circuit to be available.
113172
"""
114-
function get_equivalent_available(bp::BranchesParallel)
173+
function get_equivalent_available(bp::AbstractBranchesParallel)
115174
# All branches must be available
116175
return all(PSY.get_available(branch) for branch in bp.branches)
117176
end
118177

178+
PSY.get_available(bp::AbstractBranchesParallel) = get_equivalent_available(bp)
179+
119180
"""
120-
get_equivalent_α(bp::BranchesParallel)
181+
get_equivalent_α(bp::AbstractBranchesParallel)
121182
122183
Get the phase angle shift for parallel branches.
123184
Returns the average phase angle shift across all parallel branches.
124185
Returns 0.0 if branches don't support phase angle shift (e.g., lines).
125186
"""
126-
function get_equivalent_α(bp::BranchesParallel)
187+
function get_equivalent_α(bp::AbstractBranchesParallel)
127188
# Need to check the PS books
128189
end
129190

130-
function Base.iterate(bp::BranchesParallel)
191+
function Base.iterate(bp::AbstractBranchesParallel)
131192
return iterate(bp.branches)
132193
end
133194

134-
function Base.iterate(bp::BranchesParallel, state)
195+
function Base.iterate(bp::AbstractBranchesParallel, state)
135196
return iterate(bp.branches, state)
136197
end
137198

138-
function Base.length(bp::BranchesParallel)
199+
function Base.length(bp::AbstractBranchesParallel)
139200
return length(bp.branches)
140201
end
141202

@@ -144,33 +205,32 @@ function add_to_map(
144205
filters::Dict,
145206
) where {T <: PSY.ACTransmission}
146207
isempty(filters) && return true
147-
if isabstracttype(T)
148-
@warn "Parallel circuit contains mixed branch types, filters might be applied to more components than intended. Use Logging.Debug for additional information."
149-
@debug "Parallel circuit branch types: $(typeof.(double_circuit.branches))"
150-
@debug "Parallel circuit branch names: $(PSY.get_name.(double_circuit.branches))"
151-
for branch in double_circuit.branches
152-
filter = get(filters, typeof(branch), x -> true)
153-
if !filter(branch)
154-
return false
155-
end
156-
end
208+
if !haskey(filters, T)
157209
return true
158210
end
211+
return any(filters[T](device) for device in double_circuit)
212+
end
159213

160-
if !haskey(filters, T)
161-
return true
162-
else
163-
return any([filters[T](device) for device in double_circuit])
214+
function add_to_map(double_circuit::MixedBranchesParallel, filters::Dict)
215+
isempty(filters) && return true
216+
@warn "Parallel circuit contains mixed branch types, filters might be applied to more components than intended. Use Logging.Debug for additional information."
217+
@debug "Parallel circuit branch types: $(typeof.(double_circuit.branches))"
218+
@debug "Parallel circuit branch names: $(PSY.get_name.(double_circuit.branches))"
219+
for branch in double_circuit.branches
220+
filter = get(filters, typeof(branch), x -> true)
221+
if !filter(branch)
222+
return false
223+
end
164224
end
165-
error("Invalid condition reached in add_to_map for BranchesParallel")
225+
return true
166226
end
167227

168-
function Base.:(==)(a::BranchesParallel, b::BranchesParallel)
228+
function Base.:(==)(a::AbstractBranchesParallel, b::AbstractBranchesParallel)
169229
return a.branches == b.branches
170230
end
171231

172-
function Base.show(io::IO, x::MIME{Symbol("text/plain")}, y::BranchesParallel)
232+
function Base.show(io::IO, x::MIME{Symbol("text/plain")}, y::AbstractBranchesParallel)
173233
show(io, x, y.branches)
174234
end
175235

176-
is_a_reduction(::BranchesParallel) = true
236+
is_a_reduction(::AbstractBranchesParallel) = true

0 commit comments

Comments
 (0)