From 65166a1d94724f0329f3d96e7d97591cb4bcf1f5 Mon Sep 17 00:00:00 2001
From: "St. Elmo" <stelmozors@gmail.com>
Date: Fri, 13 Aug 2021 16:03:28 +0200
Subject: [PATCH] Implement NLP version of thermodynamic FBA

---
 src/analysis/modifications/thermo.jl          | 121 ++++++++++++++++++
 .../thermodynamic_flux_balance_analysis.jl    |  48 +++++++
 2 files changed, 169 insertions(+)
 create mode 100644 src/analysis/modifications/thermo.jl
 create mode 100644 src/analysis/thermodynamic_flux_balance_analysis.jl

diff --git a/src/analysis/modifications/thermo.jl b/src/analysis/modifications/thermo.jl
new file mode 100644
index 000000000..595264d54
--- /dev/null
+++ b/src/analysis/modifications/thermo.jl
@@ -0,0 +1,121 @@
+"""
+    thermodynamic_constraints(
+        gibbs_free_energies::Dict{String,Float64};
+        proton_ids = ["h_c", "h_e"],
+        water_ids = ["h2o_c", "h2o_e"],
+        concentration_ratios = [
+            ("atp_c", "adp_c", 10.0),
+            ("adp_c", "amp_c", 1.0),
+            ("nadph_c", "nadp_c", 10.0),
+            ("nadh_c", "nad_c", 0.1),
+        ],
+        constant_concentrations = [
+            ("coa_c", 1e-3),
+            ("co2_c", 10e-6),
+            ("pi_c", 10e-3),
+            ("ppi_c", 1e-3),
+        ],
+        concentration_lb = 1e-6,
+        concentration_ub = 10e-3,
+    )
+
+Add thermodynamic constraints to an optimization model. This function directly ensures that
+the Gibbs energy dissipated by each reaction is negative, i.e. `ΔG * flux < 0`.
+Consequently, quadratic constraints are added to the optimization model, causing the problem
+to become nonlinear. This is unlike the traditional tFBA and llFBA that instead cast the
+model into a mixed integer optimization problem. This modification adds `dgs` and `logcs` as
+variables to the optimization model, where `dgs` are the actual Gibbs energy of each
+reaction and `logcs` are the logged concentration of each metabolite. Only reactions and
+metabolites involved with the supplied thermodynamic data have constraints placed on them,
+all the others are free variables. This allows solvers to optimize these variables away, but
+allows users to access the values of the solved problem in the order they are stored in the
+model.
+
+Since biological thermodynamics data are assumed to be passed into the function, protons and
+water do not have adjustable concentration (constant pH and aqueous conditions are assumed).
+Hence, they are always free variables. Additional constraints are supplied in
+`concentration_ratios`, `constant_concentrations`, `concentration_lb`, and
+`concentration_ub`. Their meaning is the same as in [`max_min_driving_force`](@ref).
+Concentrations in Molar.
+
+See also: [`thermodynamic_flux_balance_analysis_dict`](@ref) for a convenience wrapper of
+this modification.
+"""
+thermodynamic_constraints(
+    gibbs_free_energies::Dict{String,Float64};
+    proton_ids = ["h_c", "h_e"],
+    water_ids = ["h2o_c", "h2o_e"],
+    concentration_ratios = [
+        ("atp_c", "adp_c", 10.0),
+        ("adp_c", "amp_c", 1.0),
+        ("nadph_c", "nadp_c", 10.0),
+        ("nadh_c", "nad_c", 0.1),
+    ],
+    constant_concentrations = [
+        ("coa_c", 1e-3),
+        ("co2_c", 10e-6),
+        ("pi_c", 10e-3),
+        ("ppi_c", 1e-3),
+    ],
+    concentration_lb = 1e-6,
+    concentration_ub = 10e-3,
+) =
+    (model, opt_model) -> begin
+
+        # find reactions with thermodynamic data
+        dg_rids = filter(x -> haskey(gibbs_free_energies, x), reactions(model))
+        dg_ridxs = Int.(indexin(dg_rids, reactions(model)))
+
+        #=
+        remove protons, water from metabolites whose concentration can change, as well as all
+        metabolites not involved in the reactions that have thermodynamic information
+        =#
+        dg_mids = unique(
+            vcat(
+                [
+                    collect(keys(rxn.metabolites)) for
+                    (rid, rxn) in model.reactions if rid in dg_rids
+                ]...,
+            ),
+        )
+        filter!(x -> !(x in [proton_ids; water_ids]), dg_mids)
+        dg_midxs = Int.(indexin(dg_mids, metabolites(model)))
+
+        # reduced stoichiometric matrix
+        St = (stoichiometry(model)[dg_midxs, dg_ridxs])'
+
+        RT = 298.15 * 8.314e-3 # kJ/mol
+
+        dg0s = [gibbs_free_energies[rid] for rid in dg_rids] # NB: in order of dg_ridxs
+
+        # Add variables
+        @variables opt_model begin
+            dgs[1:n_reactions(model)] # all reactions get dG data, but only the reactions with data are constrained
+            logcs[1:n_metabolites(model)] # all metabolites become variables - solver will remove unnecessary ones
+        end
+
+        @constraints opt_model begin
+            dgs[dg_ridxs] .== dg0s .+ RT .* St * logcs[dg_midxs]
+            dgs[dg_ridxs] .* opt_model[:x][dg_ridxs] .<= 0.0 # thermodynamically consistent
+        end
+
+        log_lb = log(concentration_lb)
+        log_ub = log(concentration_ub)
+        constant_concentration_dict =
+            Dict(met => val for (met, val) in constant_concentrations)
+        # add bounds for metabolites, metabolites not involved in thermo as free
+        for mid in dg_mids
+            i = first(indexin([mid], metabolites(model)))
+            if haskey(constant_concentration_dict, mid)
+                @constraint(opt_model, logcs[i] == log(constant_concentration_dict[mid]))
+            else
+                @constraint(opt_model, log_lb <= logcs[i] <= log_ub)
+            end
+        end
+
+        # add metabolite ratio constraints
+        for (met1, met2, val) in concentration_ratios
+            i, j = Int.(indexin([met1, met2], metabolites(model)))
+            @constraint(opt_model, logcs[i] == log(val) + logcs[j])
+        end
+    end
diff --git a/src/analysis/thermodynamic_flux_balance_analysis.jl b/src/analysis/thermodynamic_flux_balance_analysis.jl
new file mode 100644
index 000000000..4456bbc0a
--- /dev/null
+++ b/src/analysis/thermodynamic_flux_balance_analysis.jl
@@ -0,0 +1,48 @@
+"""
+    thermodynamic_flux_balance_analysis_dict(model, optimizer; kwargs...)
+
+A shortcut for wrapping the output of [`flux_balance_analysis`](@ref) with thermodyamic
+data, `kwargs` must contain `modifications` where [`thermodynamic_constraints`](@ref) is
+included for this convenience wrapper to make sense. Returns a named tuple mapping `fluxes`,
+`concentrations` and `gibbs_reaction_energies` to reactions and metabolites in `model`.
+
+# Example
+```
+sol = thermodynamic_flux_balance_analysis_dict(
+    model,
+    optimizer;
+    modifications = [
+        thermodynamic_constraints(
+            gibbs_free_energies;
+            proton_ids = ["h_c", "h_e"],
+            water_ids = ["h2o_c", "h2o_e"],
+            concentration_ratios = [
+                ("atp_c", "adp_c", 10.0),
+                ("adp_c", "amp_c", 1.0),
+                ("nadph_c", "nadp_c", 10.0),
+                ("nadh_c", "nad_c", 0.1),
+            ],
+            constant_concentrations = [
+                ("coa_c", 1e-3),
+                ("co2_c", 10e-6),
+                ("pi_c", 10e-3),
+                ("ppi_c", 1e-3),
+            ],
+            concentration_lb = 1e-6,
+            concentration_ub = 10e-3,
+        )
+    ],
+)
+```
+"""
+function thermodynamic_flux_balance_analysis_dict(args...; kwargs...)
+
+    sol = flux_balance_analysis(args...; kwargs...)
+
+    isnothing(sol) && return nothing
+
+    fluxes = Dict(zip(reactions(args[1]), value.(sol[:x])))
+    concens = Dict(zip(metabolites(args[1]), exp.(value.(sol[:logcs]))))
+    dgs = Dict(zip(reactions(args[1]), value.(sol[:dgs])))
+    return (fluxes = fluxes, concentrations = concens, gibbs_reaction_energies = dgs)
+end
-- 
GitLab