Growth media and conditions#
A flux balance analysis (FBA) result applies to one medium. The medium is the set of open exchange reactions and the width of their bounds. A condition stores a medium as reviewable data, where a script of bound-setting calls hides it.
Functions#
MATLAB |
Python |
Purpose |
|---|---|---|
find the exchange reactions |
||
set a whole medium and close all other uptakes |
||
set the bounds of one reaction |
||
find the smallest medium that supports growth |
||
apply a condition file |
Setup#
The model is yeast-GEM.yml from docs/data/, with an aerobic minimal glucose medium applied.
Open exchanges#
An exchange reaction connects a boundary metabolite to nothing, so its flux is the rate at which the metabolite enters or leaves the system. yeast-GEM writes its exchange reactions as metabolite <=>, so a negative flux is entry and a positive flux is exit. The medium is the set of exchange reactions with a negative lower bound. A model that writes uptake as => metabolite has the opposite sign (Solvers).
model = readYAMLmodel('yeast-GEM.yml');
[exchangeRxns, exchangeIdx] = getExchangeRxns(model);
fprintf('%d exchange reactions\n', numel(exchangeRxns));
open = sort(exchangeIdx(model.lb(exchangeIdx) < 0));
for i = 1:numel(open)
fprintf(' %s %-28s %8.1f\n', model.rxns{open(i)}, ...
model.rxnNames{open(i)}, -model.lb(open(i)));
end
273 exchange reactions
r_1654 ammonium exchange 1000.0
r_1714 D-glucose exchange 1.0
r_1832 H+ exchange 1000.0
r_1861 iron(2+) exchange 1000.0
r_1992 oxygen exchange 1000.0
r_2005 phosphate exchange 1000.0
r_2020 potassium exchange 1000.0
r_2049 sodium exchange 1000.0
r_2060 sulphate exchange 1000.0
r_2100 water exchange 1000.0
r_4593 chloride exchange 1000.0
r_4594 Cu2(+) exchange 1000.0
r_4595 Mn(2+) exchange 1000.0
r_4596 Zn(2+) exchange 1000.0
r_4597 Mg(2+) exchange 1000.0
r_4600 Ca(2+) exchange 1000.0
getExchangeRxns identifies an exchange reaction by its stoichiometry (a reaction without substrates or without products), so it does not depend on a naming convention. reactionType selects a subset. The values 'uptake', 'excrete', 'reverse' and 'blocked' classify a reaction by what its bounds permit. The values 'in' and 'out' classify by the side of the boundary metabolite and ignore the bounds.
The filter in the code and 'uptake' answer different questions. 'uptake' returns the reactions that can take up and cannot secrete. Every open exchange reaction in yeast-GEM also has an upper bound of 1000, so it can secrete and is classified 'reverse'. The medium is the selection model.lb < 0.
from raven_toolbox.io import read_yaml_model
model = read_yaml_model("yeast-GEM.yml")
shipped = dict(model.medium) # keep it; later steps restore from here
print(len(model.exchanges), "exchange reactions")
for rxn_id, uptake in sorted(model.medium.items()):
print(f" {rxn_id} {model.reactions.get_by_id(rxn_id).name:<28} {uptake:>8}")
270 exchange reactions
r_1654 ammonium exchange 1000.0
r_1714 D-glucose exchange 1.0
r_1832 H+ exchange 1000.0
r_1861 iron(2+) exchange 1000.0
r_1992 oxygen exchange 1000.0
r_2005 phosphate exchange 1000.0
r_2020 potassium exchange 1000.0
r_2049 sodium exchange 1000.0
r_2060 sulphate exchange 1000.0
r_2100 water exchange 1000.0
r_4593 chloride exchange 1000.0
r_4594 Cu2(+) exchange 1000.0
r_4595 Mn(2+) exchange 1000.0
r_4596 Zn(2+) exchange 1000.0
r_4597 Mg(2+) exchange 1000.0
r_4600 Ca(2+) exchange 1000.0
model.medium is a dictionary of {exchange identifier: maximum uptake rate}. The rate is a positive number, and cobrapy flips the sign, so an entry of 1.0 means a lower bound of -1.0. The dictionary lists the open exchange reactions, and the loop prints 16 of 270 reactions.
The toolboxes count the exchange reactions differently, and both counts are correct. cobrapy sorts reactions with a single metabolite into exchanges, demands and sinks, and getExchangeRxns returns all of them. In yeast-GEM the difference is three reactions, r_2111 (growth) and the sinks r_4062 and r_4064, so MATLAB reports 273 where model.exchanges reports 270.
Changing one nutrient#
model = setParam(model, 'lb', 'r_1714', -10); % D-glucose exchange
sol = solveLP(model);
idx = getIndexes(model, 'r_1714', 'rxns');
fprintf('[%g %g] -> %.4f /h\n', model.lb(idx), model.ub(idx), sol.f);
[-10 1000] -> 0.8370 /h
setParam takes a reaction identifier, a list of identifiers or indices, and applies one value to all of them or one value to each. 'lb', 'ub' and 'eq' set bounds, and 'obj' sets the objective coefficient.
from raven_toolbox.conditions import set_reaction_bounds
glucose = model.reactions.get_by_id("r_1714")
set_reaction_bounds(glucose, -10.0, 1000.0)
print(glucose.bounds, "->", f"{model.slim_optimize():.4f} /h")
(-10.0, 1000.0) -> 0.8370 /h
reaction.bounds = (-10.0, 1000.0) has the same effect for ordinary edits. set_reaction_bounds writes the underlying attributes directly, so it also accepts lb > ub, which the setter of cobrapy rejects as invalid. A condition uses this to force flux through a reaction with a sentinel bound.
slim_optimize returns the objective value without building a Solution, which is cheaper in a loop over many bound settings.
Defining a whole medium#
Assigning to the medium replaces it, and every exchange reaction that is not in the dictionary is closed. The assignment suits an exact recipe. A change to one entry edits a copy of dict(model.medium).
% every uptake closed
[~, exchIdx] = getExchangeRxns(model);
modelClosed = setParam(model, 'lb', model.rxns(exchIdx), 0);
sol = solveLP(modelClosed);
fprintf('growth with nothing to eat: %.4f /h\n', sol.f);
% the shipped recipe, with more glucose
model = setParam(model, 'lb', 'r_1714', -10);
sol = solveLP(model);
fprintf('complete medium: %.4f /h\n', sol.f);
growth with nothing to eat: /h
complete medium: 0.8370 /h
The first line prints no number. With every uptake closed, the linear programme (LP) is infeasible, solveLP returns an empty array in sol.f and sets sol.stat to -1, and fprintf prints nothing for an empty argument. sol.stat is checked before the value is read (Simulating growth with FBA).
setExchangeBounds corresponds to the assignment of model.medium. It takes metabolite names, sets their exchange bounds, and with the default closeOthers closes every other uptake. mediaOnly restricts it to the extracellular compartment, which leaves intracellular sink reactions unchanged.
with model:
model.medium = {} # every uptake closed
print(f"growth with nothing to eat: {model.slim_optimize():.4f} /h")
with model:
model.medium = {"r_1714": 10.0, "r_1992": 1000.0} # only glucose and oxygen
growth = round(model.slim_optimize(), 4) + 0.0 # + 0.0 turns solver noise -0.0 into 0.0
print(f"glucose and oxygen alone: {growth:.4f} /h")
model.medium = {**shipped, "r_1714": 10.0} # the shipped recipe, more glucose
print(f"complete medium: {model.slim_optimize():.4f} /h")
growth with nothing to eat: nan /h
glucose and oxygen alone: 0.0000 /h
complete medium: 0.8370 /h
Glucose and oxygen are not sufficient. yeast-GEM also needs nitrogen, phosphate, sulphate and several ions. A medium built from dict(model.medium) with individual entries edited keeps the rest of the recipe, and a medium written out in full does not.
The three results are three different states. nan means that the LP had no solution, 0.0000 means that it was solved with an optimum of zero, and 0.8370 means that it was solved and the model grows. slim_optimize returns nan in the first case and does not raise. model.optimize().status shows the cause of an unexpected number.
Anaerobic growth and conditions#
An anaerobic yeast-GEM needs more than a closed oxygen exchange. Without oxygen the model cannot make sterols and unsaturated fatty acids, so they are supplied. Heme a leaves the cofactor pseudoreaction, the biomass gains an FADH2 term, and two reactions that glucose represses are blocked. These edits belong together, and both toolboxes read them from one YAML file, a condition, which is reviewed as data.
anaerobic.yml is that file, transcribed from anaerobicModel.m of yeast-GEM, without its step for the amino-acid ratios:
cofactor_pseudoreaction:
rxn_id: r_4598
remove_mets:
- { met: s_3714 } # heme a
charge_balance_met: s_0794 # H+
biomass_stoichiometry_delta:
rxn_id: r_4041 # biomass pseudoreaction
add:
- { met: s_0689, coef: 0.08 } # FADH2
- { met: s_0687, coef: -0.08 } # FAD
- { met: s_0794, coef: -0.16 } # H+
bounds:
- { rxn: r_1992, lb: 0 } # no oxygen
- { rxn: r_1757, lb: -1000 } # ergosterol, supplied
- { rxn: r_2189, lb: -1000 } # oleate, supplied
- { rxn: r_1967, lb: -1000 } # nicotinate
- { rxn: r_0714, lb: 0, ub: 0 } # MDH2, repressed on glucose
The schema has five parts: an optional prelude that resets the exchange reactions, an optional edit of the cofactor pseudoreaction, an optional change of the biomass stoichiometry, a list of bounds per reaction and an expected_uptake_count, which triggers a warning when it differs from the number of bounds entries with a lower bound of -1000.
Removing a metabolite from the cofactor pseudoreaction leaves its charge unbalanced, and charge_balance_met names the metabolite whose coefficient absorbs the difference. Both toolboxes recompute the coefficient and do not store it, so the file states the intent.
modelAnaerobic = applyCondition(model, 'anaerobic.yml');
sol = solveLP(modelAnaerobic);
fprintf('anaerobic growth: %.4f /h\n', sol.f);
anaerobic growth: 0.1615 /h
applyCondition reads the file with parseYAML, a YAML reader written in MATLAB.
from raven_toolbox.conditions import apply_condition, load_condition
condition = load_condition("anaerobic.yml")
print("bounds changed:", len(condition["bounds"]))
anaerobic = apply_condition(model.copy(), condition)
print(f"anaerobic growth: {anaerobic.slim_optimize():.4f} /h")
bounds changed: 12
anaerobic growth: 0.1615 /h
apply_condition edits the model in place and returns it, so a copy should be passed when the aerobic model is needed. It also accepts the path: apply_condition(model, "anaerobic.yml"). load_condition parses the file without applying it, which allows a condition to be inspected or edited before it is applied.
The growth rate of 0.1615 /h compares with the 0.8370 /h that the same model reached aerobically in Defining a whole medium. Both are on 10 mmol glucose, because the condition is applied to the model as that section left it. Fermentation yields much less ATP per glucose than respiration, and the ratio of the two growth rates is the comparison.
Minimal medium#
A medium copied from a paper often contains more than the model needs. getMinimalMedium and minimal_medium of cobrapy search for the smallest set of uptakes that supports a given growth rate. The result shows which components are necessary, and it shows a nutrient that the model does not need because a gap-filled reaction produces it internally.
% getMinimalMedium solves a MILP: GLPK warns and solves it, setRavenSolver('gurobi') is faster
sol = solveLP(model);
medium = getMinimalMedium(model, 'minGrowth', 0.9 * sol.f);
[Warning: Solving a MILP with GLPK. This works for small problems, but for larger problems Gurobi is much faster and more reliable; select it with setRavenSolver('gurobi'). Silence this warning with warning('off','RAVEN:glpkMILP').]
==============================================================
Minimal medium (14 of 16 candidate uptake reactions)
Target growth: 0.7533
--------------------------------------------------------------
r_1654 ammonium exchange
r_1714 D-glucose exchange
r_1861 iron(2+) exchange
r_1992 oxygen exchange
r_2005 phosphate exchange
r_2020 potassium exchange
r_2049 sodium exchange
r_2060 sulphate exchange
r_4593 chloride exchange
r_4594 Cu2(+) exchange
r_4595 Mn(2+) exchange
r_4596 Zn(2+) exchange
r_4597 Mg(2+) exchange
r_4600 Ca(2+) exchange
==============================================================
The candidates are the exchange reactions with lb < 0, so a nutrient that the model cannot take up is never proposed. minGrowth defaults to 10 % of the optimum under the current bounds, a limit loose enough that most components drop out, and it is set explicitly when the question concerns a given growth rate. verbose set to false suppresses the table and returns the identifiers.
Of the 16 open uptakes, 14 are kept. Water and H+ are dropped because other reactions supply them. getMinimalMedium solves a mixed-integer linear programme (MILP) that minimises the number of open uptakes. The GLPK that ships with RAVEN solves it as well, with a warning that recommends Gurobi. minimal_medium of cobrapy solves by default an LP that minimises the summed uptake flux (in mmol/gDW/h, all metabolites weighted equally), so it returns rates.
from cobra.medium import minimal_medium
minimal = minimal_medium(model, model.slim_optimize() * 0.9)
print(minimal.round(3).to_string())
r_1654 4.959
r_1714 9.004
r_1861 0.000
r_1992 20.139
r_2005 0.210
r_2020 0.003
r_2049 0.003
r_2060 0.105
r_4593 0.001
r_4594 0.000
r_4595 0.002
r_4596 0.001
r_4597 0.001
r_4600 0.000
The values are uptake rates. A zero is a component that the solution needs at a rate below the printed precision and is no component that the solution does without. minimize_components=True solves the MILP for the number of components, which also runs on GLPK. It returns the import fluxes of one feasible solution, which define the medium, and the rates are not minimal. Values close to the solver tolerance count as zero, and with Gurobi the answer for this model contains a heme a uptake of 1e-6. An integer value returns alternative media as columns.
Troubleshooting
The model is infeasible after setting a medium. An essential nutrient is closed. Reopen the components one at a time, or run
minimal_mediumon the working model and compare.The signs of two conventions are mixed. For an exchange written
metabolite <=>, as in yeast-GEM, uptake is a negative flux, andmodel.mediumtakes positive numbers. Both are correct, and mixing them gives wrong bounds.The model grows without a carbon source. A reaction usually produces carbon from nothing. Quality control describes the diagnosis with
canExchangeandgap_report.A result cannot be reproduced. A medium defined inside a script differs between users who edit the script. A condition file is data, and a text difference shows its changes.
See also#
Simulating growth with FBA: the solve that uses the medium.
Reading and writing models: keeping a model and its conditions in a repository.
MATLAB vs Python: the full function mapping.