Skill Tracer
Skills › Scientific Agent Skills › cobrapy › workflows.md

workflows.md

353 lines · skills/cobrapy/references/workflows.md at 5f2bc03

Open on GitHub
1# COBRApy workflows
2
3The five Python workflows below were executed with cobra 0.32.1, GLPK, and Python
43.12 on the bundled textbook model. They are small demonstrations, not validated
5predictions for an organism or condition. Run this setup first, then each workflow
6independently; each loads a fresh model. Plotting needs matplotlib/seaborn. Choose
7`OUTDIR` from the task context; authorization to produce outputs covers those files.
8
9```python
10from pathlib import Path
11import cobra
13OUTDIR = Path("cobrapy_output")
14OUTDIR.mkdir(parents=True, exist_ok=True)
15cobra.Configuration().solver = "glpk"
16cobra.Configuration().processes = 1
17```

COBRApy workflows

The five Python workflows below were executed with cobra 0.32.1, GLPK, and Python 3.12 on the bundled textbook model. They are small demonstrations, not validated predictions for an organism or condition. Run this setup first, then each workflow independently; each loads a fresh model. Plotting needs matplotlib/seaborn. Choose OUTDIR from the task context; authorization to produce outputs covers those files.

from pathlib import Path
import cobra

OUTDIR = Path("cobrapy_output")
OUTDIR.mkdir(parents=True, exist_ok=True)
cobra.Configuration().solver = "glpk"
cobra.Configuration().processes = 1
Raw lines 1 to 18
19## Workflow 1: Knockouts and synthetic-lethal candidates
21The 1% wild-type growth threshold is an analysis choice. Separate successful
22low-growth predictions from infeasible, numerical, or other unresolved solves.
23The `ids` column contains sets, including singletons for self-pairs. Never derive
24identifiers from the integer row index. GPR redundancy means one gene need not
25correspond to one reaction. A bounded subset is a screen, not an exhaustive study.
27```python
28import pandas as pd
29import matplotlib.pyplot as plt
30from cobra.io import load_model
31from cobra.flux_analysis import single_gene_deletion, double_gene_deletion
33model = load_model("textbook")
34baseline = model.slim_optimize(error_value=None)
35assert baseline > 0
36threshold = 0.01 * baseline
37single_results = single_gene_deletion(model, processes=1)
38valid_single = single_results[
39 single_results.status.eq("optimal") & single_results.growth.notna()
40].copy()
41valid_single["gene"] = valid_single.ids.map(lambda ids: next(iter(ids)))
42single_by_gene = valid_single.set_index("gene")["growth"]
43low_growth = valid_single[valid_single.growth < threshold]
44unresolved_single = single_results.drop(valid_single.index)
45target_genes = sorted(single_by_gene[single_by_gene >= 0.5 * baseline].index)[:30]
46double_results = double_gene_deletion(
47 model, gene_list1=target_genes, gene_list2=target_genes, processes=1,
49synthetic_lethals = double_results[
50 double_results.status.eq("optimal")
51 & double_results.growth.lt(threshold)
52 & double_results.ids.map(
53 lambda ids: len(ids) == 2
54 and all(single_by_gene.get(g, float("nan")) >= 0.5 * baseline for g in ids)
55 )
56].copy()
58fig, ax = plt.subplots()
59valid_single.growth.hist(bins=25, ax=ax)
60ax.axvline(baseline, color="red", linestyle="--", label="Wild type")
61ax.set(xlabel="Predicted growth (1/h)", ylabel="Number of genes")
62ax.legend()
63fig.savefig(OUTDIR / "single_deletion_distribution.png", dpi=150)
64plt.close(fig)
65# Sort identifiers for reproducible, human-readable CSVs.
66for name, table in [("single", single_results), ("double", double_results),
67 ("synthetic_lethals", synthetic_lethals)]:
68 exported = table.assign(ids=table.ids.map(lambda ids: ";".join(sorted(ids))))
69 exported.sort_values("ids").to_csv(OUTDIR / f"{name}_deletions.csv", index=False)
70print("Low-growth single mutants:", len(low_growth))
71print("Unresolved single mutants:", len(unresolved_single))
72print("Candidate synthetic-lethal pairs:", len(synthetic_lethals))
73```

