Skip to content

Commit d7ff1b1

Browse files
authored
Merge pull request #294 from JuliaDynamics/hw/linear_analysis
add linear analysis methods
2 parents ec494b1 + b609aff commit d7ff1b1

File tree

8 files changed

+245
-0
lines changed

8 files changed

+245
-0
lines changed

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# NetworkDynamics Release Notes
22

33
## v0.10.1 Changelog
4+
- [#294](https://github.com/JuliaDynamics/NetworkDynamics.jl/pull/294) add linear stability analysis functions: `isfixpoint`, `jacobian_eigenvals`, and `is_linear_stable` with support for both ODE and DAE systems
45
- [#283](https://github.com/JuliaDynamics/NetworkDynamics.jl/pull/283) add automatic sparsity detection using `get_jac_prototype` and `set_jac_prototype!`
56
- [#285](https://github.com/JuliaDynamics/NetworkDynamics.jl/pull/285) rename `delete_initconstraint!` -> `delete_initconstaints!` and `delete_initformula!` -> `delete_initformulas!`
67

docs/examples/init_tutorial.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ du = ones(dim(nw_dyn))
269269
nw_dyn(du, uflat(u0_dyn), pflat(u0_dyn), 0.0)
270270
extrema(du .- zeros(dim(nw_dyn))) # very close to zero, confirming we have a steady state!
271271

272+
# Alternatively, we can used the [`isfixpoint`](@ref) function to check if the state is a fixpoint:
273+
@assert isfixpoint(nw_dyn, u0_dyn)
274+
nothing #hide
275+
272276
#=
273277
## Simulating the Dynamic Model
274278

docs/src/API.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ delete_initformulas!
179179
interface_values
180180
```
181181

182+
## Linear Stability Analysis
183+
```@docs
184+
isfixpoint
185+
jacobian_eigenvals
186+
is_linear_stable
187+
```
188+
182189
## Callbacks API
183190
### Define Callbacks
184191
```@docs

docs/src/initialization.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,6 @@ nothing #hide
227227

228228
**Applying Constraints**: Constraints can be either added to the metadata of components ([`set_initconstraint!`](@ref), [`add_initconstraint!`](@ref)) or passed as `additional_initconstraint` to the
229229
[`initialize_component[!]`](@ref NetworkDynamics.initialize_component) functions.
230+
231+
## Analysing Fixpoints
232+
In order to analyse fixpoints NetworkDynamis provides the functions [`isfixpoint`](@ref), [`is_linear_stable`](@ref) and [`jacobian_eigenvals`](@ref).

src/NetworkDynamics.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ export has_marker, get_marker, set_marker!
103103
export get_defaults_dict, get_guesses_dict, get_bounds_dict, get_inits_dict
104104
include("metadata.jl")
105105

106+
export isfixpoint, is_linear_stable, jacobian_eigenvals
107+
include("linear_stability.jl")
108+
106109
include("show.jl")
107110

108111
const CHECK_COMPONENT = Ref(true)

src/linear_stability.jl

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
isfixpoint(nw::Network, s0::NWState; tol=1e-10)
3+
4+
Check if the state `s0` is a fixpoint of the network `nw` by
5+
calculating the the RHS and check that every entry is within the
6+
given tolerance `tol`.
7+
"""
8+
function isfixpoint(nw::Network, s0::NWState; tol=1e-10)
9+
# Check if the state is a fixpoint of the network
10+
u0 = uflat(s0)
11+
p0 = pflat(s0)
12+
du = zeros(eltype(u0), length(u0))
13+
nw(du, u0, p0, s0.t)
14+
15+
# Check if the change is within the tolerance
16+
return all(abs.(du) .< tol)
17+
end
18+
19+
"""
20+
is_linear_stable(nw::Network, s0::NWState; kwargs...)
21+
22+
Check if the fixpoint `s0` of the network `nw` is linearly stable by computing
23+
the eigenvalues of the Jacobian matrix (or reduced Jacobian for constrained systems).
24+
25+
A fixpoint is linearly stable if all eigenvalues of the Jacobian have negative
26+
real parts. For systems with algebraic constraints (non-identity mass matrix),
27+
the reduced Jacobian is used following the approach in [1].
28+
See [`jacobian_eigenvals`](@ref) for more details.
29+
30+
# Arguments
31+
- `nw::Network`: The network dynamics object
32+
- `s0::NWState`: The state to check for linear stability (must be a fixpoint)
33+
- `kwargs...`: Additional keyword arguments passed to `jacobian_eigenvals`
34+
35+
# Returns
36+
- `Bool`: `true` if the fixpoint is linearly stable, `false` otherwise
37+
38+
# References
39+
[1] "Power System Modelling and Scripting", F. Milano, Chapter 7.2.
40+
"""
41+
function is_linear_stable(nw::Network, s0; kwargs...)
42+
isfixpoint(nw, s0) || error("The state s0 is not a fixpoint of the network nw.")
43+
λ = jacobian_eigenvals(nw, s0; kwargs...)
44+
if all-> real(λ) < 0.0, λ)
45+
return true
46+
else
47+
return false
48+
end
49+
end
50+
51+
"""
52+
jacobian_eigenvals(nw::Network, s0::NWState; eigvalf=LinearAlgebra.eigvals)
53+
54+
Compute the eigenvalues of the Jacobian matrix for linear stability analysis of
55+
the network dynamics at state `s0`.
56+
57+
For systems without algebraic constraints (identity mass matrix), this returns
58+
the eigenvalues of the full Jacobian matrix. For constrained systems (non-identity
59+
mass matrix), it computes the eigenvalues of the reduced Jacobian following the
60+
approach for differential-algebraic equations outlined in [1]
61+
62+
# Arguments
63+
- `nw::Network`: The network dynamics object
64+
- `s0::NWState`: The state at which to compute the Jacobian eigenvalues
65+
- `eigvalf`: Function to compute eigenvalues (default: `LinearAlgebra.eigvals`)
66+
67+
# Returns
68+
- `Vector`: Eigenvalues of the Jacobian (or reduced Jacobian for constrained systems)
69+
70+
# Algorithm
71+
For unconstrained systems (M = I):
72+
- Computes eigenvalues of the full Jacobian J
73+
74+
For constrained systems (M ≠ I, differential-algebraic equations):
75+
- The system has the form: M * dz/dt = f(z, t) where M is a diagonal mass matrix
76+
- Variables are partitioned into differential (M_ii = 1) and algebraic (M_ii = 0) components
77+
- Let z = [x; y] where x are differential and y are algebraic variables
78+
- The Jacobian J = ∂f/∂z is partitioned as:
79+
```
80+
J = [f_x f_y] where f_x = ∂f_d/∂x, f_y = ∂f_d/∂y
81+
[g_x g_y] g_x = ∂g_a/∂x, g_y = ∂g_a/∂y
82+
```
83+
- For the algebraic constraints 0 = g_a(x, y), we have dy/dt = -g_y^(-1) * g_x * dx/dt
84+
- Substituting into the differential equations gives the reduced system:
85+
dx/dt = (f_x - f_y * g_y^(-1) * g_x) * x = A_s * x
86+
- The eigenvalues of the reduced Jacobian A_s determine stability
87+
- This approach follows the theory of differential-algebraic equations [1]
88+
89+
# References
90+
[1] "Power System Modelling and Scripting", F. Milano, Chapter 7.2.
91+
"""
92+
function jacobian_eigenvals(nw::Network, s0; eigvalf=LinearAlgebra.eigvals)
93+
x0, p, t = uflat(s0), pflat(s0), s0.t # unpack state
94+
M = nw.mass_matrix
95+
96+
h!(dx, x) = nw(dx, x, p, t)
97+
j(x) = (dx = similar(x); ForwardDiff.jacobian(h!, dx, x)) # Jacobian
98+
J = j(x0) # Full Jacobian at the equilibrium point
99+
100+
if M == LinearAlgebra.I # Constraint free system -> use eigenvalues of jacobian
101+
return eigvalf(J)
102+
else # Constraints -> Use eigenvalues of reduced jacobian
103+
M != LinearAlgebra.Diagonal(M) && error("The constraints are not diagonal.")
104+
c_idx = findall(LinearAlgebra.diag(M) .== 0)
105+
d_idx = findall(LinearAlgebra.diag(M) .== 1)
106+
107+
f_x = J[d_idx, d_idx] # Differential equations evaluated at the differential variables
108+
f_y = J[d_idx, c_idx] # Differential equations evaluated at the constrained variables
109+
110+
g_x = J[c_idx, d_idx] # Constrained equations evaluated at the differential variables
111+
g_y = J[c_idx, c_idx] # Constrained equations evaluated at the constrained variables
112+
113+
D = f_y * LinearAlgebra.pinv(g_y) * g_x # Degradation matrix
114+
A_s = f_x - D # State matrix / Reduced Jacobian (eq. 7.16 in [1])
115+
return eigvalf(A_s) # Eigenvalues of the reduced jacobian
116+
end
117+
end

test/linear_stability_test.jl

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using NetworkDynamics, Graphs
2+
using LinearAlgebra
3+
using SteadyStateDiffEq
4+
using Test
5+
6+
(isinteractive() && @__MODULE__()==Main ? includet : include)("ComponentLibrary.jl")
7+
8+
@testset "Linear Stability Tests" begin
9+
10+
@testset "Diffusion network tests" begin
11+
# Create diffusion network once and test all functions
12+
g = path_graph(3)
13+
vertex_model = Lib.diffusion_vertex()
14+
edge_model = Lib.diffusion_edge()
15+
nw = Network(g, vertex_model, edge_model)
16+
s0 = find_fixpoint(nw, NWParameter(nw))
17+
18+
# Test isfixpoint function
19+
@test isfixpoint(nw, s0)
20+
@test isfixpoint(nw, s0; tol=1e-12)
21+
@test isfixpoint(nw, s0; tol=1e-15)
22+
23+
# Test with non-fixpoint state
24+
s_non_fix = NWState(s0)
25+
s_non_fix.v[1, :s] = 1.0
26+
@test !isfixpoint(nw, s_non_fix)
27+
28+
# Test jacobian_eigenvals function
29+
λ = jacobian_eigenvals(nw, s0)
30+
@test length(λ) == 3 # Should have 3 eigenvalues for 3 vertices
31+
@test all(real.(λ) .≤ 0) # All eigenvalues should have non-positive real parts
32+
@test nw.mass_matrix == I # Verify unconstrained system
33+
34+
# Test custom eigenvalue function
35+
λ_custom = jacobian_eigenvals(nw, s0; eigvalf=eigvals)
36+
@test λ λ_custom
37+
38+
# Test is_linear_stable function
39+
@test is_linear_stable(nw, s0)
40+
@test_throws ErrorException is_linear_stable(nw, s_non_fix)
41+
end
42+
43+
@testset "Kuramoto system test" begin
44+
# Test with a more complex system: Kuramoto oscillators
45+
g = path_graph(4)
46+
vertex_model = Lib.kuramoto_second()
47+
edge_model = Lib.kuramoto_edge()
48+
49+
nw = Network(g, vertex_model, edge_model)
50+
51+
# Set up parameters
52+
p = NWParameter(nw)
53+
p.v[:, :Pm] .= [1, -0.5, -0.5, 0] # Power injections
54+
p.v[:, :M] .= 1.0 # Inertia
55+
p.v[:, :D] .= 0.1 # Damping
56+
p.e[:, :K] .= 2.0 # Coupling strength
57+
58+
# Find fixpoint
59+
s0 = find_fixpoint(nw, p)
60+
61+
# Test fixpoint check
62+
@test isfixpoint(nw, s0)
63+
64+
# Test eigenvalue computation
65+
λ = jacobian_eigenvals(nw, s0)
66+
@test length(λ) == 8 # 4 nodes × 2 states each
67+
68+
# Test stability (should be stable for this configuration)
69+
@test is_linear_stable(nw, s0)
70+
71+
# Test that eigenvalues can be complex
72+
@test any(isa.(λ, Complex))
73+
end
74+
75+
@testset "DAE system with mass matrix" begin
76+
# Test DAE system with mixed differential/algebraic equations
77+
g = path_graph(3)
78+
# Create vertex with mixed mass matrix: some differential, some algebraic
79+
mixed_vertex = VertexModel(f=(dx, x, edges, p, t) -> (dx[1] = -x[1] + edges[1]; dx[2] = 0),
80+
g=(y, x, p, t) -> y[1] = x[1],
81+
dim=2, outdim=1,
82+
mass_matrix=LinearAlgebra.Diagonal([1, 0])) # Mixed ODE/DAE
83+
84+
simple_edge = EdgeModel(g=AntiSymmetric((e, v_s, v_d, p, t) -> e[1] = v_s[1] - v_d[1]),
85+
outdim=1)
86+
87+
nw = Network(g, mixed_vertex, simple_edge)
88+
89+
# This system has a non-identity mass matrix due to algebraic constraints
90+
@test nw.mass_matrix != I
91+
s0 = find_fixpoint(nw)
92+
@test isfixpoint(nw, s0)
93+
94+
# Test eigenvalue computation for DAE system (should use reduced Jacobian)
95+
λ = jacobian_eigenvals(nw, s0)
96+
@test length(λ) == 3 # Should have 3 eigenvalues (differential variables only)
97+
@test all(real.(λ) .< 0) # Should be stable
98+
99+
# Test stability
100+
@test is_linear_stable(nw, s0)
101+
102+
# Verify this system uses the DAE branch (reduced Jacobian approach)
103+
c_idx = findall(LinearAlgebra.diag(nw.mass_matrix) .== 0)
104+
d_idx = findall(LinearAlgebra.diag(nw.mass_matrix) .== 1)
105+
@test length(c_idx) > 0 # Has algebraic constraints
106+
@test length(d_idx) > 0 # Has differential equations
107+
end
108+
109+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ haskey(ENV, "BUILDKITE") && @test CUDA.functional() # fail early in buildkite if
4545
@safetestset "initialization test" begin include("initialization_test.jl") end
4646
@safetestset "Callbacks test" begin include("callbacks_test.jl") end
4747
@safetestset "Metadata test" begin include("metadata_test.jl") end
48+
@safetestset "Linear Stability test" begin include("linear_stability_test.jl") end
4849
@safetestset "Show-methods test" begin include("show_test.jl") end
4950

5051
@safetestset "AD test" begin include("AD_test.jl") end

0 commit comments

Comments
 (0)