Implementing new model types
For convenience, AbstractFBCModels
defines a "canonical" implementation of a FBC model: a completely generic data structure that can store exactly the complete information that is representable by the AbstractFBCModel
accessors, and nothing else.
The type is not useful for actually constructing the models, but may serve a good purpose in several other cases:
- If you need an "intermediate step" when converting complicated FBC models to other types, the canonical model is guaranteed not to lose any information, yet perform relatively well when re-exporting the information via the accessors.
- If you need to make quick modifications to another model type that does not admin easy mutation (e.g., it is made of immutable
struct
s), you can convert to the canonical model and make the small fixes in there. - Here, we use it for describing how to "perform" your own definition of model type, and demonstrate the use of the pre-defined testing framework on it.
The model is available for use as AbstractFBCModels.CanonicalModel.Model
Defining the model
For convenience in the later explanation, we list the whole definition of the module here:
module CanonicalModel
using DocStringExtensions
import ..AbstractFBCModels as A
import Serialization as S
import SparseArrays: sparse, findnz
"""
$(TYPEDEF)
A canonical Julia representation of a reaction in the `AbstractFBCModels` interface.
# Fields
$(TYPEDFIELDS)
"""
Base.@kwdef mutable struct Reaction
name::A.Maybe{String} = nothing
lower_bound::Float64 = -Inf
upper_bound::Float64 = Inf
stoichiometry::Dict{String,Float64} = Dict()
objective_coefficient::Float64 = 0.0
gene_association_dnf::A.Maybe{A.GeneAssociationDNF} = nothing
annotations::A.Annotations = A.Annotations()
notes::A.Notes = A.Notes()
end
Base.show(io::Base.IO, ::MIME"text/plain", x::Reaction) = A.pretty_print_kwdef(io, x)
"""
$(TYPEDEF)
A canonical Julia representation of a metabolite in the `AbstractFBCModels` interface.
# Fields
$(TYPEDFIELDS)
"""
Base.@kwdef mutable struct Metabolite
name::A.Maybe{String} = nothing
compartment::A.Maybe{String} = nothing
formula::A.Maybe{A.MetaboliteFormula} = nothing
charge::A.Maybe{Int} = nothing
balance::Float64 = 0.0
annotations::A.Annotations = A.Annotations()
notes::A.Notes = A.Notes()
end
Base.show(io::Base.IO, ::MIME"text/plain", x::Metabolite) = A.pretty_print_kwdef(io, x)
"""
$(TYPEDEF)
A canonical Julia representation of a gene in the `AbstractFBCModels` interface.
# Fields
$(TYPEDFIELDS)
"""
Base.@kwdef mutable struct Gene
name::A.Maybe{String} = nothing
annotations::A.Annotations = A.Annotations()
notes::A.Notes = A.Notes()
end
Base.show(io::Base.IO, ::MIME"text/plain", x::Gene) = A.pretty_print_kwdef(io, x)
"""
$(TYPEDEF)
A canonical Julia representation of a row in a coupling matrix of the
`AbstractFBCModels` interface.
# Fields
$(TYPEDFIELDS)
"""
Base.@kwdef mutable struct Coupling
name::A.Maybe{String} = nothing
reaction_weights::Dict{String,Float64} = Dict()
lower_bound::Float64 = -Inf
upper_bound::Float64 = Inf
annotations::A.Annotations = A.Annotations()
notes::A.Notes = A.Notes()
end
Base.show(io::Base.IO, ::MIME"text/plain", x::Coupling) = A.pretty_print_kwdef(io, x)
"""
$(TYPEDEF)
A canonical Julia representation of a metabolic model that sotres exactly the
data represented by `AbstractFBCModels` accessors.
The implementation is useful for manipulating model data manually without
writing new model types, or even for constructing models from base data in many
simple cases.
Additionally, you can use the implementation of accessors for this model type
in the source code of `AbstractFBCModels` as a starting point for creating an
`AbstractFBCModel` interface for your own models.
# Fields
$(TYPEDFIELDS)
"""
Base.@kwdef struct Model <: A.AbstractFBCModel
reactions::Dict{String,Reaction} = Dict()
metabolites::Dict{String,Metabolite} = Dict()
genes::Dict{String,Gene} = Dict()
couplings::Dict{String,Coupling} = Dict()
end
Base.show(io::Base.IO, ::MIME"text/plain", x::Model) = A.pretty_print_kwdef(io, x)
A.reactions(m::Model) = sort(collect(keys(m.reactions)))
A.metabolites(m::Model) = sort(collect(keys(m.metabolites)))
A.genes(m::Model) = sort(collect(keys(m.genes)))
A.couplings(m::Model) = sort(collect(keys(m.couplings)))
A.n_reactions(m::Model) = length(m.reactions)
A.n_metabolites(m::Model) = length(m.metabolites)
A.n_genes(m::Model) = length(m.genes)
A.n_couplings(m::Model) = length(m.couplings)
A.reaction_name(m::Model, id::String) = m.reactions[id].name
A.metabolite_name(m::Model, id::String) = m.metabolites[id].name
A.gene_name(m::Model, id::String) = m.genes[id].name
A.coupling_name(m::Model, id::String) = m.couplings[id].name
A.reaction_annotations(m::Model, id::String) = m.reactions[id].annotations
A.metabolite_annotations(m::Model, id::String) = m.metabolites[id].annotations
A.gene_annotations(m::Model, id::String) = m.genes[id].annotations
A.coupling_annotations(m::Model, id::String) = m.couplings[id].annotations
A.reaction_notes(m::Model, id::String) = m.reactions[id].notes
A.metabolite_notes(m::Model, id::String) = m.metabolites[id].notes
A.gene_notes(m::Model, id::String) = m.genes[id].notes
A.coupling_notes(m::Model, id::String) = m.couplings[id].notes
function A.stoichiometry(m::Model)
midxs = Dict(mid => idx for (idx, mid) in enumerate(A.metabolites(m)))
I = Int[]
J = Int[]
V = Float64[]
for (ridx, rid) in enumerate(A.reactions(m))
for (smid, v) in m.reactions[rid].stoichiometry
push!(I, midxs[smid])
push!(J, ridx)
push!(V, v)
end
end
sparse(I, J, V, A.n_metabolites(m), A.n_reactions(m))
end
function A.coupling(m::Model)
ridxs = Dict(rid => idx for (idx, rid) in enumerate(A.reactions(m)))
I = Int[]
J = Int[]
V = Float64[]
for (cidx, cid) in enumerate(A.couplings(m))
for (rid, v) in m.couplings[cid].reaction_weights
push!(I, cidx)
push!(J, ridxs[rid])
push!(V, v)
end
end
sparse(I, J, V, A.n_couplings(m), A.n_reactions(m))
end
A.bounds(m::Model) = (
[m.reactions[rid].lower_bound for rid in A.reactions(m)],
[m.reactions[rid].upper_bound for rid in A.reactions(m)],
)
A.coupling_bounds(m::Model) = (
[m.couplings[cid].lower_bound for cid in A.couplings(m)],
[m.couplings[cid].upper_bound for cid in A.couplings(m)],
)
A.balance(m::Model) =
sparse(Float64[m.metabolites[mid].balance for mid in A.metabolites(m)])
A.objective(m::Model) =
sparse(Float64[m.reactions[rid].objective_coefficient for rid in A.reactions(m)])
A.reaction_gene_association_dnf(m::Model, id::String) = m.reactions[id].gene_association_dnf
A.reaction_gene_products_available(m::Model, id::String, fn::Function) =
A.reaction_gene_products_available_from_dnf(m, id, fn)
A.reaction_stoichiometry(m::Model, id::String) = m.reactions[id].stoichiometry
A.metabolite_formula(m::Model, id::String) = m.metabolites[id].formula
A.metabolite_charge(m::Model, id::String) = m.metabolites[id].charge
A.metabolite_compartment(m::Model, id::String) = m.metabolites[id].compartment
A.coupling_weights(m::Model, id::String) = m.couplings[id].reaction_weights
A.load(::Type{Model}, path::String) = S.deserialize(path)
A.save(m::Model, path::String) = S.serialize(path, m)
A.filename_extensions(::Type{Model}) = ["canonical-serialized-fbc"]
function Base.convert(::Type{Model}, x::A.AbstractFBCModel)
(lbs, ubs) = A.bounds(x)
(clbs, cubs) = A.coupling_bounds(x)
Model(
reactions = Dict(
r => Reaction(
name = A.reaction_name(x, r),
lower_bound = lb,
upper_bound = ub,
stoichiometry = A.reaction_stoichiometry(x, r),
objective_coefficient = o,
gene_association_dnf = A.reaction_gene_association_dnf(x, r),
annotations = A.reaction_annotations(x, r),
notes = A.reaction_notes(x, r),
) for (r, o, lb, ub) in zip(A.reactions(x), A.objective(x), lbs, ubs)
),
metabolites = Dict(
m => Metabolite(
name = A.metabolite_name(x, m),
balance = b,
formula = A.metabolite_formula(x, m),
charge = A.metabolite_charge(x, m),
compartment = A.metabolite_compartment(x, m),
annotations = A.metabolite_annotations(x, m),
notes = A.metabolite_notes(x, m),
) for (m, b) in zip(A.metabolites(x), A.balance(x))
),
genes = Dict(
g => Gene(
name = A.gene_name(x, g),
annotations = A.gene_annotations(x, g),
notes = A.gene_notes(x, g),
) for g in A.genes(x)
),
couplings = Dict(
c => Coupling(
name = A.coupling_name(x, c),
lower_bound = lb,
upper_bound = ub,
reaction_weights = A.coupling_weights(x, c),
annotations = A.coupling_annotations(x, c),
notes = A.coupling_notes(x, c),
) for (c, lb, ub) in zip(A.couplings(x), clbs, cubs)
),
)
end
end # module CanonicalModel
The definition contains several main parts:
- the data structures for the model and all the main parts
- overloaded accessors that provide generic access for the things in the model
- overloaded loading and saving functions, together with the declaration of the common model suffixes
- a conversion function that can extract data using accessors from any other
AbstractFBCModel
and construct the canonical model.
Notably, the default file extension is chosen as very unwieldy so that no one ever really exchanges data using this model type.
Testing your model definition
Apart from making sure that the accessors work by usual unit tests, you can use 2 testing functions that scrutinize the expected properties of the model type both solely as a type, and using an example model file. These allow you to discover potential problems, as well as build a self-updating test suite for your model that provides long-term sustainability and quality assurance.
Running type-level tests
Typically, the test suite would run the following to check if types of everything match the expectations.
import AbstractFBCModels as A
import AbstractFBCModels.CanonicalModel: Model
A.run_fbcmodel_type_tests(Model);
Test Summary: | Pass Total Time
Model type AbstractFBCModels.CanonicalModel.Model properties | 33 33 0.1s
Making a simple model for value tests
For testing the values, you need to provide an existing file that contains the model. Let's create some contents first:
import AbstractFBCModels.CanonicalModel: Reaction, Metabolite, Gene, Coupling
m = Model()
m.metabolites["m1"] = Metabolite(compartment = "inside")
m.metabolites["m2"] = Metabolite(compartment = "outside")
m.genes["g1"] = Gene()
m.genes["g2"] = Gene()
m.reactions["forward"] = Reaction(
name = "import",
stoichiometry = Dict("m1" => -1.0, "m2" => 1.0),
gene_association_dnf = [["g1"], ["g2"]],
objective_coefficient = 1.0,
)
m.reactions["and_back"] =
Reaction(name = "export", stoichiometry = Dict("m2" => -1.0, "m1" => 1.0))
m.reactions["exchange1"] = Reaction(
name = "exchange m1",
stoichiometry = Dict("m1" => -1.0),
gene_association_dnf = [[]], # DNF encoding of a reaction that requires no gene products
)
m.reactions["exchange2"] = Reaction(
name = "exchange m2",
stoichiometry = Dict("m2" => -1.0),
gene_association_dnf = [], # DNF encoding of a reaction that never has gene products available
)
m.couplings["total_exchange_limit"] = Coupling(
lower_bound = 0,
upper_bound = 10,
reaction_weights = Dict("exchange$i" => 1.0 for i = 1:2),
)
We should immediately find the basic accessors working:
A.stoichiometry(m)
2×4 SparseArrays.SparseMatrixCSC{Float64, Int64} with 6 stored entries:
1.0 -1.0 ⋅ -1.0
-1.0 ⋅ -1.0 1.0
A.objective(m)
4-element SparseArrays.SparseVector{Float64, Int64} with 1 stored entry:
[4] = 1.0
A.coupling(m)
1×4 SparseArrays.SparseMatrixCSC{Float64, Int64} with 2 stored entries:
⋅ 1.0 1.0 ⋅
We can check various side things, such as which reactions would and would not work given all gene products disappear:
products_available = [
A.reaction_gene_products_available(m, rid, _ -> false) for
rid in ["forward", "and_back", "exchange1", "exchange2"]
]
4-element Vector{Union{Nothing, Bool}}:
false
nothing
true
false
We can now also write the model to disk and try to load it with the default loading function:
mktempdir() do dir
path = joinpath(dir, "model.canonical-serialized-fbc")
A.save(m, path)
A.load(path)
end
AbstractFBCModels.CanonicalModel.Model(
reactions = Dict{String, AbstractFBCModels.CanonicalModel.Reaction}("forward"…
metabolites = Dict{String, AbstractFBCModels.CanonicalModel.Metabolite}("m2" …
genes = Dict{String, AbstractFBCModels.CanonicalModel.Gene}("g2" => AbstractF…
couplings = Dict{String, AbstractFBCModels.CanonicalModel.Coupling}("total_ex…
)
Running the value tests
Given the data, value tests have an opportunity to scrutinize much greater amount of properties of the model implementation.
Running the tests requires a model type and an "example" model file:
mktempdir() do dir
path = joinpath(dir, "model.canonical-serialized-fbc")
A.save(m, path)
A.run_fbcmodel_file_tests(Model, path, name = "small model")
end;
Test Summary: | Pass Total Time
Model `small model' of type AbstractFBCModels.CanonicalModel.Model | 57 57 0.1s
This page was generated using Literate.jl.