Workflow 1: Knockouts and synthetic-lethal candidates

The 1% wild-type growth threshold is an analysis choice. Separate successful low-growth predictions from infeasible, numerical, or other unresolved solves. The ids column contains sets, including singletons for self-pairs. Never derive identifiers from the integer row index. GPR redundancy means one gene need not correspond to one reaction. A bounded subset is a screen, not an exhaustive study.

import pandas as pd
import matplotlib.pyplot as plt
from cobra.io import load_model
from cobra.flux_analysis import single_gene_deletion, double_gene_deletion

model = load_model("textbook")
baseline = model.slim_optimize(error_value=None)
assert baseline > 0
threshold = 0.01 * baseline
single_results = single_gene_deletion(model, processes=1)
valid_single = single_results[
    single_results.status.eq("optimal") & single_results.growth.notna()
].copy()
valid_single["gene"] = valid_single.ids.map(lambda ids: next(iter(ids)))
single_by_gene = valid_single.set_index("gene")["growth"]
low_growth = valid_single[valid_single.growth < threshold]
unresolved_single = single_results.drop(valid_single.index)
target_genes = sorted(single_by_gene[single_by_gene >= 0.5 * baseline].index)[:30]
double_results = double_gene_deletion(
    model, gene_list1=target_genes, gene_list2=target_genes, processes=1,
)
synthetic_lethals = double_results[
    double_results.status.eq("optimal")
    & double_results.growth.lt(threshold)
    & double_results.ids.map(
        lambda ids: len(ids) == 2
        and all(single_by_gene.get(g, float("nan")) >= 0.5 * baseline for g in ids)
    )
].copy()

fig, ax = plt.subplots()
valid_single.growth.hist(bins=25, ax=ax)
ax.axvline(baseline, color="red", linestyle="--", label="Wild type")
ax.set(xlabel="Predicted growth (1/h)", ylabel="Number of genes")
ax.legend()
fig.savefig(OUTDIR / "single_deletion_distribution.png", dpi=150)
plt.close(fig)
# Sort identifiers for reproducible, human-readable CSVs.
for name, table in [("single", single_results), ("double", double_results),
                    ("synthetic_lethals", synthetic_lethals)]:
    exported = table.assign(ids=table.ids.map(lambda ids: ";".join(sorted(ids))))
    exported.sort_values("ids").to_csv(OUTDIR / f"{name}_deletions.csv", index=False)
print("Low-growth single mutants:", len(low_growth))
print("Unresolved single mutants:", len(unresolved_single))
print("Candidate synthetic-lethal pairs:", len(synthetic_lethals))
Raw lines 19 to 74
75## Workflow 2: Minimal and custom media
77With `open_exchanges=False`, the **allowed nutrient universe stays fixed**. The
78optimizer still **selects the nutrient subset and its import bounds** within that
79universe; the selected nutrients and amounts are not fixed. This workflow therefore
80minimizes component count within allowed nutrients, then rechecks the selected
81medium. Opening all exchanges expands the allowed universe, permits alternative
82carbon sources, and defines a different experiment. Minimal media may be nonunique; integer tolerances can affect tiny
83imports. Report the objective, growth target and tested uptake units.
85```python
86import pandas as pd
87from cobra.io import load_model
88from cobra.medium import minimal_medium
90model = load_model("textbook")
91baseline = model.slim_optimize(error_value=None)
92assert baseline > 0
93minimal_media = {}
94rechecked_growth = {}
95for fraction in [0.25, 0.5, 0.75, 0.95]:
96 target = fraction * baseline
97 medium = minimal_medium(
98 model, min_objective_value=target,
99 minimize_components=True, open_exchanges=False,
100 )
101 if medium is None:
102 raise RuntimeError(f"No medium found for growth {target}")
103 with model:
104 model.medium = medium.to_dict()
105 growth = model.slim_optimize(error_value=None)
106 assert growth >= target - 1e-6
107 minimal_media[fraction] = medium
108 rechecked_growth[fraction] = growth
109pd.DataFrame(minimal_media).fillna(0).to_csv(OUTDIR / "minimal_media.csv")
111# Retain valid IDs and other nutrients in this model; textbook has no EX_so4_e.
112custom_medium = model.medium
113custom_medium["EX_glc__D_e"] = 5.0
114custom_medium["EX_o2_e"] = 0.0
115with model:
116 model.medium = custom_medium
117 custom_growth = model.slim_optimize(error_value=None)
118 limiting = []
119 if custom_growth > model.tolerance:
120 for exchange, bound in custom_medium.items():
121 if bound <= 0: # Doubling zero is not nutrient supplementation.
122 continue
123 with model:
124 altered = model.medium
125 altered[exchange] = 2 * bound
126 model.medium = altered
127 growth = model.slim_optimize(error_value=None)
128 if growth > 1.01 * custom_growth:
129 limiting.append((exchange, growth / custom_growth - 1))
130print("Custom growth:", custom_growth, "Candidate limiting imports:", limiting)
131```

