Skip to content

Commit f1c3789

Browse files
authored
Merge pull request #965 from vyudu/detailedbalance
Detailed balance
2 parents bef4566 + 11c1313 commit f1c3789

File tree

2 files changed

+298
-0
lines changed

2 files changed

+298
-0
lines changed

src/network_analysis.jl

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,19 @@ function isterminal(lc::Vector, rn::ReactionSystem)
398398
true
399399
end
400400

401+
function isforestlike(rn::ReactionSystem)
402+
subnets = subnetworks(rn)
403+
nps = get_networkproperties(rn)
404+
405+
isempty(nps.incidencemat) && reactioncomplexes(rn)
406+
sparseig = issparse(nps.incidencemat)
407+
for subnet in subnets
408+
nps = get_networkproperties(subnet)
409+
isempty(nps.incidencemat) && reactioncomplexes(subnet; sparse = sparseig)
410+
end
411+
all(Graphs.is_tree SimpleGraph incidencematgraph, subnets)
412+
end
413+
401414
@doc raw"""
402415
deficiency(rn::ReactionSystem)
403416
@@ -724,6 +737,87 @@ function conservationlaw_errorcheck(rs, pre_varmap)
724737
error("The system has conservation laws but initial conditions were not provided for some species.")
725738
end
726739

740+
"""
741+
isdetailedbalanced(rs::ReactionSystem, parametermap; reltol=1e-9, abstol)
742+
743+
Constructively compute whether a kinetic system (a reaction network with a set of rate constants) will admit detailed-balanced equilibrium
744+
solutions, using the Wegscheider conditions, [Feinberg, 1989](https://www.sciencedirect.com/science/article/pii/0009250989851243). A detailed-balanced solution is one for which the rate of every forward reaction exactly equals its reverse reaction. Accepts a dictionary, vector, or tuple of variable-to-value mappings, e.g. [k1 => 1.0, k2 => 2.0,...].
745+
"""
746+
747+
function isdetailedbalanced(rs::ReactionSystem, parametermap::Dict; abstol=0, reltol=1e-9)
748+
if length(parametermap) != numparams(rs)
749+
error("Incorrect number of parameters specified.")
750+
elseif !isreversible(rs)
751+
return false
752+
elseif !all(r -> ismassaction(r, rs), reactions(rs))
753+
error("The supplied ReactionSystem has reactions that are not ismassaction. Testing for being complex balanced is currently only supported for pure mass action networks.")
754+
end
755+
756+
isforestlike(rs) && deficiency(rs) == 0 && return true
757+
758+
pmap = symmap_to_varmap(rs, parametermap)
759+
pmap = Dict(ModelingToolkit.value(k) => v for (k, v) in pmap)
760+
761+
# Construct reaction-complex graph
762+
complexes, D = reactioncomplexes(rs)
763+
img = incidencematgraph(rs)
764+
undir_img = SimpleGraph(incidencematgraph(rs))
765+
K = ratematrix(rs, pmap)
766+
767+
spanning_forest = Graphs.kruskal_mst(undir_img)
768+
outofforest_edges = setdiff(collect(edges(undir_img)), spanning_forest)
769+
770+
# Independent Cycle Conditions: for any cycle we create by adding in an out-of-forest reaction, the product of forward reaction rates over the cycle must equal the product of reverse reaction rates over the cycle.
771+
for edge in outofforest_edges
772+
g = SimpleGraph([spanning_forest..., edge])
773+
ic = Graphs.cycle_basis(g)[1]
774+
fwd = prod([K[ic[r], ic[r + 1]] for r in 1:(length(ic) - 1)]) * K[ic[end], ic[1]]
775+
rev = prod([K[ic[r + 1], ic[r]] for r in 1:(length(ic) - 1)]) * K[ic[1], ic[end]]
776+
isapprox(fwd, rev; atol = abstol, rtol = reltol) ? continue : return false
777+
end
778+
779+
# Spanning Forest Conditions: for non-deficiency 0 networks, we get an additional δ equations. Choose an orientation for each reaction pair in the spanning forest (we will take the one given by default from kruskal_mst).
780+
781+
if deficiency(rs) > 0
782+
rxn_idxs = [edgeindex(D, Graphs.src(e), Graphs.dst(e)) for e in spanning_forest]
783+
S_F = netstoichmat(rs)[:, rxn_idxs]
784+
sols = positive_nullspace(S_F)
785+
786+
for i in 1:size(sols, 2)
787+
α = sols[:, i]
788+
fwd = prod([K[Graphs.src(e), Graphs.dst(e)]^α[i]
789+
for (e, i) in zip(spanning_forest, 1:length(α))])
790+
rev = prod([K[Graphs.dst(e), Graphs.src(e)]^α[i]
791+
for (e, i) in zip(spanning_forest, 1:length(α))])
792+
isapprox(fwd, rev; atol = abstol, rtol = reltol) ? continue : return false
793+
end
794+
end
795+
796+
true
797+
end
798+
799+
# Helper to find the index of the reaction with a given reactant and product complex.
800+
function edgeindex(imat, src::T, dst::T) where T <: Int
801+
for i in 1:size(imat, 2)
802+
(imat[src, i] == -1) && (imat[dst, i] == 1) && return i
803+
end
804+
error("This edge does not exist in this reaction graph.")
805+
end
806+
807+
function isdetailedbalanced(rs::ReactionSystem, parametermap::Vector{<:Pair})
808+
pdict = Dict(parametermap)
809+
isdetailedbalanced(rs, pdict)
810+
end
811+
812+
function isdetailedbalanced(rs::ReactionSystem, parametermap::Tuple{<:Pair})
813+
pdict = Dict(parametermap)
814+
isdetailedbalanced(rs, pdict)
815+
end
816+
817+
function isdetailedbalanced(rs::ReactionSystem, parametermap)
818+
error("Parameter map must be a dictionary, tuple, or vector of symbol/value pairs.")
819+
end
820+
727821
"""
728822
iscomplexbalanced(rs::ReactionSystem, parametermap)
729823

test/network_analysis/network_properties.jl

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ let
355355
k = rand(rng, numparams(rn))
356356
rates = Dict(zip(parameters(rn), k))
357357
@test Catalyst.iscomplexbalanced(rn, rates) == true
358+
@test Catalyst.isdetailedbalanced(rn, rates) == false
358359
end
359360

360361
### STRONG LINKAGE CLASS TESTS
@@ -498,6 +499,107 @@ let
498499
@test isempty(cyclemat)
499500
end
500501

502+
### Complex and detailed balance tests
503+
504+
# The following network is conditionally complex balanced - it only
505+
506+
# Reversible, forest-like deficiency zero network - should be detailed balance for any choice of rate constants.
507+
let
508+
rn = @reaction_network begin
509+
(k1, k2), A <--> B + C
510+
(k3, k4), A <--> D
511+
(k5, k6), A + D <--> E
512+
(k7, k8), A + D <--> G
513+
(k9, k10), G <--> 2F
514+
(k11, k12), A + E <--> H
515+
end
516+
517+
k1 = rand(rng, numparams(rn))
518+
rates1 = Dict(zip(parameters(rn), k1))
519+
k2 = rand(StableRNG(232), numparams(rn))
520+
rates2 = Dict(zip(parameters(rn), k2))
521+
522+
rcs, D = reactioncomplexes(rn)
523+
@test Catalyst.isforestlike(rn) == true
524+
@test Catalyst.isdetailedbalanced(rn, rates1) == true
525+
@test Catalyst.isdetailedbalanced(rn, rates2) == true
526+
end
527+
528+
# Simple connected reversible network
529+
let
530+
rn = @reaction_network begin
531+
(k1, k2), A <--> B
532+
(k3, k4), B <--> C
533+
(k5, k6), C <--> A
534+
end
535+
536+
rcs, D = reactioncomplexes(rn)
537+
rates1 = [:k1=>1.0, :k2=>1.0, :k3=>1.0, :k4=>1.0, :k5=>1.0, :k6=>1.0]
538+
@test Catalyst.isdetailedbalanced(rn, rates1) == true
539+
rates2 = [:k1=>2.0, :k2=>1.0, :k3=>1.0, :k4=>1.0, :k5=>1.0, :k6=>1.0]
540+
@test Catalyst.isdetailedbalanced(rn, rates2) == false
541+
end
542+
543+
# Independent cycle tests: the following reaction entwork has 3 out-of-forest reactions.
544+
let
545+
rn = @reaction_network begin
546+
(k1, k2), A <--> B + C
547+
(k3, k4), A <--> D
548+
(k5, k6), B + C <--> D
549+
(k7, k8), A + D <--> E
550+
(k9, k10), G <--> 2F
551+
(k11, k12), A + D <--> G
552+
(k13, k14), G <--> E
553+
(k15, k16), 2F <--> E
554+
(k17, k18), A + E <--> H
555+
end
556+
557+
rcs, D = reactioncomplexes(rn)
558+
k = rand(rng, numparams(rn))
559+
p = parameters(rn)
560+
rates = Dict(zip(parameters(rn), k))
561+
@test Catalyst.isdetailedbalanced(rn, rates) == false
562+
563+
# Adjust rate constants to obey the independent cycle conditions.
564+
rates[p[6]] = rates[p[1]]*rates[p[4]]*rates[p[5]] / (rates[p[2]]*rates[p[3]])
565+
rates[p[14]] = rates[p[13]]*rates[p[11]]*rates[p[8]] / (rates[p[12]]*rates[p[7]])
566+
rates[p[16]] = rates[p[8]]*rates[p[15]]*rates[p[9]]*rates[p[11]] / (rates[p[7]]*rates[p[12]]*rates[p[10]])
567+
@test Catalyst.isdetailedbalanced(rn, rates) == true
568+
end
569+
570+
# Deficiency two network: the following reaction network must satisfy both the independent cycle conditions and the spanning forest conditions
571+
let
572+
rn = @reaction_network begin
573+
(k1, k2), 3A <--> A + 2B
574+
(k3, k4), A + 2B <--> 3B
575+
(k5, k6), 3B <--> 2A + B
576+
(k7, k8), 2A + B <--> 3A
577+
(k9, k10), 3A <--> 3B
578+
end
579+
580+
rcs, D = reactioncomplexes(rn)
581+
@test Catalyst.edgeindex(D, 1, 2) == 1
582+
@test Catalyst.edgeindex(D, 4, 3) == 6
583+
k = rand(rng, numparams(rn))
584+
p = parameters(rn)
585+
rates = Dict(zip(parameters(rn), k))
586+
@test Catalyst.isdetailedbalanced(rn, rates) == false
587+
588+
# Adjust rate constants to fulfill independent cycle conditions.
589+
rates[p[8]] = rates[p[7]]*rates[p[5]]*rates[p[9]] / (rates[p[6]]*rates[p[10]])
590+
rates[p[3]] = rates[p[2]]*rates[p[4]]*rates[p[9]] / (rates[p[1]]*rates[p[10]])
591+
@test Catalyst.isdetailedbalanced(rn, rates) == false
592+
# Should still fail - doesn't satisfy spanning forest conditions.
593+
594+
# Adjust rate constants to fulfill spanning forest conditions.
595+
cons = rates[p[6]] / rates[p[5]]
596+
rates[p[1]] = rates[p[2]] * cons
597+
rates[p[9]] = rates[p[10]] * cons^(3/2)
598+
rates[p[8]] = rates[p[7]]*rates[p[5]]*rates[p[9]] / (rates[p[6]]*rates[p[10]])
599+
rates[p[3]] = rates[p[2]]*rates[p[4]]*rates[p[9]] / (rates[p[1]]*rates[p[10]])
600+
@test Catalyst.isdetailedbalanced(rn, rates) == true
601+
end
602+
501603
### Other Network Properties Tests ###
502604

503605
# Tests outgoing complexes matrices (1).
@@ -637,6 +739,108 @@ let
637739
@test Catalyst.robustspecies(EnvZ_OmpR) == [6]
638740
end
639741

742+
743+
### Complex and detailed balance tests
744+
745+
# The following network is conditionally complex balanced - it only
746+
747+
# Reversible, forest-like deficiency zero network - should be detailed balance for any choice of rate constants.
748+
let
749+
rn = @reaction_network begin
750+
(k1, k2), A <--> B + C
751+
(k3, k4), A <--> D
752+
(k5, k6), A + D <--> E
753+
(k7, k8), A + D <--> G
754+
(k9, k10), G <--> 2F
755+
(k11, k12), A + E <--> H
756+
end
757+
758+
k1 = rand(rng, numparams(rn))
759+
rates1 = Dict(zip(parameters(rn), k1))
760+
k2 = rand(StableRNG(232), numparams(rn))
761+
rates2 = Dict(zip(parameters(rn), k2))
762+
763+
rcs, D = reactioncomplexes(rn)
764+
@test Catalyst.isforestlike(rn) == true
765+
@test Catalyst.isdetailedbalanced(rn, rates1) == true
766+
@test Catalyst.isdetailedbalanced(rn, rates2) == true
767+
end
768+
769+
# Simple connected reversible network
770+
let
771+
rn = @reaction_network begin
772+
(k1, k2), A <--> B
773+
(k3, k4), B <--> C
774+
(k5, k6), C <--> A
775+
end
776+
777+
rcs, D = reactioncomplexes(rn)
778+
rates1 = [:k1=>1.0, :k2=>1.0, :k3=>1.0, :k4=>1.0, :k5=>1.0, :k6=>1.0]
779+
@test Catalyst.isdetailedbalanced(rn, rates1) == true
780+
rates2 = [:k1=>2.0, :k2=>1.0, :k3=>1.0, :k4=>1.0, :k5=>1.0, :k6=>1.0]
781+
@test Catalyst.isdetailedbalanced(rn, rates2) == false
782+
end
783+
784+
# Independent cycle tests: the following reaction entwork has 3 out-of-forest reactions.
785+
let
786+
rn = @reaction_network begin
787+
(k1, k2), A <--> B + C
788+
(k3, k4), A <--> D
789+
(k5, k6), B + C <--> D
790+
(k7, k8), A + D <--> E
791+
(k9, k10), G <--> 2F
792+
(k11, k12), A + D <--> G
793+
(k13, k14), G <--> E
794+
(k15, k16), 2F <--> E
795+
(k17, k18), A + E <--> H
796+
end
797+
798+
rcs, D = reactioncomplexes(rn)
799+
k = rand(rng, numparams(rn))
800+
p = parameters(rn)
801+
rates = Dict(zip(parameters(rn), k))
802+
@test Catalyst.isdetailedbalanced(rn, rates) == false
803+
804+
# Adjust rate constants to obey the independent cycle conditions.
805+
rates[p[6]] = rates[p[1]]*rates[p[4]]*rates[p[5]] / (rates[p[2]]*rates[p[3]])
806+
rates[p[14]] = rates[p[13]]*rates[p[11]]*rates[p[8]] / (rates[p[12]]*rates[p[7]])
807+
rates[p[16]] = rates[p[8]]*rates[p[15]]*rates[p[9]]*rates[p[11]] / (rates[p[7]]*rates[p[12]]*rates[p[10]])
808+
@test Catalyst.isdetailedbalanced(rn, rates) == true
809+
end
810+
811+
# Deficiency two network: the following reaction network must satisfy both the independent cycle conditions and the spanning forest conditions
812+
let
813+
rn = @reaction_network begin
814+
(k1, k2), 3A <--> A + 2B
815+
(k3, k4), A + 2B <--> 3B
816+
(k5, k6), 3B <--> 2A + B
817+
(k7, k8), 2A + B <--> 3A
818+
(k9, k10), 3A <--> 3B
819+
end
820+
821+
rcs, D = reactioncomplexes(rn)
822+
@test Catalyst.edgeindex(D, 1, 2) == 1
823+
@test Catalyst.edgeindex(D, 4, 3) == 6
824+
k = rand(rng, numparams(rn))
825+
p = parameters(rn)
826+
rates = Dict(zip(parameters(rn), k))
827+
@test Catalyst.isdetailedbalanced(rn, rates) == false
828+
829+
# Adjust rate constants to fulfill independent cycle conditions.
830+
rates[p[8]] = rates[p[7]]*rates[p[5]]*rates[p[9]] / (rates[p[6]]*rates[p[10]])
831+
rates[p[3]] = rates[p[2]]*rates[p[4]]*rates[p[9]] / (rates[p[1]]*rates[p[10]])
832+
@test Catalyst.isdetailedbalanced(rn, rates) == false
833+
# Should still fail - doesn't satisfy spanning forest conditions.
834+
835+
# Adjust rate constants to fulfill spanning forest conditions.
836+
cons = rates[p[6]] / rates[p[5]]
837+
rates[p[1]] = rates[p[2]] * cons
838+
rates[p[9]] = rates[p[10]] * cons^(3/2)
839+
rates[p[8]] = rates[p[7]]*rates[p[5]]*rates[p[9]] / (rates[p[6]]*rates[p[10]])
840+
rates[p[3]] = rates[p[2]]*rates[p[4]]*rates[p[9]] / (rates[p[1]]*rates[p[10]])
841+
@test Catalyst.isdetailedbalanced(rn, rates) == true
842+
end
843+
640844
### DEFICIENCY ONE TESTS
641845

642846
# Fails because there are two terminal linkage classes in the linkage class

0 commit comments

Comments
 (0)