Workflow 2: Minimal and custom media

With open_exchanges=False, the allowed nutrient universe stays fixed. The optimizer still selects the nutrient subset and its import bounds within that universe; the selected nutrients and amounts are not fixed. This workflow therefore minimizes component count within allowed nutrients, then rechecks the selected medium. Opening all exchanges expands the allowed universe, permits alternative carbon sources, and defines a different experiment. Minimal media may be nonunique; integer tolerances can affect tiny imports. Report the objective, growth target and tested uptake units.

import pandas as pd
from cobra.io import load_model
from cobra.medium import minimal_medium

model = load_model("textbook")
baseline = model.slim_optimize(error_value=None)
assert baseline > 0
minimal_media = {}
rechecked_growth = {}
for fraction in [0.25, 0.5, 0.75, 0.95]:
    target = fraction * baseline
    medium = minimal_medium(
        model, min_objective_value=target,
        minimize_components=True, open_exchanges=False,
    )
    if medium is None:
        raise RuntimeError(f"No medium found for growth {target}")
    with model:
        model.medium = medium.to_dict()
        growth = model.slim_optimize(error_value=None)
        assert growth >= target - 1e-6
    minimal_media[fraction] = medium
    rechecked_growth[fraction] = growth
pd.DataFrame(minimal_media).fillna(0).to_csv(OUTDIR / "minimal_media.csv")

# Retain valid IDs and other nutrients in this model; textbook has no EX_so4_e.
custom_medium = model.medium
custom_medium["EX_glc__D_e"] = 5.0
custom_medium["EX_o2_e"] = 0.0
with model:
    model.medium = custom_medium
    custom_growth = model.slim_optimize(error_value=None)
    limiting = []
    if custom_growth > model.tolerance:
        for exchange, bound in custom_medium.items():
            if bound <= 0:  # Doubling zero is not nutrient supplementation.
                continue
            with model:
                altered = model.medium
                altered[exchange] = 2 * bound
                model.medium = altered
                growth = model.slim_optimize(error_value=None)
                if growth > 1.01 * custom_growth:
                    limiting.append((exchange, growth / custom_growth - 1))
print("Custom growth:", custom_growth, "Candidate limiting imports:", limiting)
Raw lines 75 to 132
133## Workflow 3: Matched FVA and sampling
135FVA constraints are temporary. Explicitly install the growth floor **before**
136creating the sampler. Compare samples with FVA from that same constrained model.
137OptGP feasibility checks do not measure convergence; use independent seeds/chains,
138trace plots and effective sample size for scientific inference. Correlation from
139stoichiometric constraints is not evidence of regulation or causation.
141```python
142import matplotlib.pyplot as plt
143import seaborn as sns
144from cobra.io import load_model
145from cobra.flux_analysis import flux_variability_analysis
146from cobra.sampling import OptGPSampler
148model = load_model("textbook")
149biomass_id = "Biomass_Ecoli_core"
150baseline = model.slim_optimize(error_value=None)
151key_reactions = ["PFK", "FBA", "TPI", "GAPD", "PGK", "PGM", "ENO", "PYK"]
152fva_optimal = flux_variability_analysis(
153 model, reaction_list=key_reactions, fraction_of_optimum=1.0, processes=1,
155with model:
156 model.reactions.get_by_id(biomass_id).lower_bound = 0.9 * baseline
157 fva_sampled = flux_variability_analysis(
158 model, reaction_list=key_reactions, fraction_of_optimum=0.0, processes=1,
159 )
160 sampler = OptGPSampler(model, processes=1, thinning=100, seed=7)
161 samples = sampler.sample(500)
162 assert (sampler.validate(samples) == "v").all()
163 assert (samples[biomass_id] >= 0.9 * baseline - 1e-6).all()
165for reaction_id in key_reactions:
166 assert samples[reaction_id].min() >= fva_sampled.loc[reaction_id, "minimum"] - 1e-6
167 assert samples[reaction_id].max() <= fva_sampled.loc[reaction_id, "maximum"] + 1e-6
168fig, ax = plt.subplots()
169samples.PFK.hist(bins=25, ax=ax)
170for bound in fva_sampled.loc["PFK", ["minimum", "maximum"]]:
171 ax.axvline(bound, color="red", linestyle="--")
172ax.set(xlabel="PFK flux (mmol/gDW/h)", ylabel="Frequency")
173fig.savefig(OUTDIR / "matched_flux_distribution.png", dpi=150)
174plt.close(fig)
175correlation = samples[key_reactions].corr()
176fig, ax = plt.subplots()
177sns.heatmap(correlation, vmin=-1, vmax=1, center=0, cmap="coolwarm", ax=ax)
178fig.tight_layout()
179fig.savefig(OUTDIR / "flux_correlations.png", dpi=150)
180plt.close(fig)
181samples.to_csv(OUTDIR / "flux_samples.csv", index=False)
182fva_optimal.to_csv(OUTDIR / "fva_optimal.csv")
183fva_sampled.to_csv(OUTDIR / "fva_sampled.csv")
184```

Workflow 3: Matched FVA and sampling

FVA constraints are temporary. Explicitly install the growth floor before creating the sampler. Compare samples with FVA from that same constrained model. OptGP feasibility checks do not measure convergence; use independent seeds/chains, trace plots and effective sample size for scientific inference. Correlation from stoichiometric constraints is not evidence of regulation or causation.

import matplotlib.pyplot as plt
import seaborn as sns
from cobra.io import load_model
from cobra.flux_analysis import flux_variability_analysis
from cobra.sampling import OptGPSampler

model = load_model("textbook")
biomass_id = "Biomass_Ecoli_core"
baseline = model.slim_optimize(error_value=None)
key_reactions = ["PFK", "FBA", "TPI", "GAPD", "PGK", "PGM", "ENO", "PYK"]
fva_optimal = flux_variability_analysis(
    model, reaction_list=key_reactions, fraction_of_optimum=1.0, processes=1,
)
with model:
    model.reactions.get_by_id(biomass_id).lower_bound = 0.9 * baseline
    fva_sampled = flux_variability_analysis(
        model, reaction_list=key_reactions, fraction_of_optimum=0.0, processes=1,
    )
    sampler = OptGPSampler(model, processes=1, thinning=100, seed=7)
    samples = sampler.sample(500)
    assert (sampler.validate(samples) == "v").all()
    assert (samples[biomass_id] >= 0.9 * baseline - 1e-6).all()

for reaction_id in key_reactions:
    assert samples[reaction_id].min() >= fva_sampled.loc[reaction_id, "minimum"] - 1e-6
    assert samples[reaction_id].max() <= fva_sampled.loc[reaction_id, "maximum"] + 1e-6
fig, ax = plt.subplots()
samples.PFK.hist(bins=25, ax=ax)
for bound in fva_sampled.loc["PFK", ["minimum", "maximum"]]:
    ax.axvline(bound, color="red", linestyle="--")
ax.set(xlabel="PFK flux (mmol/gDW/h)", ylabel="Frequency")
fig.savefig(OUTDIR / "matched_flux_distribution.png", dpi=150)
plt.close(fig)
correlation = samples[key_reactions].corr()
fig, ax = plt.subplots()
sns.heatmap(correlation, vmin=-1, vmax=1, center=0, cmap="coolwarm", ax=ax)
fig.tight_layout()
fig.savefig(OUTDIR / "flux_correlations.png", dpi=150)
plt.close(fig)
samples.to_csv(OUTDIR / "flux_samples.csv", index=False)
fva_optimal.to_csv(OUTDIR / "fva_optimal.csv")
fva_sampled.to_csv(OUTDIR / "fva_sampled.csv")
Raw lines 133 to 185
186## Workflow 4: Product coupling at a common growth floor
188For affected reactions whose bounds already allow zero, deletion removes feasible
189flux states; it cannot increase the global product maximum if other constraints
190are identical. Verify this premise: resetting a forced nonzero reaction to zero
191can instead relax an original requirement. Instead, this screen asks whether
192minimum acetate secretion increases at the **same absolute growth floor**, while
193recording growth capacity and maximum secretion. A higher minimum is a conditional
194coupling prediction, not proof of better experimental production. Ranking only one
195arbitrary FBA solution can mistake alternate optima for improved production.
197```python
198import pandas as pd
199import matplotlib.pyplot as plt
200from cobra.io import load_model
201from cobra.flux_analysis import production_envelope, flux_variability_analysis
203model = load_model("textbook")
204biomass_id = "Biomass_Ecoli_core" # Explicit model-specific objective, not solver variable order.
205product_id = "EX_ac_e"
206baseline = model.slim_optimize(error_value=None)
207growth_floor = 0.1 * baseline
208with model:
209 model.reactions.get_by_id(biomass_id).lower_bound = growth_floor
210 envelope = production_envelope(
211 model, reactions=["EX_glc__D_e"], objective=product_id,
212 carbon_sources=["EX_glc__D_e"], points=12,
213 )
214 wt_range = flux_variability_analysis(
215 model, reaction_list=[product_id], fraction_of_optimum=0.0, processes=1,
216 ).loc[product_id]
217 records = []
218 for gene in sorted(model.genes, key=lambda g: g.id):
219 with model:
220 gene.knock_out()
221 growth_solution = model.optimize()
222 record = {"gene": gene.id, "status": growth_solution.status}
223 if growth_solution.status == "optimal":
224 product_range = flux_variability_analysis(
225 model, reaction_list=[product_id], fraction_of_optimum=0.0,
226 processes=1,
227 ).loc[product_id]
228 record.update(max_growth=growth_solution.objective_value,
229 product_min=product_range.minimum,
230 product_max=product_range.maximum)
231 records.append(record)
232knockout_results = pd.DataFrame(records)
233feasible = knockout_results[knockout_results.status.eq("optimal")].copy()
234# Bounds can differ by solver tolerance, but a strict subset cannot raise the maximum.
235assert (feasible.product_max <= wt_range.maximum + 1e-6).all()
236candidates = feasible[feasible.product_min > wt_range.minimum + 1e-6]
237candidates = candidates.sort_values("product_min", ascending=False)
238knockout_results.to_csv(OUTDIR / "product_coupling_screen.csv", index=False)
240plot_data = envelope.assign(uptake=-envelope.EX_glc__D_e).sort_values("uptake")
241fig, ax = plt.subplots()
242ax.plot(plot_data.uptake, plot_data.flux_maximum, label="Maximum acetate flux")
243ax.plot(plot_data.uptake, plot_data.flux_minimum, label="Minimum acetate flux")
244ax.set(xlabel="Glucose uptake (mmol/gDW/h)", ylabel="Acetate secretion (mmol/gDW/h)")
245ax.legend()
246fig.savefig(OUTDIR / "production_envelope.png", dpi=150)
247plt.close(fig)
248print("Conditional coupling candidates:", len(candidates))
249```
251Do not automatically combine the best single knockouts: rerun combined genotypes
252under the identical medium and growth requirement. Report failed statuses and any
253trade-off in growth or secretion bounds. Yield requires a nonzero, correctly
254oriented substrate flux; flux units are not yield units.

Workflow 4: Product coupling at a common growth floor

For affected reactions whose bounds already allow zero, deletion removes feasible flux states; it cannot increase the global product maximum if other constraints are identical. Verify this premise: resetting a forced nonzero reaction to zero can instead relax an original requirement. Instead, this screen asks whether minimum acetate secretion increases at the same absolute growth floor, while recording growth capacity and maximum secretion. A higher minimum is a conditional coupling prediction, not proof of better experimental production. Ranking only one arbitrary FBA solution can mistake alternate optima for improved production.

import pandas as pd
import matplotlib.pyplot as plt
from cobra.io import load_model
from cobra.flux_analysis import production_envelope, flux_variability_analysis

model = load_model("textbook")
biomass_id = "Biomass_Ecoli_core"  # Explicit model-specific objective, not solver variable order.
product_id = "EX_ac_e"
baseline = model.slim_optimize(error_value=None)
growth_floor = 0.1 * baseline
with model:
    model.reactions.get_by_id(biomass_id).lower_bound = growth_floor
    envelope = production_envelope(
        model, reactions=["EX_glc__D_e"], objective=product_id,
        carbon_sources=["EX_glc__D_e"], points=12,
    )
    wt_range = flux_variability_analysis(
        model, reaction_list=[product_id], fraction_of_optimum=0.0, processes=1,
    ).loc[product_id]
    records = []
    for gene in sorted(model.genes, key=lambda g: g.id):
        with model:
            gene.knock_out()
            growth_solution = model.optimize()
            record = {"gene": gene.id, "status": growth_solution.status}
            if growth_solution.status == "optimal":
                product_range = flux_variability_analysis(
                    model, reaction_list=[product_id], fraction_of_optimum=0.0,
                    processes=1,
                ).loc[product_id]
                record.update(max_growth=growth_solution.objective_value,
                              product_min=product_range.minimum,
                              product_max=product_range.maximum)
            records.append(record)
knockout_results = pd.DataFrame(records)
feasible = knockout_results[knockout_results.status.eq("optimal")].copy()
# Bounds can differ by solver tolerance, but a strict subset cannot raise the maximum.
assert (feasible.product_max <= wt_range.maximum + 1e-6).all()
candidates = feasible[feasible.product_min > wt_range.minimum + 1e-6]
candidates = candidates.sort_values("product_min", ascending=False)
knockout_results.to_csv(OUTDIR / "product_coupling_screen.csv", index=False)

plot_data = envelope.assign(uptake=-envelope.EX_glc__D_e).sort_values("uptake")
fig, ax = plt.subplots()
ax.plot(plot_data.uptake, plot_data.flux_maximum, label="Maximum acetate flux")
ax.plot(plot_data.uptake, plot_data.flux_minimum, label="Minimum acetate flux")
ax.set(xlabel="Glucose uptake (mmol/gDW/h)", ylabel="Acetate secretion (mmol/gDW/h)")
ax.legend()
fig.savefig(OUTDIR / "production_envelope.png", dpi=150)
plt.close(fig)
print("Conditional coupling candidates:", len(candidates))

Do not automatically combine the best single knockouts: rerun combined genotypes under the identical medium and growth requirement. Report failed statuses and any trade-off in growth or secretion bounds. Yield requires a nonzero, correctly oriented substrate flux; flux units are not yield units.

Raw lines 186 to 255
256## Workflow 5: Validation and bounded diagnostics
258Feasibility, chemistry, connectivity, and loop constraints answer different
259questions. Missing formulas/charges make chemical validation incomplete. Boundary,
260biomass and pseudoreactions have deliberate bookkeeping exceptions; inspect them
261rather than declaring every nonzero imbalance an error. A direction-aware local
262dead-end screen is only a topology heuristic, not flux consistency.
264```python
265import pandas as pd
266from cobra.io import load_model
267from cobra.flux_analysis import find_blocked_reactions, flux_variability_analysis
269model = load_model("textbook")
270solution = model.optimize()
271status = solution.status
272objective_value = solution.objective_value if status == "optimal" else None
273missing_chemistry = [m.id for m in model.metabolites if not m.formula or m.charge is None]
274excluded_sbo = {"SBO:0000627", "SBO:0000628", "SBO:0000629", "SBO:0000631", "SBO:0000632"}
275excluded_ids = {"Biomass_Ecoli_core"} | {r.id for r in model.boundary}
276unbalanced = {}
277unchecked = []
278for reaction in model.reactions:
279 if reaction.id in excluded_ids or reaction.annotation.get("sbo") in excluded_sbo:
280 continue
281 if any(m.id in missing_chemistry for m in reaction.metabolites):
282 unchecked.append(reaction.id)
283 continue
284 try:
285 imbalance = reaction.check_mass_balance()
286 except ValueError:
287 unchecked.append(reaction.id)
288 continue
289 if imbalance:
290 unbalanced[reaction.id] = imbalance
292tol = model.tolerance
293dead_end_candidates = []
294for met in model.metabolites:
295 producers, consumers = set(), set()
296 for reaction in met.reactions:
297 coefficient = reaction.metabolites[met]
298 if (coefficient > 0 and reaction.upper_bound > tol) or (coefficient < 0 and reaction.lower_bound < -tol):
299 producers.add(reaction.id)
300 if (coefficient < 0 and reaction.upper_bound > tol) or (coefficient > 0 and reaction.lower_bound < -tol):
301 consumers.add(reaction.id)
302 if not producers or not consumers:
303 dead_end_candidates.append(met.id)
305# Exact stoichiometric duplicates only; compare bounds/GPRs before merging anything.
306seen, duplicates = {}, []
307for reaction in model.reactions:
308 key = tuple(sorted((m.id, c) for m, c in reaction.metabolites.items()))
309 if key in seen:
310 duplicates.append((seen[key], reaction.id))
311 else:
312 seen[key] = reaction.id
313orphan_genes = [g.id for g in model.genes if not g.reactions]
314blocked, loop_sensitive = None, None
315if status == "optimal":
316 blocked = find_blocked_reactions(model, open_exchanges=False, processes=1)
317 subset = ["PFK", "FRD7", "SUCDi"]
318 ordinary = flux_variability_analysis(model, subset, processes=1)
319 loopless = flux_variability_analysis(model, subset, loopless="fastSNP", processes=1)
320 loop_sensitive = [rid for rid in subset if
321 ordinary.loc[rid, "maximum"] > loopless.loc[rid, "maximum"] + 1e-6
322 or ordinary.loc[rid, "minimum"] < loopless.loc[rid, "minimum"] - 1e-6]
323else:
324 print("[FAIL] Original model status:", status)
325 with model:
326 medium = {r.id: 1000.0 for r in model.exchanges}
327 model.medium = medium
328 diagnostic = model.optimize()
329 print("Open-import diagnostic status:", diagnostic.status)
330 # Do not overwrite the original status or claim a structural cause from this alone.
331report = {
332 "model_id": model.id, "status": status, "objective_value": objective_value,
333 "missing_chemistry": len(missing_chemistry), "unchecked_internal": len(unchecked),
334 "unbalanced_internal": len(unbalanced), "dead_end_candidates": len(dead_end_candidates),
335 "exact_duplicate_pairs": len(duplicates), "orphan_genes": len(orphan_genes),
336 "blocked_in_current_medium": None if blocked is None else len(blocked),
337 "loop_sensitive_in_tested_subset": None if loop_sensitive is None else len(loop_sensitive),
339pd.DataFrame([report]).to_csv(OUTDIR / "validation_report.csv", index=False)
340print(report)
341```
343A negative loop screen on three reactions is not a whole-model thermodynamic
344certificate. Growth, knockouts, media, and sampling all depend on the model's
345objective, uptake bounds, maintenance costs, and solver tolerances.

Workflow 5: Validation and bounded diagnostics

Feasibility, chemistry, connectivity, and loop constraints answer different questions. Missing formulas/charges make chemical validation incomplete. Boundary, biomass and pseudoreactions have deliberate bookkeeping exceptions; inspect them rather than declaring every nonzero imbalance an error. A direction-aware local dead-end screen is only a topology heuristic, not flux consistency.

import pandas as pd
from cobra.io import load_model
from cobra.flux_analysis import find_blocked_reactions, flux_variability_analysis

model = load_model("textbook")
solution = model.optimize()
status = solution.status
objective_value = solution.objective_value if status == "optimal" else None
missing_chemistry = [m.id for m in model.metabolites if not m.formula or m.charge is None]
excluded_sbo = {"SBO:0000627", "SBO:0000628", "SBO:0000629", "SBO:0000631", "SBO:0000632"}
excluded_ids = {"Biomass_Ecoli_core"} | {r.id for r in model.boundary}
unbalanced = {}
unchecked = []
for reaction in model.reactions:
    if reaction.id in excluded_ids or reaction.annotation.get("sbo") in excluded_sbo:
        continue
    if any(m.id in missing_chemistry for m in reaction.metabolites):
        unchecked.append(reaction.id)
        continue
    try:
        imbalance = reaction.check_mass_balance()
    except ValueError:
        unchecked.append(reaction.id)
        continue
    if imbalance:
        unbalanced[reaction.id] = imbalance

tol = model.tolerance
dead_end_candidates = []
for met in model.metabolites:
    producers, consumers = set(), set()
    for reaction in met.reactions:
        coefficient = reaction.metabolites[met]
        if (coefficient > 0 and reaction.upper_bound > tol) or (coefficient < 0 and reaction.lower_bound < -tol):
            producers.add(reaction.id)
        if (coefficient < 0 and reaction.upper_bound > tol) or (coefficient > 0 and reaction.lower_bound < -tol):
            consumers.add(reaction.id)
    if not producers or not consumers:
        dead_end_candidates.append(met.id)

# Exact stoichiometric duplicates only; compare bounds/GPRs before merging anything.
seen, duplicates = {}, []
for reaction in model.reactions:
    key = tuple(sorted((m.id, c) for m, c in reaction.metabolites.items()))
    if key in seen:
        duplicates.append((seen[key], reaction.id))
    else:
        seen[key] = reaction.id
orphan_genes = [g.id for g in model.genes if not g.reactions]
blocked, loop_sensitive = None, None
if status == "optimal":
    blocked = find_blocked_reactions(model, open_exchanges=False, processes=1)
    subset = ["PFK", "FRD7", "SUCDi"]
    ordinary = flux_variability_analysis(model, subset, processes=1)
    loopless = flux_variability_analysis(model, subset, loopless="fastSNP", processes=1)
    loop_sensitive = [rid for rid in subset if
                      ordinary.loc[rid, "maximum"] > loopless.loc[rid, "maximum"] + 1e-6
                      or ordinary.loc[rid, "minimum"] < loopless.loc[rid, "minimum"] - 1e-6]
else:
    print("[FAIL] Original model status:", status)
    with model:
        medium = {r.id: 1000.0 for r in model.exchanges}
        model.medium = medium
        diagnostic = model.optimize()
        print("Open-import diagnostic status:", diagnostic.status)
    # Do not overwrite the original status or claim a structural cause from this alone.
report = {
    "model_id": model.id, "status": status, "objective_value": objective_value,
    "missing_chemistry": len(missing_chemistry), "unchecked_internal": len(unchecked),
    "unbalanced_internal": len(unbalanced), "dead_end_candidates": len(dead_end_candidates),
    "exact_duplicate_pairs": len(duplicates), "orphan_genes": len(orphan_genes),
    "blocked_in_current_medium": None if blocked is None else len(blocked),
    "loop_sensitive_in_tested_subset": None if loop_sensitive is None else len(loop_sensitive),
}
pd.DataFrame([report]).to_csv(OUTDIR / "validation_report.csv", index=False)
print(report)

A negative loop screen on three reactions is not a whole-model thermodynamic certificate. Growth, knockouts, media, and sampling all depend on the model's objective, uptake bounds, maintenance costs, and solver tolerances.

Raw lines 256 to 346
347## Official sources
349- [Deletion tutorial](https://cobrapy.readthedocs.io/en/latest/deletions.html)
350- [Media tutorial](https://cobrapy.readthedocs.io/en/latest/media.html)
351- [Sampling tutorial](https://cobrapy.readthedocs.io/en/latest/sampling.html)
352- [FVA API and loopless methods](https://cobrapy.readthedocs.io/en/latest/autoapi/cobra/flux_analysis/variability/index.html)
353- [Production-envelope API](https://cobrapy.readthedocs.io/en/latest/autoapi/cobra/flux_analysis/phenotype_phase_plane/index.html)